entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

Kubernetes Jobをテンプレートから生成するCRDを作りました

こんにちは。lyluckです。

複数の似たようなJobやCronJobを記述するとほとんどの設定がコピペになり、管理が非効率になる問題を解決するOSSを作りました。 github.com

背景

オンプレミスで稼働しているバッチ処理をKubernetes(以下、k8s)へ移行する場合、シンプルに実現する場合はJob(以下、bJob)やCronJob(以下、bCronJob)をクラスター上に作成することになります。

移行対象のバッチ処理は一つや二つではなく、大量にありました。大部分は同じ内容なのですが、コマンドの引数など細かい差異がありました。

マニフェスト管理には基本的にkustomizeを使っています。環境ごとの差分は簡単に書けますが、元(base)となるマニフェストはバッチ処理ごとに必要でした。

シェル芸でなんとかする方法(テンプレートのyamlファイルの特定の箇所を引数によって別の値に置換)も考えましたが

  • 差異の種類が多く、スクリプトの引数で対応しきることが難しい
  • 差異は単純な文字列だけではなく、構造を持つものがある(例えばresources)

といったことから、結局手でマニフェストを編集する必要があり、あまり効率がよくありません。

Helmチャートの作成も考えましたが、テキストテンプレートで頑張ってテンプレートを書くので、記述量が多くて大変だったため見送りました。

まとめ

大量の類似しているバッチ処理をk8sへ効率よく移行するため、複数のbJobやbCronJobを共通部分と差分のペアの形式で、効率的に生成できるCRDを実装しました。 github.com

  • PodProfile: bJobやbCronJobが起動するPodのテンプレートとなるPodTemplateSpecを共通部分として定義する
  • Job: bJobを生成する。bJobパラメータ(ここではbJob.specのtemplate以外のこと。activeDeadlineSecondsなど)とPodProfileに対するパッチを含む
  • CronJob : bCronJobを生成する。bCronJobパラメータ(ここではbCronJob.specのjobTemplate以外のこと。scheduleなど)とPodProfileに対するパッチを含む

例えばこのようにPodProfileとCronJobを定義すると

apiVersion: pixiv.net/v1
kind: PodProfile
metadata:
  name: podprofile-sample
spec:
  template:
    spec:
      containers:
        - name: pi
          image: perl:5.34.0
          command: ["perl", "-Mbignum=bpi", "-wle", "print bpi(500)"]
      restartPolicy: Never
---
apiVersion: pixiv.net/v1
kind: CronJob
metadata:
  name: cronjob-sample
spec:
  schedule: "* * * * *"
  jobProfile:
    podProfileRef: podprofile-sample
    patches:
      - op: replace
        path: /spec/containers/0/command
        value: ["perl", "-Mbignum=bpi", "-wle", "print bpi(10)"]
    jobParams:
      activeDeadlineSeconds: 120

以下のようなbCronJobを生成します。

apiVersion: batch/v1
kind: CronJob
metadata:
  name: cronjob-sample-pxvcjob
  ownerReferences:
  - apiVersion: pixiv.net/v1
    blockOwnerDeletion: true
    controller: true
    kind: CronJob
    name: cronjob-sample
    uid: 6b35de70-fcd0-4a6d-a35d-5f8cfe08a5de
  uid: 364495ba-0239-4e7b-9911-f85059726f68
spec:
  concurrencyPolicy: Allow
  failedJobsHistoryLimit: 1
  jobTemplate:
    metadata:
      creationTimestamp: null
    spec:
      activeDeadlineSeconds: 120
      backoffLimit: 6
      completionMode: NonIndexed
      suspend: false
      template:
        metadata:
          creationTimestamp: null
        spec:
          containers:
          - command:
            - perl
            - -Mbignum=bpi
            - -wle
            - print bpi(10)
            image: perl:5.34.0
            imagePullPolicy: IfNotPresent
            name: pi
            resources: {}
            terminationMessagePath: /dev/termination-log
            terminationMessagePolicy: File
          dnsPolicy: ClusterFirst
          restartPolicy: Never
          schedulerName: default-scheduler
          securityContext: {}
          terminationGracePeriodSeconds: 30
  schedule: '* * * * *'
  successfulJobsHistoryLimit: 3
  suspend: false

詳細

CRDとは

カスタムリソースの定義のことです。カスタムリソースとはk8s APIを拡張するものです。

k8s標準のPodやbJobなどとは別の、新しい種類の独自のリソースとその振る舞いを定義できます(オペレーターパターン)。

実現したいこと

今回、CRDで実現したいことはこれらです。

  • 複数のバッチ処理に共通するマニフェストの部分を柔軟に定義できる
  • 共通部分のマニフェストに柔軟にパッチをあてることができる
  • 上の二つを利用して、bJob, bCronJobを生成できる

定義するカスタムリソース

次のカスタムリソースを定義することにしました。

  • PodProfile : マニフェストの共通部分
  • Job : 共通部分にパッチをあててbJobを作る
  • CronJob : 共通部分にパッチをあててbCronJobを作る

APIスキーマを定義する

カスタムリソースのマニフェストに何を書けるかを決めます。

例えばPodのマニフェストではspecの下にcontainersやvolumesを記述できますが、独自のリソースでは何を書けるようにしたいか、ということです。

PodProfile

どのbJob, bCronJobにも共通して存在するPodTemplateSpecを保持させます。PodTemplateSpecとは次のbJobのspec.template.spec以下のことです。

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  template:
   spec:
      containers:
      - name: pi
        image: perl:5.34.0
        command: ["perl",  "-Mbignum=bpi", "-wle", "print bpi(2000)"]
      restartPolicy: Never
  backoffLimit: 4
Job

次の三つを保持させます。

  • テンプレートとして参照するPodProfileの名前
  • PodTemplateSpecにあてるパッチ
  • bJobパラメータ
CronJob

次の三つを保持させます。

  • テンプレートとして参照するPodProfileの名前
  • PodTemplateSpecにあてるパッチ
  • bCronJobパラメータ

CRDプロジェクトの雛形を作る

kubebuilderを使いました。kubebuilderはSIG API Machineryのプロジェクトで、CRDを作るためのフレームワーク、ライブラリやコード生成ツールの集まりです。ドキュメントが充実しており、入門しやすいです。

CRDとは

  • APIスキーマ
  • controller: マニフェストの通りのワークロードをk8sクラスターに反映し続けるプロセス
  • controllerをk8sクラスターにデプロイするためのマニフェスト

などからなるものです。ゼロベースで書くと記述量が多いです。

パッチのあてかた

JobやCronJobが保持するパッチを、PodProfileが保持するPodTemplateSpecに適用し、bJobやbCronJobのPodTemplateSpecを生成する必要があります。

パッチの適用方法としては2通り考えましたが、json patchを採用しました。

どう違うのかを説明します。説明のためにオリジナル(パッチをあてる対象)のマニフェストが以下だとします。適用したいパッチの内容はspec.template.spec.containers[0].nameをmainに変更することだとします。

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  template:
    spec:
      containers:
      - name: pi  # pi を main に変えたい
        image: perl:5.34.0
        command: ["perl",  "-Mbignum=bpi", "-wle", "print bpi(2000)"]
      restartPolicy: Never
  backoffLimit: 4

patches strategic mergeでは以下のように書きます。

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  template:
    spec:
      containers:
      - name: main

json patchでは以下のように書きます。

- path: /spec/template/spec/containers/0/name
  op: replace
  value: main

patches strategic mergeは元のマニフェストと構造が似ていてわかりやすいですが、変更したい箇所までの構造を書く必要があります。パッチ対象を指定する情報(apiVersion, kind, metadata.name)がパッチの内容に含まれているため、使い回しが難しいです。

json patchでは構造ではなく変更箇所までのパスを指定します。こちらのほうが記述量が少なく、使い回しがききます。

パッチの実装

2通り考えました。

  • k8sライブラリを使ってマニフェストのstructにパッチをあてる
  • kubectl kustomizeコマンドをプロセス呼び出しする

後者を採用しました。

k8sライブラリを使ってマニフェストのstructにパッチをあてる

k8sのライブラリは巨大で複雑です。今回のカスタムリソースでは

を読んで適切なライブラリを見つける必要がありました。私にはjson patchの変換処理や適切な型変換がどれなのかを簡単に見破ることはできませんでした。

kubectl kustomizeコマンドをプロセス呼び出しする

kustomizationファイルに従ってマニフェストを生成するコマンドです。

kustomizationファイルやパッチ対象ファイル、パッチファイルを適切に配置、生成してコマンドを呼び出せば、パッチが適用されたマニフェストを作ってくれます。

型を気にせず、yamlへシリアライズしてコマンドを呼び出し、その結果をデシリアライズすれば目的を果たせます。

controllerの実装

与えられたJobやCronJobのマニフェストが指定する望ましい状態(spec)を、k8sクラスターに反映し続けるプロセスを実装します。

反映し続けるというところがミソで

  • マニフェストが変更された
  • マニフェストが依存しているリソースが変更された(ここではPodProfile)
  • 反映したリソース(bJobやbCronJob)の状態が望ましい状態からはずれた

といったとき、望ましい状態に戻るような操作(Reconcile)を実行しなければなりません。

kubebuilderではそのようなイベントが発生した時にReconcileを呼び出すように簡単に設定できます。

JobのReconcile

次のような処理をします。

  1. Jobと依存するPodProfileのマニフェストからPodTemplateSpecを作る
  2. 1とJobからbJobのマニフェストを作る
  3. bJobを生成する

順番に説明します。

1. Jobと依存するPodProfileのマニフェストからPodTemplateSpecを作る

kubectl kustomize を使ってパッチを適用します。

2. 1とJobからbJobのマニフェストを作る

Jobが保持しているactiveDeadlineSecondsなどのパラメータを新しいbJobマニフェストに書き込みます。

3. bJobを生成する

無条件に生成するわけではありません。Reconcileは様々なイベントから呼び出されるので、無条件だとbJobが無数に発生してしまいます。場合分けが必要です。

  • 既存のbJobがない場合
  • 既存のbJobが完了していて、specが2のマニフェストと異なる場合

前者はJobが所有するbJobがない場合です。 望ましい状態のbJobが存在していないので生成します。

後者はJobが所有するbJobがある場合です。 既存のbJobが完了していない場合は何もしません。Jobは一つのバッチ処理に対応するため、古いバージョンと新しいバージョンのbJobが同時に起動していると問題がある場合が多いからです。

specが異なるということは望ましいbJobの設定が異なるということです。specが異なるかどうかをすばやく判定するため、あらかじめbJobの jobs.pixiv.net/job-spec-hash ラベルにspecのハッシュ値を記録しています。

Jobの履歴

JobはbCronJobのようにbJobの実行履歴を持ちます。実行が完了したbJobについては、成功にせよ失敗にせよログを閲覧できる猶予がほしいからです。

JobのTTL

bJobパラメータにはttlSecondsAfterFinishedがあります。完了してから指定した時間経過したら自身を消すという設定です。

素直にbJobに設定してしまうとJobとしては意図しない挙動になります。

ReconcileはbJobの削除時にも呼ばれるため、既存のbJobがTTLで削除されなくなった状態でReconcileします。その場合は新しいbJobが生成されてしまいますが、ユーザーはそのためにttlSecondsAfterFinishedを設定するわけではありません。

しかし、もともと既存bJobがなかった(Job新規作成時)のか、TTLで既存bJobがなくなって、結果として既存bJobがないのかをReconcileが見分けることは難しいです。仮にbJobの削除イベントを無視したとしても、別のイベント(PodProfileの変化など)が発生すると既存bJobがない状態でReconcileが呼ばれます。

そのため、TTLでのbJob消去処理は独自に実装しました。TTLで既存bJobがすべてなくなってしまう場合はTTLを無視します。

CronJobのReconcile

次のような処理をします。

  1. CronJobJobと依存するPodProfileのマニフェストからPodTemplateSpecを作る
  2. 1とCronJobからbCronJobのマニフェストを作る
  3. bCronJobマニフェストを生成もしくはapplyする

Jobと比べると簡単です。bJobは再生成が必要ですが、bCronJobの修正処理はServer-Side Applyで済むからです。

controllerのマニフェスト

controllerをk8sクラスターにデプロイするためのマニフェストです。

kubebuilderがもともとkustomize形式のマニフェストを作ってくれていましたが、利用しやすさの観点からHelmチャートにしました。kubebuilderにはHelmチャートを生成してくれるプラグインがあります。

Helmチャートではcontrollerのresourcesなど、変更可能な箇所が一覧(values.yaml)になっており把握しやすく、それと同じ構造のファイルを書けばパッチを適用できます。kustomizeでは変更したい箇所の構造を調べてパッチを書く必要があります。

lyluck
2022年にピクシブ株式会社に中途入社。趣味はピアノと音ゲー。オンプレミスのミドルウェアやKubernetesを担当。