EKS on FargateでArgoWorkflowを利用したJob実行基盤の構築


f:id:cloudfish:20201215101142p:plain:w300
 遅ればせながらEKS on Fargateを使ってバッチ処理を行うことになったので検証を行いました。
 通常、EKS上でジョブを実行する場合、Webサービス等の実行ノードと同じかもしくは分けてバッチジョブ用のノードを用意することになるかと思いますが、ノードを分けない場合は、バッチ処理による負荷がサービスへ影響を与える可能性がありますし、ノードを分ける場合は、利用しない間も起動しておく必要があることからコスト面が課題になってきます。
 そのため、バッチ処理をFargate上で実行することで、処理の負荷の影響を切り離してかつ必要な時に必要なリソースだけ利用することでコストの最適化も行えるのではないかと考え検証を行いました。今回は検証過程で得た知見を紹介したいと思います。

バッチ処理でEKS on Fargateを使う理由

 同一ノード上でバッチ処理を行うとどうしても一定時間、CPUやメモリを占有するということが起きてしまいます。そのためどうにかして負荷を下げようとすると、まずはバッチ専用ノードを立てるという案が出てきますが、これはバッチ処理が1日に数回程度の利用状況だとコスト的に見合わないことになります。
 次に考えられる案として、AWS前提の話となりますが、LambdaやAWS Batchの利用かと思います。現状、弊社では開発プロセスkubernetesに寄せた形にしているため、それに関する知見も多く蓄積されているので、ここであえて開発者にLambdaやAWS Batchを学習してもらう必要もないと考えました。
 これを検討し始めた時点ではLambdaのコンテナイメージ利用やAWS BatchのFargate利用などの機能がまだ出ていなかったので上記のような結論になりましたが、こうした機能が使えるのであれば、社内にある知見を活用しつつうまく利用できる方法を検討していきたいと考えています。
 こうした状況を踏まえて、kubernetesの知見を活かしつつバッチ処理の負荷を切り離す方法としてFargateの利用が最も効果的ではないかと考えました。

EKS on FargateでのJobの実行方法

 Fargate上でPodを実行するための設定はそれほど難しくありません。Fargate Profileを設定し、そこで指定したNamespaceでJobをapplyするだけでFargateノードでJobが実行されます。
 また、FargateProfileは既存のクラスタに対して後から追加可能となっており、Fargateノードで起動するかどうかはNamespaceだけでなくlabelで制御することも可能です。
 以下はeksctlのcluster.yamlへの設定方法になります。対象のnamespaceがfargate-testとなり、labelにfargate: onが指定されたJobのみFargateノードで起動される設定になります。

  :
fargateProfiles:
  - name: fargate-profile
    selectors:
      - namespace: fargate-test
        labels:
          fargate: 'on'

FargateProfileの作成

eksctl create fargatargateprofile -f cluster.yaml

EKS on FargateでJobを実行する時の問題点

 テスト用のJobをFargate上で動かしたところ、本番運用にあたっていくつかの問題に気づきました。

ジョブの実行完了後に誰がジョブを削除するか?

 Jobの実行後にステータスがCompletedとなったPodは意図的に削除しない限り残り続けることとなります。EC2ノード上だと実行が完了しているので、それでリソースが使われるということはなく単にゴミが残るだけとなりますが、Fargateの場合、Fargateノードも残ることとなりその分課金され続けてしまいます。EKSではPodの終了後に自動で削除してくれるttlSecondsAfterFinishedを使うことはできないので、Podの削除方法については自前で仕組みを検討する必要があります。

モニタリング、ログの取得

 EKSの監視はDatadogなどのagentをDaemonsetで起動させてモニタリングしてるケースが多いかと思いますが、Fargateではコンテナのみの提供となるためDaemonsetは使えません。そのため、sidecar方式でPodにagentを同居させる必要があります。しかしながら、こうしたagent系のコンテナをsidecarとして実行させると、メインのJobの実行は完了したもののagentが終了しないのでJobがいつまでも起動したままとなります。そのため、どうにかしてagentをJobの完了に合わせて終了させる必要があります。
以下のブログはアプリの終了時にsidecarをkillするということをやっているようです。
tech.recruit-mp.co.jp

そしてこのブログを書いている間に、reinvent2020が始まり以下の機能がリリースされました。ログに関してはsidecarで取得することなくcloudwatch logsに送ることが可能になりました。
aws.amazon.com

起動時間の考慮

 Fargateノードで起動する場合、ノードを起動するまで思ったよりも時間がかかりました。検証時には毎回2-3分かかりました。調べたところ以下のブログで検証されているのですが、起動時間の大半はイメージのpullにかかっているため、主にイメージサイズに依存します。そのため、時間どおり正確に処理を実行したいなどの要件がある場合は、実行基盤としてはそぐわない場合もあると思います。
qiita.com

DaemonsetでデプロイされたPodが起動しようとする

 検証時のクラスターにはEFS CNIドライバーが起動されていました。テスト用のJobを実行すると、Fargateノードの起動を検知して同様にEFS CNDドライバもFargateノードで起動しようとする動きが見られました。EFS CNIドライバーはFargateノードではエラーで起動できなかったのでメインのPodの削除とともに消えましたが、ものによっては起動が成功するかもしれないので、環境によっては注意が必要になります。

開発者とインフラ担当者の責任分界点

 弊社では、モニタリングやログ取得については、インフラ担当の責任範囲としており、アプリケーションに関するManifestは開発者の責任範囲としています。しかし、Fargateを利用するとモニタリングやログの取得はsidecar方式での利用となるため、設定を開発者へ委ねることになります。また、Fargateノードで起動するしないの設定も開発者に意識してもらう必要がありますので、こうした点を意識して設定してもらう必要があります。

その他

他にも、現状では、SecurityGroupが任意のものを設定できないといった問題がありました。このあたりは、今後の改善に期待ですね。

Argo Workflowの利用

 EKS on FargateでJobを実行する場合の大きな問題点は、上記で書いたようにJob実行後のPodをどう削除するかとsidecarとなるコンテナをどのようにkillするかという点になるかと思います。これらを解決できないとJob実行後もFargateノードが残り続けることとなるためFargateノードを使う意味がなくなります。何か良い方法がないか調べていたところ、Argo Workflowを利用することで解決できそうなことがわかりました。
 Argo WorkflowとはオープンソースのJobのワークフローエンジンとなり、UIでジョブ管理ができるツールになります。詳細は以下のリンクを参照してください。Argo WorkflowでJobを管理、実行することで完了後のPodも自動で削除してくれますし、メインのコンテナ終了後にsidecarコンテナもあわせてkillしてくれるので、Jobを実行するにあたっての大きな懸念点が解消されました。
argoproj.github.io

インストール及び設定

インストール方法

 インストール方法については、cluster-installとnamespace-installの大きく2種類があります。
cluster-installとはクラスタのどのnamespaceでもJobを実行できるようにするインストール方式です。一方でnamespace-installは、argo workflowをインストールしたnamespaceと同じnamespaceでしかJobを実行できません。
 現状ではargoworkflowはユーザーごとの権限制御があまり細かく設定できなさそうなので、namespaceごとにユーザーを限定したいのであればnamespace-installを利用すればいいかと思います。今回はnamespaceごとにインストールしたくはないのとあまり細かい制御は必要ないのでcluster-installとしました。

gitからargo workflowの取得
git clone https://github.com/argoproj
configmapを編集

 Fargate上で動かすためには、ランタイムの設定であるcontainerRuntimeExecutorをk8sapiに変更する必要があります。ここが少しハマったポイントなのですが、デフォルト設定のdockerのままだと実行したJobのステータスがPendingのまま実行されませんでした。
 また、workflowのデフォルト設定もできるので、デフォルトの値を設定しておくことで実行時のミスを減らせるかと思います。他の項目については必要に応じて編集してください。

cd argo/manifests/
vim base/workflow-controller/workflow-controller-configmap.yaml
workflow-controller-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: workflow-controller-configmap
data:
  config: | 

      :
      :
    containerRuntimeExecutor: k8sapi
      :
      :

kustomizeでビルドし、applyしてください。

cd cluster-install
kustomize build . > install.yaml
kubectl apply -f install.yaml -n argo

ポートフォワードします。必要に応じてALBなどを設定してください。

kubectl -n argo port-forward deployment/argo-server 2746:2746

以下のURLにアクセスするとArgo WorkflowのUIにアクセスできます。本番用途ではALBの利用やSSOなどの利用を検討してもいいかと思います。
http://localhost:2746

f:id:cloudfish:20201215141548p:plain

サービスアカウント

 Argo WorkflowでJobを実行するには、以下の権限を持ったサービスアカウントが必要になります。以下の内容を保存してapplyしてください。
 AWSのリソースにアクセスする必要がある場合は、eksctlなどでサービスアカウントを作成しているかと思いますが、そこで作成したサービスアカウントに以下のロールをバインドする必要があります。このあたりはeksctlで一括管理したいですが、今のところいい方法はなさそうです。

vim fargate-serviceaccount.yaml

apiVersion: v1
kind: ServiceAccount
metadata:
  name: fargate-serviceaccount
  namespace: fargate-test

---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: workflow-role
rules:
- apiGroups:
  - ""
  resources:
  - pods
  verbs:
  - get
  - watch
  - patch
- apiGroups:
  - ""
  resources:
  - pods/log
  verbs:
  - get
  - watch

---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: argo-batch-binding
  namespace: fargate-test
subjects:
  - kind: ServiceAccount
    name: fargate-serviceaccount
  namespace: fargate-test
roleRef:
  kind: Role
  name: workflow-role
  apiGroup: rbac.authorization.k8s.io
fluentbitの設定

Jobを実行するnamespaceでfluentbit用のConfigMapを設定しておきます。
以下はログをdatadogに送る場合のサンプルになります。DatadogのAPIキーが必要になります。

apiVersion: v1
kind: ConfigMap
metadata:
  name: fluentbit-config
  namespace: fargate-test
data:
  # Configuration files: server, input, filters and output
  # ======================================================
  fluent-bit.conf: |
    [INPUT]
        Name              tail
        Tag               *.logs
        Path              /var/log/*.log
        DB                /var/log/logs.db
        Mem_Buf_Limit     5MB
        Skip_Long_Lines   On
        Refresh_Interval  10
    [OUTPUT]
        Name        datadog
        Match       *
        compress    gzip
        apikey      [your datadog api key]
        dd_service  hello-world
        dd_source   job
        dd_tags     vendor:sample,env:stg
Jobの実行

以下はJobのサンプルになります。5秒ごとに「hello world」を出力するだけのサンプルです。

apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
  generateName: hello-world-
spec:
  entrypoint: helloworld
  templates:
  - name: helloworld
    serviceAccountName: fargate-serviceaccount
    metadata:
      labels:
        fargate: "on"
    container:
      image: busybox
      args:
      - /bin/sh
      - -c
      - >
        for i in 1 2 3 4 5;
        do 
          echo hello world >> /var/log/app.log;
          sleep 5;
        done
      volumeMounts:
        - name: varlog
          mountPath: /var/log
      resources:
        requests:
          memory: 512Mi
          cpu: 500m
    sidecars:
    - name: fluentbit-agent
      image: amazon/aws-for-fluent-bit:latest
      ports:
        - containerPort: 2020
      env:
      - name: FLUENTD_HOST
        value: "fluentd"
      - name: FLUENTD_PORT
        value: "24224"
      - name: POD_NAME
        valueFrom:
          fieldRef:
            fieldPath: metadata.name
      - name: POD_NAMESPACE
        valueFrom:
          fieldRef:
            fieldPath: metadata.namespace
      volumeMounts:
      - name: varlog
        mountPath: /var/log
      - name: fluentbit-config
        mountPath: /fluent-bit/etc/
  volumes:
  - name: varlog
    emptyDir: {}
  - name: fluentbit-config
    configMap:
      name: fluentbit-config

  ttlStrategy:
    secondsAfterCompletion: 30
    secondsAfterSuccess: 30
    secondsAfterFailure: 30

以下設定ポイントになります。

  • namesapceとlabelsの[fargate: 'on']設定がFargateで起動するかどうかを制御します。
  • アプリケーションからログを/var/log/app.logにリダイレクトすることでfluentbitがログを収集します。
  • resourcesでのリソース要求設定で必要なFargateのスペックが割り当てられます。
  • ttlStrategyでJobが完了した場合などで、何秒後にPodを削除するか設定します。

fargateラベルやttlStrategyなどは設定が漏れると困るのでデフォルト値として設定しておいてもいいかもしれません。

上記のマニフェストファイルを適当な名前で保存してUIから実行するか以下のコマンドを実行するとジョブが実行されます。

argo submit helloworld.yaml

ArgoWorkflowによるsidecarのkill制御

 ArgoWorkflowでは、mainコンテナが終了するとsidecarコンテナもkillしてJobを終了させてくれますが、sidecarコンテナが複数あるとtimeoutが発生し、一つしかkillされませんでした。コントローラ用のコンテナからはシグナルを全てのコンテナに送っているものの片方しか終了させれない状況となっていました。そのため、当初はfluent-bitとdatadog-agentをsidecarとして配置し、ログとメトリクスを取得する予定でしたが、メトリクスの取得は断念しました。
 しかし、上記でも紹介しましたがEKS on Fargateで直接Fargateのログをcloudwatch logsなどに送ってくれる機能追加が行われたため、これを利用しつつdatadog-agentをsidecarとして配置することでメトリクスを取得できそうです。ただし、現時点では利用中の環境のEKSプラットフォームバージョンが上がってないためこの機能を利用できないので、プラットフォームバージョンが上がるまで待つこととしました。ちなみにEKSのプラットフォームバージョンは利用者が意図的にあげることはできないようです。AWS側で定期的にプラットフォームのバージョンを更新しているためそれを待つ必要があります。どうしてもアップさせたい場合は、新しくクラスタを作り直すかkubernetesのメジャーバージョンをあげる必要があります。

まとめ

 EKS on Fargateでのバッチ処理については、ある程度のデータ量を集計する必要があるなど、まとまったリソースが必要な処理には適していると思います。
向いていない処理としては、起動時間に数分必要となるので、正確な時間に実行する必要のある処理や毎分実行する処理、またイベント駆動的な処理などにもあまり適していないと感じました。
 また、ログはEKSの標準機能でCloudWatchlogsから取得できるようになりましたが、メトリクスを取得するにはsidecarで取得せざるを得ない状況です。sidecarをArgoWorkflowで制御できるとはいえ、Jobごとに書く必要があるため冗長になってしまいます。できればメトリクスについてもcloudwatchから取得できるようにして欲しいところです。AWSコンソールでもkubernetesのワークロードが見れるようになったので、近いうちに対応してくれるのではないかと期待しています。
 バッチ処理のようなバックグラウンドで行われる処理の負荷をどう下げるかというのは頭を悩ませていましたが、Fargateをうまく活用することで一時的な負荷を切り離しつつかつコストも抑えれることが分かりましたので、今後は色々な環境で利用していきたいと思います。