はじめに

CIU (CyberAgent group Infrastructure Unit) の ML Platform チームでエンジニアをしている吉瀬です。

ML Platform では社内ユーザ向けに開発や実験のための GPU インスタンスを簡単にデプロイできる GPUaaS (GPU as a Service) というサービスを提供しています。

現在の GPUaaS ではユーザに払い出す JupyterLab などのインスタンスを実現する Kubernetes のオブジェクトリソース管理をカスタムリソース+ カスタムコントローラで管理しています。

今回の記事では、チームで開発・運用しているカスタムコントローラ gpuaastask-controller-manager の概要と Pipeline Framework を中心とした内部設計を紹介します。

背景

私の所属する部署 CIU (CyberAgent group Infrastructure Unit) では 「Cycloud」 というプライベートクラウドを運用しており、サイバーエージェント全体のパブリッククラウド利用料などのキャッシュアウト削減を目標の一つとして掲げたプライベートクラウド事業を社内向けに展開しています。

その一環として、私たちが管理するデータセンターに存在するオンプレミスのクラスタ上の GPU 等の計算リソースを抽象化し、社内ユーザーが利用しやすい形で提供しています。

その提供方法の一つが GPUaaS (GPU as a Service) です。GPUaaS は、ブラウザから GPU を利用できる Jupyter Notebook 環境やカスタムコンテナ環境のインスタンスを作成できるサービスです。

このサービスでユーザに提供されるインスタンスの実態は、私たちの運用する Kubernetes クラスタ上の StatefulSet や Service などのリソース群として管理されています。

現在の GPUaaS のアーキテクチャとして採用しているカスタムリソース + カスタムコントローラの構成に移る以前、旧来の GPUaaS ではサーバーが HTTP リクエストを直接受け取り、これらを依存順に手続き的に作成していました。このため管理リソースの宣言的な管理ができず、非同期な状態変化に対応した処理やリソースの状態に依存したテストの拡充なども難しい状態であったため、現在のカスタムリソース + カスタムコントローラの構成にサービスのアーキテクチャの再設計を含むリプレイス作業を行いました。

このリプレイス作業ではバックエンドサーバやクライアントのAPI設計も含めた抜本的な刷新を行いましたが、本記事ではサーバーサイドのうち、特に Kubernetes のリソースの状態管理を担うコンポーネントである gpuaastask-controller-manager に絞って設計の紹介を行います。ユーザ向け API を受け付けるバックエンドサーバ(gpuaas-manager)は全体像の図に登場しますが、あくまで概要の紹介に留めて詳細は割愛します。

本記事で登場する ML Platform の用語

この記事では ML Platform 独自の語彙が出てきます。先に軽く触れておきます。

  • GPUaaSTask
    • GPUaaS で最終的に生成される StatefulSet / Service / Ingress(または HTTPRoute)/ Secret / ConfigMap などのリソース群の総称 

なぜカスタムコントローラにしたか

GPUaaS のサービスでカスタムコントローラを導入した理由、解決したかった課題として以下のような事情がありました。

  1. Kubernetes のリソースを使用しているのに宣言的に管理されていない、できない状態になっている
    • StatefulSetService といった Kubernetes リソースを使っているにもかかわらず、「あるべき状態」を宣言してそれを収束させるという Kubernetes 本来の宣言的なリソース管理という運用ができていませんでした。作成したリソースは削除されるまでは基本的に作ったままで、今インスタンスがどういう状態にあるのかもクライアントからは正確に把握しづらい状態でした。
  2. 命令的(手続き的)にリソースを作っているため、時間のかかる非同期処理を待ち受けるのが難しい
      • リソースを「作って終わり」の命令的モデルだったため、例えば Ingress のエンドポイントが実際に疎通可能になるまで待つ(IP が割り当たり、証明書が発行され、ヘルスチェックが通る)といった、状態変化までに時間がかかる処理を並行して待ち受けるのが困難でした。
      • 実際、API 上はインスタンスが作成済みでも、ユーザが発行された JupyterLab の URL を開くとエンドポイントが確立できておらず Not Found エラーになってしまっていました。
  3. 巨大な main 関数に処理が集約され、テストがほぼ書けない
    • 処理が一つの main 関数でまとめて実行されるような構造になっており、Integration Test や E2E Test どころか、Unit Test すら書くのが難しい状態でした。
    • テストが書けないので、変更を入れるたびにリグレッションが怖い、という悪循環に陥っていました。

上記の3つの課題について解決策を示してくれるのがカスタムコントローラを作成するという方法でした。それぞれの簡単な解決した方法を記します。

  1. カスタムリソースによる宣言的なリソース管理
    • ユーザの要求を TaskDeployment という宣言的なカスタムリソースとして表現し、その差分をコントローラーがあるべき状態に修正し続ける(リコンサイル)、という Kubernetes ネイティブなモデルに移行しました。状態はすべてリソースの .status として可視化されます。
  2. カスタムコントローラによる冪等な処理
    • コントローラーのリコンサイル ループは「あるべき状態に収束するまで何度でも実行される」性質を持つため、Ingress のエンドポイントが疎通可能になるまで監視し続ける、といった処理が自然に書けます。JupyterLab の not found 問題は後述の PodReadinessGate を用いることで解消しました。
  3. サービスのコード全体の再設計と徹底的なテストの整備
    • 巨大 main をやめ、API サーバ・各コントローラを分離した結果、Unit / Integration(envtest)/ E2E(kind)の全レイヤーでテストが書けるようになりました。

サービスの再設計の前後のアーキテクチャ比較

サービスの再設計の前後のアーキテクチャの概念図を並べて示します。重要な点は状態管理の責務がサーバーからコントローラへ移ったことです。

従来の GPUaaS のサービス概念図

再設計前のアーキテクチャ図

サーバーがリソースを「作って終わり」で、リコンサイルも Status による状態の可視化もありませんでした。

現在の GPUaaS のサービス概念図

再設計後のアーキテクチャ図

図の流れをユーザのリクエストから実際にリソースが作られるまでの順番でざっくり追うと次のようになります。

  1. 利用者が GUI や CLI からワークロードの作成をリクエストする
  2. gpuaas-manager がそのリクエストを TaskDeployment CR(Custom Resource) に変換し、クラスタに作成する
  3. TaskDeploymentControllerTaskDeployment を監視してリコンサイル を開始し、参照先の Blueprint を読み込む
  4. TaskDeploymentControllerBlueprintTaskDeployment を組み合わせて GPUaaSTask (StatefulSet / Service / Ingress / Certificate など一式) を組み立て、クラスタに作成する
  5. PodReadinessControllerIngress / CertificateHTTPRoute)の status を監視し、外から疎通可能になったタイミングで Pod の Readiness 条件を更新する
  6.  Readiness が満たされると TaskDeployment の Status が Available に更新され、利用者はエンドポイント経由でワークロードを利用できるようになる 

以降は、このコントローラの設計を掘り下げます。

Kubernetes の宣言的 API とカスタムコントローラ

カスタムコントローラが何をしているのかを理解するために、最低限必要な Kubernetes の概念を説明します

Kubernetes では、ユーザは「手順」ではなく「最終的にこうあってほしい状態(desired state)」をマニフェストに書きます。コントローラはその desired state と current state の差分を埋め続ける役割で、その一連の処理をリコンサイル (reconcile) と呼びます。リコンサイル は冪等である必要があり、「1 回目だけ作成」ではなく「常にあるべき状態へ収束させる」発想で書きます。冒頭の非同期待ちが自然に書けるのは、このモデルのおかげです。

Kubernetes では CRD (Custom Resource Definition) を作成することで 元の Kubernetes にない独自のカスタムリソース(Custom Resource)を追加でき、そのカスタムリソースをリコンサイルするコントローラをカスタムコントローラと呼びます。また、CRD とセットで提供するカスタムコントローラを慣習的に Operator と呼びます。gpuaastask-controller-manager はまさにこの Operator で、我々が作成したカスタムリソース TaskDeployment / TaskBlueprint をリコンサイルして、インスタンスに必要な Kubernetes のワークロードを宣言的に管理します。

カスタムコントローラについて詳細に知りたい方は つくって学ぶKubebuilder を読んで実際に実装していただくのがおすすめです。

gpuaastask-controller-manager とは何なのか

ここからは、実際に GPUaaS で採用している Operator 方式を実装したカスタムコントローラ  gpuaastask-controller-manager の全体像を説明します。

私たちはユーザが GPUaaS で作成するインスタンスの概念である GPUaaSTask で必要とされるリソースを準備するのに必要なパラメータを、 2 つの種類のカスタムリソースに分割して管理するアプローチを採用しました。

ひとつは共通のパラメータを多く持ち、テンプレートとしての役割を果たす Blueprint、もうひとつはユーザ毎にカスタムされたパラメータのなどを管理する TaskDeployment です。

厳密には Blueprint にはクラスタ全体で共通の ClusterTaskBlueprint と、テナント単位で固有の TaskBlueprint が存在しますが、簡単のため以降は基本的にクラスタ全体で共通の ClusterTaskBlueprint を取り扱います。

登場するカスタムリソース

種別 リソース名 スコープ 役割
Blueprint TaskBlueprint Namespace 特定のテナント(Namespace)で使うテンプレート
ClusterTaskBlueprint Cluster 全テナントで共有する Cluster-wide のテンプレート
TaskDeployment TaskDeployment Namespace Blueprint を参照し、上書きパラメータを与えて GPUaaSTask を実際にデプロイする本体

ユーザはテンプレートである Blueprint を選択して、自分の必要なカスタムパラメータを入力した TaskDeployment を作成するだけで細かな設定を含めた必要なすべてのパラメータの入力が完了し、GPUaaSTask の実現に必要な実体リソース一式が自動的に組み上がる、というのがこのコントローラの本質です。

Blueprint と TaskDeployment により GPUaaSTask が作成されることを表す図

利用イメージ(JupyterLab / Jupyter Notebook の例)

以下はユーザが GPUaaSTask で JupyterLab をデプロイする際に指定可能な ClusterTaskBlueprint の例です。 Blueprint はテンプレートとしての役割を持つため、インスタンスとして共通で設定するコンテナの設定や、インスタンス構成(リソース設定)となる MachineType のデフォルトの値が入力されています。

apiVersion: mlplatform.cycloud.io/v1beta1
kind: ClusterTaskBlueprint
metadata:
  name: managed-jupyter-notebook
spec:
  machineTypeClaimTemplates:
  - name: jupyter-notebook-server-machinetype
    resources:
      machineTypeName: ml-standard-1      # MachineType を指定するだけで CPU/GPU/メモリが決まる
  instancePolicy:
    replicas: 1
    # StatefulSet 周りの設定
  routePolicy:
    # Service / Ingress のテンプレート
  template:
    spec:
      containers:
      - name: jupyter-notebook-server
        command: ["jupyter"]
        args: ["lab"]
        # ...

利用者は、上記のような Blueprint を参照して、自分が変えたいパラメータ(MachineType やコンテナイメージなど)だけを TaskDeployment に書きます。

以下の TaskDeployment では GPUaaSTask で作成される StatefulSet で設定されるコンテナイメージや MachineType (コンテナのリソース量)の設定をカスタムしています。

apiVersion: mlplatform.cycloud.io/v1beta1
kind: TaskDeployment
metadata:
  name: fine-tuning-calm3-nb
  namespace: test-namespace
  labels:
    kueue.x-k8s.io/queue-name: test-namespace-queue
spec:
  suspend: false
  blueprintRef: # 参照する Blueprint を指定
    kind: ClusterTaskBlueprint
    name: managed-jupyter-notebook
  taskConfig:
    machineTypeClaims:
    - name: jupyter-notebook-server-machinetype
      resources:
        machineTypeName: ml-standard-32   # Blueprint の ml-standard-1 を上書き
    images:
    - name: asia-docker.pkg.dev/example:latest
    # volumeMounts, volumes など

このように Blueprint を選び、必要な設定を TaskDeployment に埋めるだけで、JupyterLab のインスタンスの起動、アクセストークンの生成やマウント、Service/Ingress などのエンドポイントの構築、さらにエンドポイントに疎通が可能であるかどうかの Ready 判定までが自動で非同期的に行われます。

実際にはユーザは GUI から起動したいコンテナイメージやリソースの設定を選ぶだけでよく、バックエンドサーバがユーザの入力に応じて TaskDeployment を作成します。このため、利用者は最低限のカスタムする設定だけを意識すればよく、Kubernetes の StatefulSet の細かな書き方や TaskDeployment の書き方を知る必要はありません。

gpuaastask-controller-manager の内部

ここからは更に細かいコントローラの内部実装の設計について紹介します。

gpuaastask-controller-manager は単一プロセスの中に、役割の異なる 3 つのコントローラを内包しています。

おおまかな役割は次のとおりです。

  • BlueprintController
    • BlueprintController は、利用中の Blueprint が誤って消されないよう Finalizer を管理する。
  • TaskDeploymentController
    • TaskDeployment が投入されると、TaskDeploymentController がリコンサイルを実行する。参照先の Blueprint を取得し、両者を組み合わせて GPUaaSTask(StatefulSet などの一式)をビルドし、クラスタに作成する。
  • PodReadinessController
      • Ingress / Certificate / HTTPRoute の status を監視し、エンドポイントが本当に疎通可能になったタイミングで Pod の Readiness 条件を更新する。

Status による状態の可視化

従来の GPUaaS では GPUaaSTask の状態は StatefulSet の Ready なレプリカ数などの情報のみで把握する必要がありました。しかし、エンドポイントの確立判定や各種リソースの詳細な状態のハンドリングが難しいという課題がありました。

この「状態の可視化」の課題を gpuaastask-controller-manager では、TaskDeployment.status.conditions で解決しています。
TaskDeployment は次の 3 つの状態を持ち、リコンサイルのたびに更新されます。

  • Suspended.spec.suspend に応じてリソース作成を停止/再開している状態
  • Created:Blueprint と組み合わせて GPUaaSTask のビルド・作成に成功した状態(まだ疎通可能とは限らない)
  • Available:対応する StatefulSetavailableReplicas が要求 replica 数に達し、エンドポイント経由で利用可能になった状態

これにより TaskDeployment の Status を確認することで、現在の GPUaaSTask の状態を知ることができます。利用者はバックエンドサーバがこの Status を元に判別した状態(Active, Provisioning, Suspended など)を見ることで GPUaaSTask が現在どの段階にいるのかが一目で分かります。

ステータス情報を StatefulSet からのみ取得していた従来のシステムとは対照的に、オブジェクトの状態を知るために必要な情報はすべてリソースの status として表現されています。

工夫した点と設計思想

ここからは実装上の工夫を、背後の思想とあわせて紹介します。

工夫① 内部を Pluggable にする「GPUaaSTask Pipeline Framework」

最大の設計上のチャレンジは、「任意のアプリケーションをワークロードとしてデプロイ可能にする」という拡張性の要件でした。Jupyter Notebook には Jupyter 固有のセットアップ(トークン生成、リモートカーネル用 ConfigMap など)が必要ですが、将来的には Stable Diffusion WebUI など別種のワークロードも簡単に追加できる必要があります。

そこで、GPUaaSTask の組み立て処理そのものを Pluggable なパイプラインとして実装しました。これは Kubernetes のスケジューラ(kube-scheduler)の Scheduler Framework のパイプライン設計から着想を得たアーキテクチャです。

GPUaaSTask の組み立てを担う PipeLine の図

パイプラインは 4 つの Phase に分かれており、各 Phase に Extension Point(拡張点) が定義されています。プラグインがこの拡張点を実装することで、各 Phase に任意の処理を差し込めます。

  • Startup Phase
    • マネージャ起動時。コントローラや Webhook サーバの起動、追加リソースの Watch 登録などを行う。
  • PreExecution Phase
    • リコンサイルの前処理。TaskDeployment のカスタムバリデーションを差し込む。
  • Build Phase
    • リコンサイルの処理。Blueprint の各 Policy(InstancePolicy / RoutePolicy)を適用し、Kubernetes オブジェクトを組み立てる。
  • PostExecution Phase
    • ビルド完了の後処理。Suspended / Created / Available の各 Condition や .status.deployedTask を生成する。

プラグインのインターフェースは、Go の インターフェース合成を活用してきめ細かく分割されています。プラグインは「自分が関心のある拡張点のインターフェースだけ」を実装すればよく、フレームワーク側は型アサーションでそれを検出して該当 Phase のリストに登録します。

// 各拡張点は独立した小さなインターフェースとして定義されている
type EnforceInstancePolicyPlugin interface {
	Plugin
	EnforceInstancePolicy(ctx context.Context, info *task.Info, taskDeployment *mlpv1beta1.TaskDeployment) error
}

type ComponentBuilderPlugin interface {
	Plugin
	// Build はワークロードビルドの最終 Phase で実行され、作成/更新対象のオブジェクトを返す
	Build(ctx context.Context, info *task.Info, taskDeployment *mlpv1beta1.TaskDeployment) ([]client.Object, error)
}

// フレームワークは型アサーションで「そのプラグインがどの拡張点を実装しているか」を判定し登録する
if p, ok := plugin.(framework.EnforceInstancePolicyPlugin); ok {
	f.enforceInstancePolicyPlugins = append(f.enforceInstancePolicyPlugins, p)
}
if p, ok := plugin.(framework.ComponentBuilderPlugin); ok {
	f.componentBuilderPlugins = append(f.componentBuilderPlugins, p)
}
// ... 他の拡張点も同様

実際に Jupyter プラグインは EnforceInstancePolicyPluginComponentBuilderPluginWatchExtensionPluginCustomValidationPluginDeployedTaskPlugin という 5 つの拡張点を 1 つの構造体で実装しています。一方、StatefulSetService といった汎用部分は別のプラグインに分かれています。「Jupyter 固有の知識を Jupyter プラグインの中に閉じ込める」ことで、新しいワークロード種別を足すときも既存コードに手を入れずプラグインを 1 つ追加するだけで済む、という拡張性を実現しています。

フレームワーク(使用側)が必要とする最小限の契約だけをインターフェースとして切り出すことで、プラグイン側の実装の自由度を保っています。

工夫② 巨大 main の解体と、テスト可能なアーキテクチャ

再設計以前の GPUaaS のコードは巨大な main 関数に処理が集約されており、これによりテストを書くことすら困難な状況に陥っていました。新実装では API サーバ・3 つのコントローラ・各プラグインのそれぞれのレイヤーで client.Client などの依存を注入する形で分離可能な形に設計しました。

これにより、各レイヤーを独立してテストできるようになりました。テストは次の 3 層で構成しています。これにより、各レイヤーを独立してテストできるようになりました。テストは次の 3 層で構成しています。

  • Unit Test
    • controller-runtime の fakeClient を使い、リコンサイルやプラグインのロジックを単体で検証する。クライアントエラーを注入してエラーパスも安定して再現できる。
  • Integration Test
    • envtest で、Webhook やコントローラの挙動を実 API サーバ相手に検証。
  • E2E Test
    • kind で実クラスタを立て、ビルドしたイメージをデプロイし end-to-end で検証。

各レイヤーでテストを分離し、それぞれで網羅的にテストを実装することによって、新たにチームメンバーが加入した場合でも関数や機能がそれぞれどのような挙動をするのか把握するのが容易となっています。

工夫③ Validation を kubebuilder の marker (CEL) で行い、Admission Webhook を最小限にする

Kubernetes Resource を作成する時などの Validation には Admission Webhook で行われることが多いですが、Admission Webhook は kube-apiserver が検証のたびに Webhook の pod へ HTTP(S) リクエストを行い、リクエスト回数が多いと kube-apiserver の負荷の増加につながります。また Webhook 用の Deployment や TLS 証明書などの管理が必要です。

代替手段として kubebuilder の marker (XValidation)を用いることで kube-apiserver 内でインラインの Validation を実現することができます。もちろん全てのケースで使用できるわけではなく、CEL という言語で表現できる Validation に限られているため他のリソースを参照する・外部システムへの問い合わせを行うなどは Admission Webhook を使用する必要があります。

今回は kubebuilder の marker で実現できる部分は marker を使用した validation にできる限り寄せることで、kube-apiserver への負荷を最小限にするような実装としています。

工夫④ PodReadinessGate でエンドポイントの本当の Readyを表現する

再設計以前の GPUaaS のコードでは StatefulSet のステータス情報のみで状態を監視していたため、エンドポイントの確立の確認などができないという課題があると述べました。現在は TaskDeployment.status.conditions に詳細な状態を取得できるようになっていますが、ここではどうやってその状態を設定しているのかについて、詳細を述べます。

GPUaaSTask はエンドポイント(JupyterLab や JupyterNotebook の UI など)を持つことがあるため、StatefulSet の ReadyReplicas のような「Pod のプロセスが起動した」だけでは不十分で、「Ingress に IP が割り当たり、TLS 証明書が発行され、外から本当に疎通できる」状態になって初めて “Available” と言えます。

再設計前は、まさにこの「後から変化する周辺リソースの Ready」を待てずに状態を返してしまっていたため、エンドポイントにアクセスしたが Not Found エラーが出るような状況になっていました。しかし、リコンサイルを持つコントローラーなら、この状態が整うまでエンドポイント関連のリソースを含めて監視し続けることが可能です。

Kubernetes 標準では、Pod の Ready 判定にコンテナの readinessProbe しか使えません。そこで我々は PodReadinessGateを活用しました。これは Pod などに独自の Ready 条件を追加できる Kubernetes の仕組みであり、これを利用することで特定の条件を満たした時にのみ Pod の状態を Ready に遷移させることができます。

GPUaaSTask では、 StatefulSet に独自の readinessGates を追加し、GPUaaSTaskIngressReady / GPUaaSTaskIngressCertificateReady / GPUaaSTaskHTTPRouteReady などの条件を定義します。それぞれは以下のような条件を持ちます。

  • GPUaaSTaskIngressReady
      • Ingress に IP アドレスが割り振られ Ready になった場合に設定される
  • GPUaaSTaskIngressCertificateReady
      • Ingress の TLS 証明書が Ready になった場合に設定される
  • GPUaaSTaskHTTPRouteReady
    • HTTPRoute が Ready になった場合に設定される

PodReadinessController は、特定のラベルが付いた Ingress / Certificate / HTTPRoute だけを Watch し、それらの status(IP が割り当たったか、証明書が Ready か、など)に応じて、対応する Pod の Readiness 条件を True / False に更新します。

この設計は、最終的な Available 判定を StatefulSetavailableReplicas 一本に集約できます。Pod の Readiness にエンドポイントの状態まで織り込んだので、TaskDeploymentController は IngressCertificate を直接見に行かなくても、StatefulSet.status.availableReplicas だけ見れば「エンドポイントまで含めて Ready か」を判定できます。責務をきれいに分離しつつ、判定ロジックを単純化することができています。

まとめ

この記事では、サイバーエージェントの機械学習基盤 ML Platform のサービスのひとつである GPUaaS でユーザが作成する JupyterLab / Jupyter Notebook 等のインスタンスを管理するカスタムコントローラ gpuaastask-controller-manager の概要と内部設計の紹介を行いました。

gpuaastask-controller-manager ではカスタムリソースとして定義された TaskDeployment および共通テンプレートである Blueprint を元に、ユーザのワークロードに必要なリソースを宣言的に管理することができます。

このコントローラの設計の要点は次のとおりです。

  1. リソースの宣言的な管理
    • カスタムリソース の リコンサイルでオブジェクトをあるべき状態へ収束させる
  2. リソースの状態の非同期更新
    • オブジェクトを監視して修正し続けるリコンサイルのループと PodReadinessGate でエンドポイント疎通まで待ち続ける
  3. 将来的な拡張性を持つフレームワークの採用
    • Pipeline Framework で Task に必要なリソースのワークロード種別をプラグインとして追加できる
  4. 各レイヤー分離した網羅的なテストの実装
    • DI による分離で Unit / Integration / E2E の全レイヤーをテスト可能にする

コントローラで管理に移行したことで以下のような開発上の利点がありました。

  • TaskDeployment の Status を確認することで作成される各リソースの状態を確認できるようになり、正常性の確認やトラブルシューティングがしやすくなった
  • 周辺リソースの Readiness を確認できるようになり、IP アドレスや証明書周りのトラブルがなくなり、問い合わせ対応が減少した
  • 網羅的なテストを整備できるようになり、コードの見通しも改善したため、機能追加時のリードタイムが短縮された

私たち ML Platform チームは、社内ユーザが GPU をより簡単に、かつ安全に利用できる基盤を継続的に改善しています。

今回の取り組みが、Kubernetes 上でプラットフォームを運用している方の参考になれば幸いです。

 

We are hiring

CIU (CyberAgent group Infrastructure Unit) では、Kubernetes を基盤としたプライベートクラウドや、その上で提供する機械学習基盤サービスを一緒に開発・運用してくれる仲間を募集しています。

GPU クラスタの運用や Kubernetes のカスタムコントローラ開発、Kueue などの OSS を活用した基盤づくりに興味のある方は、ぜひお気軽にご連絡ください。

https://hrmos.co/pages/cyberagent-group/jobs/ciu003