UZABASE Tech Blog

〜迷ったら挑戦する道を選ぶ〜 株式会社ユーザベースの技術チームブログです。

【kubernetes / Helm】大量のCronJobに悩む貴方に送るプラクティス

はじめに

こんにちは! UZABASE SPEEDA SRE teamの生賀id:skikkh(@skikkh)です。

最近あった嬉しかったことは、自分が翻訳した日本語がkubernetesのCronJob - Kubernetesページに反映されていたことです。

閑話休題、弊社SPEEDAサービスでは大量のバッチジョブがHinemosを起点としてVM上で動作しています。 SREチームではこのようなジョブ群を徐々にサーバから切り離して、コンテナライズを進めています。

そんな大量にあるジョブですが、環境変数だけが異なっているものも多数あり、実行環境 x 環境変数と環境が異なると掛け算式に増えていきます。 これをkubernetesのCronJobでyamlハードコーディングすると容易に1000行を超えてしまい、管理上のコストも含め現実的ではありません。1

そこで、kubernetesパッケージマネージャーを使用することにしました。その選択肢としてはHelmKustomizeなどがあります。 これらを利用することで環境毎に異なる設定値のリソースが作成できたり、複雑な依存関係を持つkubernetesリソース群をChartという一つのフォーマットにまとめることができます。

今回、要件を満たすエントリが見当たらなかった為、利用事例として投稿させていだきます。

目次

Kustomizeの場合

まずはKustomizeで同様の利用を想定した際の構成を見てみましょう。

下記ディレクトリ構成は公式のKustomizeのGithubの、サンプルディレクトリ構成から拝借しました。

Kustomizeのサンプルディレクトリ構成

~/someApp
├── base
│   ├── deployment.yaml
│   ├── kustomization.yaml
│   └── service.yaml
└── overlays
    ├── development
    │   ├── cpu_count.yaml
    │   ├── kustomization.yaml
    │   └── replica_count.yaml
    └── production
        ├── cpu_count.yaml
        ├── kustomization.yaml
        └── replica_count.yaml

staging, develop, production環境といった環境差分をoverlaysで表現するのはKustomizeの方がいいかもしれませんが、Kustomizeの基本的なユースケースに照らし合わせてジョブを作成するとなると、次の例ようにCronJob毎にディレクトリを切る必要があり、管理コストが嵩んでしまいます。

Kustomizeで多数CronJobを作成する

例えば24個のAジョブと24個のBジョブ計48個作成する場合、overlays下に48個のディレクトリ構成ができてしまいます。以下がサンプルになります。

~/someApp
├── base
│   ├── cronjob.yaml
│   └── kustomization.yaml
└── overlays
    ├── 0101-job-a
    │   ├── category.yaml
    │   ├── kustomization.yaml
    │   └── table.yaml
    ├── 0102-job-a
    │   ├── category.yaml
    │   ├── kustomization.yaml
    │   └── table.yaml
    ├── 0103-job-a
    │   ├── category.yaml
    │   ├── kustomization.yaml
    │   └── table.yaml
    │      ︙ # 増えるだけ用意しないといけなくなる
    └── 0224-job-b
        ├── caterory.yaml
        ├── kustomization.yaml
        └── table.yaml

バッチジョブが20を超えてくるとなるとディレクトリで分割するのは現実的に厳しいと思います。

参考:Kustomize で CronJob を同一テンプレートからスケジュール毎に生成する

以上の理由から、KustomizeではなくHelmを利用することを決めました。

Helmの場合

Helmではアクションと呼ばれる制御構造によってリストをループ処理することができます。したがって、今回のケースではこちらを採用することにしました。

Helm, Tillerのバージョンはv2.14.3を使用しています。

今回の要件として、

  • 同一のテンプレートをベースとして、
  • DBのテーブル毎に含まれる、
  • 複数の地域情報を取り出し処理ができる2

バッチジョブを作成する必要がありました。

これを実現するためには先述したループ処理を行う必要があります。

実現方法としては「ループ処理をネストすれば可能」というのが答えなのですが、少しだけハマりどころがあったので、それも合わせてお話できればと思います。

通常のCronJobをループ処理する場合、values.yamlに回したい変数のリストを作成し、rangeを入れればループ処理が可能です。

単一のリストを利用してCronJobをループ処理する

まずはスケジュール毎にCronJobを作成したい場合を想定してみましょう。 以下の実行例ではスケジュール毎にスケジュールのインデックスをechoで出力するという設定をyamlで行います。動作確認したい場合はhelm testを利用しましょう。

test-schedule-cj.yaml

{{- range $index, $schedule := .Values.global.schedules }}
--- # 複数作成の為に必須
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: test-{{ $index | add1 }} # CronJob毎に一意になるような名前をつける必要がある
  namespace: {{ $namespace }}
  labels:
    chart: "{{ $chartName }}-{{ $chartVersion }}"
    release: "{{ $releaseName }}"
    heritage: "{{ $releaseService }}"
spec:
  schedule: {{ $schedule }}
  suspend: false
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccount: {{ $serviceAccount }}
          containers:
          - image: "{{ $imageRepository }}:{{ $imageTag }}"
            name: test
            # テストで検証するのであれば、busyboxイメージでechoを出力します。  
            args:
            - /bin/sh
            - -c
            - date; echo Index count is $(INDEX).
            env:
              name: INDEX
              value: {{ $index | add1 }}
          restartPolicy: OnFailure
{{- end }}

因みにyamlの可読性の為、最初に以下のような代入を行っています。(以降、省略)

test-schedule-cj.yaml

# global
{{- $namespace := .Values.global.namespace }}
{{- $serviceAccount := .Values.global.serviceAccount }}
{{- $imageRepository := .Values.global.imageRepository }}
{{- $imageTag := .Values.globa.imageTag }}
# chart
{{- $chartName := .Chart.Name }}
{{- $chartVersion := .Chart.Version }}
{{- $releaseName := .Release.Name }}
{{- $releaseService := .Release.Service }}

values.yamlの設定は以下のようになります。

vaules.yaml

global:
  # kubernetes
  namespace: test-ns
  serviceAccount: test-sa

  # image  
  imageRepository: busybox
  imageTag: latest

  schedules: [
    '0 0 * * *',  '10 0 * * *',
    '20 0 * * *', '30 0 * * *'
    ]

特に難しい部分はなく、rangeアクションで作成したいCronJobを囲むだけです。 ただ一つ注意しないといけない点として、CronJobの名前は一意になるようにしなければいけません。 その為、全ての名前が一意になるように命名規則を考えてつけましょう。 基本的にはループで回す変数名をつけるようにすれば大丈夫だと思います。3

作成後、以下のhelmコマンドを実行します。

$ helm upgrade --install job01 .

これでテーブルの数だけジョブを回すことができるChartのリリースができますね。

複数のリストを利用してCronJobをループ処理する

一つの変数の条件でループ処理ができたので、複数の変数のリストを使用してループする場合を考えてみましょう。 スケジュールのループ処理内にテーブルのループ処理をネストするだけで作成できます。 記述例としては以下のようになります。

test-schedule-table-cj.yaml

{{- range $index, $schedule := .Values.global.schedules }}
{{- range $table := .Values.global.tables }}
---
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: test-{{ $index | add1 }}
  namespace: {{ $namespace }}
  labels:
    chart: "{{ $chartName }}-{{ $chartVersion }}"
    release: "{{ $releaseName }}"
    heritage: "{{ $releaseService }}"
spec:
  schedule: {{ $schedule }}
  suspend: false
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccount: {{ $serviceAccount }}
          containers:
          - image: "{{ $imageRepository }}:{{ $imageTag }}"
            name: test
            args:
            - /bin/sh
            - -c
            - date; echo Index count is $(INDEX). Table name is $(TABLE_NAME).
            env:
              name: INDEX
              value: {{ $index | add1 }}
              name: TABLE_NAME
              value: {{ $tablel.name }}
          restartPolicy: OnFailure
{{- end }}
{{- end }}

values.yaml

global:
  # kubernetes
  namespace: test-ns
  serviceAccount: test-sa

  # image  
  imageRepository: busybox
  imageTag: latest

  # schedule info
  schedules: [
    '0 0 * * *',  '10 0 * * *',
    '20 0 * * *', '30 0 * * *'
    ]

  # table info
  tables: 
    - name: XxxTest
      category: walk
    - name: YyyTest
      category: walk
    - name: ZzzTest
      category: run

しかし、例のようにスケジュール×テーブルという変数でジョブを回そうとすると失敗してしまいます。 以下が失敗の際に出力されるエラーになります。

UPGRADE FAILED
Error: render error in "test-loop/templates/est-schedule-table-cj.yaml": template: test-loop/templates/est-schedule-table-cj.yaml:15:28: executing "test-loop/templates/test-schedule-table-cj.yaml" at <.Values.global.tables>: can't evaluate field Values in type interface {}
Error: UPGRADE FAILED: render error in "test-loop/templates/est-schedule-table-cj.yaml": template: test-loop/templates/est-schedule-table-cj.yaml:15:28: executing "test-loop/templates/est-schedule-table-cj.yaml" at <.Values.global.tables>: can't evaluate field Values in type interface {}

失敗の原因は.のカレントスコープが「.Values.global.schedules」を向いているためで、.Values.global.regionが習得できません。

回避策としては、ループの処理の際に、「常にグローバルスコープを持つ$を使用する」ことでこのエラーを回避できます。

したがって、以下のdiffの変更点のように最初の記述でループしたい変数を別の変数に代入することでスコープを変えないままネストしたループ処理ができるようになります。 また、CronJobのリソース名も一意にするため、テーブルの名前を更新しておきましょう。

test-schedule-table-cj.yaml

+ {{- $regions := .Values.global.tables }}

- {{- range $index, $schedule := .Values.global.schedules }}
- {{- range $table := .Values.global.tables }}
+ {{- range $index, $schedule := .Values.global.schedules }}
+ {{- range $table := $tables }} 

-   name: test-{{ $index | add1 }} 
+   name: test-{{ $index | add1 }}-{{ $table.name }} # 名前を一意にするため

しかし、これでもまだ十分ではありません。あくまでスケジュールのindexがほしいのではなく、スケジュール(cron)毎に習得される地域の変数をCronJobのmetadataや環境変数に代入したいのです。

スケジュールの時間毎に、異なる地域のバッチジョブを実行する

golangのSprig libraryを利用してリストを取得するようにしています。 このような形にしたのは、複数のバッチジョブを後述するsubchartに記述する際、values.yamlをDRYにするためです。 全てのリストを1つづつ取得する関数がなかったので、次のような形で再現しています。

test-region-table-cj.yaml

{{- $regions := .Values.global.tables }}

{{- range $index, $schedule := $schedules }}
{{- range $table := $tables }}

# スケジュール毎に地域のリストを取得する
{{- $region := slice $regions $index ( $index | add1 ) | first }}

---
apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: test-{{ $region }}-{{ $table.name }} # indexではなく地域別で名前をつけている
  namespace: {{ $namespace }}
  labels:
    chart: "{{ $chartName }}-{{ $chartVersion }}"
    release: "{{ $releaseName }}"
    heritage: "{{ $releaseService }}"
spec:
  schedule: {{ $schedule }}
  suspend: false
  successfulJobsHistoryLimit: 1
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccount: {{ $serviceAccount }}
          containers:
          - image: "{{ $imageRepository }}:{{ $imageTag }}"
            name: {{ $name }}
            args:
            - /bin/sh
            - -c
            - date; echo This chart has region $(REGION) and table $(TABLE_NAME).
            env:
              - name: TABLE_NAME
                value: "{{ $table.name }}"
              - name: TABLE_CATEGORY
                value: "{{ $table.category }}"
              - name: REGION
                value: {{ $region | quote }}
          restartPolicy: OnFailure
{{- end }}
{{- end }}

values.yamlにはglobal.regionsを、scheduleのリスト長と同一のリストを加えましょう。4

values.yaml

+  regions: [japan, us, uk, china]

これで、地域別のDBのテーブルの情報が取得できるようになりましたね。

サブチャートを作成して、一連のワークフローを一つのChartで再現する

実際には上記のようなバッチジョブが後続に存在しているので、これらをひとまとまりにして扱う必要があります。 最後に、これらバッチジョブをひまとまりにして処理する方法を学びましょう。

複数のjob(Dockerイメージが別)を扱うためにsubchartを採用しました。 ServiceAccount関連のリソースやNetworkPolicyやNamespaceのようなグローバルなリソースは大本のChartにまとめ、各ジョブをsubchartに入れるという形を取ります。

values.yamlの項目で、globalの変数と、そうでないsubchart毎のローカル変数を使いわけることで実現できます。

以下にサンプルのディレクトリ構成を挙げます。

.
├── Chart.yaml
├── charts
│   ├── 0101-job
│   │   ├── Chart.yaml
│   │   └── templates
│   │       ├── _helpers.tpl
│   │       ├── xxx-cronjob.yaml
│   │       └── tests
│   │           └── test-xxx.yaml
│   ├── 0102-job
│   │   ├── Chart.yaml
│   │   └── templates
│   │       ├── _helpers.tpl
│   │       ├── yyy-cronjob.yaml
│   │       └── tests
│   │           └── test-yyy.yaml
│   └── 0103-job
│       ├── Chart.yaml
│       └── templates
│           ├── _helpers.tpl
│           └── zzz-cronjob.yaml
│           └── tests
│               └── test-zzz.yaml
├── templates
│   ├── NOTES.txt
│   ├── _helpers.tpl
│   ├── namespace.yaml
│   ├── networkpolicy.yaml
│   ├── secrets
│   │             ︙
│   └── serviceaccount.yaml
└── values.yaml

個別のyaml設定の注意点

各サブチャート毎に分化されたschedulesはグローバルの設定ではなく、個別のジョブ毎のローカルの設定になるため、各subchart内では、.Values.global.schedulesから.Values.schedulesのようにグローバルのスコープをローカルのスコープに変更しておきましょう。

+ {{- $category := .Values.schedules }}
- {{- $tables := .Values.global.schedules }}

フラグで実行するジョブバッチを選択する

後でsubhart毎にテストをするには、個別のsubchartだけを実行する必要があります。 そのため、enabledを各subchartに記述します。

以下のように各CronJobリソースを条件式{{- if .Values.enabled }}で挟んで、

test-subchart-cj.yaml

{{- if .Values.enabled }}
{{- range $index, $schedule := $schedules }}
{{- range $table := $tables }}
---
apiVersion: batch/v1beta1
︙
{{- end}}
{{- end}}
{{- end}}

values.yamlに以下の設定を入れます。

values.yaml

0101-job:
  enabled: true # 全てtrueにしておく
  imageRepository: xxx
︙

実行時に不必要なジョブに対してfalseのフラグを立てることで個別実行が実現できます。

$ helm upgrade --install jobs --set 0101-job.enabled=true --set 0102-job.enabled=false --set 0103-job.enabled=false .

上記は0101-jobだけがChartだけがデプロイされる例になります。

テストに関して1つ注意点があります。 テストを行う毎はテストの実行ジョブの順番が変わるのでご注意下さい。5

完成したvalues.yaml

最終的なvalues.yamlは次のようになります。

values.yaml

global:
  # kubernetes
  namespace: test-ns
  serviceAccount: test-sa

  # table info
  tables: 
    - name: XxxTest
      category: walk
    - name: YyyTest
      category: walk
    - name: ZzzTest
      category: run
     

  regions: [japan, us, uk, china]

  # test config
  tests:

    tables: 
      - name: XxxTest
        category: walk

    regions: [japan, china]

0101-job:
  enabled: true
  imageRepository: xxx
  imageTag: 1.0.0
  schedules: [
    '0 0 * * *',  '10 0 * * *',
    '20 0 * * *', '30 0 * * *'
    ]

0102-job:
  enabled: true
  imageRepository: yyy
  imageTag: 1.0.0
  schedule: '0 1 * * *'

0103-job:
  enabled: true
  imageRepository: zzz
  imageTag: 0.1.0

  schedules: [
    '0 2 * * *', '10 2 * * *',
    '20 2 * * *', '30 2 * * *'
    ]

おわりに

今回ループ処理化したCronJob Aが成功したらCronJob Bを実行すると言ったワークフローは単純なスケジュール(cron)でHelm Chart化しました。

その他ハマりどころとしてはvalues.yamlのsubchart名と一致しないChart名、ディレクトリ名になっているとチャートがデプロイされないという事例がありました。案外、見落とします。 名前を変更した際には確認するようにしましょう。

最後に注意点で、今回のエントリでは実運用で想定するようなTLSの暗号化通信やSecurityContext、Compute Resourcesなど省略しているので、それぞれの環境に合わせて設定していただればと思います。

以上!

仲間募集

ユーザベースのSPEEDA SREチームは、No Challenge, No SRE, No SPEEDA を掲げて業務に取り組んでいます。
「挑戦しなければ、SREではないし、SREがなければ、SPEEDAもない」という意識で、日々ユーザベースのMissionである、「経済情報で、世界をかえる」の実現に向けて邁進しています。

少しでも興味を持ってくださった方はこちらまで


  1. いわゆるwall of YAML

  2. 割り当てたい変数(今回でいうと地域)毎にスケジュール(cron)を生成します

  3. 63文字の制限があります

  4. 本来は順序が逆ですが、説明の便宜上このように書いています

  5. 恐らく前回の実行のリストから+1されています