kubernetesには、Horizontal Pod Autoscaler(HPA)と呼ばれるオブジェクトがある。 HPAは、特定のDeployment配下のPodの平均CPU使用率などの値によってDeploymentのPodの数(=replicas)を増減させてくれる仕組みだ。 基本的にkubernetesではこれによってトラフィックに応じたオートスケーリングを実現するのが一般的だ。
hpa diagram
しかし、実際の運用の中では事前にトラフィックの急増が予測できたりする。 単純に仕事終わりの時間帯や、TVなどのメディアで取り上げられることがわかっていたり、特定の製品の発売日を迎たりする場合だ。

このような場合、事前にPodの数を増やしておくことでよりスムーズに大量のトラフィックに対応することができる。
このような時間ベースの変更のやり方として、kubernetesのCronJobを使う方法が考えられる。 Cronjobを使うとスケジュールに従って Job を作成してくれる。JobはPodを作るので、Podの中でkubectl scale相当のことを実施すれば事前に Podの数を変更しておくことができる。

だが、このようなやり方ができるのはスケールイン/アウトが定期的である場合に限られる。 CronJobのスケジュールは通常のcronコマンドと同じで、分、時、日、週、月を指定して、特定のタイミングでの実行を繰り返すことになる。 このため、仕事終わりの時間帯に合わせてスケールアウトをするのには向いているが、製品の発売日やメディア露出 のためのスケールイン/アウトを実施するのには向いていない。

CronJob以外にも時間ベースでkubernetesをスケールさせてくれるための仕組みはあるが、いずれもcron準拠で定期的なものだった。
https://github.com/LiliC/kube-start-stop
https://github.com/amelbakry/kube-schedule-scaler

このような不定期なスケールイン/アウトを実現するため、1つポッドを動かし続けて、その中で 特定の時刻が来たらスケールイン/アウトを実行させていた。 しかし、これは障害耐性のことを考えるとあまり賢い方法ではなかった。スケールイン/アウトを実施するポッド が1つなので、該当の時間にポッドが落ちていた場合には何も動かない。だからと言ってポッドの数を増やして冗長化すると どのポッドがスケールを決めるのかという問題が発生する。(Leader Electionが必要になる。)

kubernetes上のAT

というわけで、atコマンドをkubernetesで作ってみることにした。

atコマンドとは以下のように特定の時間に1回だけ特定のコマンドを実行してくれるコマンドだ。

echo hello > /tmp/sample | at 11:50

同じように特定の時間に1回だけ、Jobを作成するATというオブジェクトをkubernetes上に作れば、不定期なスケールイン/アウトに対応することができる。 kubernetesでは、独自にCustom Resource Definition(CRD) とそれの制御をおこなうPod(コントローラー)を作ることで独自のリソースをクラスタ上に定義できてしまう(すごい)。 フレームワーク(kubebuilder)を使って作ればLeader Electionもばっちりやってくれる。また、CRDにはほかにも以下のような利点がある。

  • kubectlからオブジェクトが見えるのでチーム内の誰でもスケジュールをメンテナンスできる。
  • 入力値のバリデーションを行える。
  • ほかのリソースと同様に定義をyamlにできるので、Infrastructure as a code的にGood

kubebuilderを使うためには覚えることが結構あるが、 ATを作るうえで考えるべきことは以下の2つに集約されると思う。

  • ATオブジェクトはどのようなデータ構造を持つか?
  • ATオブジェクトのControl Loopはどのようなものになるか?

ATのデータ構造

トップレベルのATのデータ構造は以下のようになっている。

type AT struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   ATSpec   `json:"spec,omitempty"`
	Status ATStatus `json:"status,omitempty"`
}

実はここまではテンプレで、ATは各種メタデータとSpec(ATを定義するための情報)とStatus(Control Loopやユーザーのための状態)を持っている。 (別にATに限らずここまではほとんどのケースで同じになるはず)

type ATSpec struct {
	// JobTemplate that is run by AT
	JobTemplate batchv1beta1.JobTemplateSpec `json:"jobTemplate"`
	// Time which we run this job
	Schedule *metav1.Time `json:"schedule"`
}

Specに関しては上のようにしてみた。
JobTemplateは通常のkubernetesのJobを作る際のものと同じで通常Jobを作るときには以下のように指定しているものになる。

apiVersion: batch/v1
kind: Job
metadata:
  name: hello
spec:
  template:
    spec:
      containers:
      - name: hello
        image: busybox
        args:
        - /bin/sh
        - -c
        - echo "hello world"
      restartPolicy: Never

Scheduleに関しては、cronだと5 4 * * *のような形式で指定するところだが、特定の時刻に1回だけ実行 するものなので*metav1.Timeで時刻を指定してもらうようにした。(yamlからATを作る際には、RFC3339の形式で指定できるようになる。)

type ATStatus struct {
	// Status of AT
	// +optional
	LastScheduleTime *metav1.Time `json:"lastScheduleTime,omitempty"`
	// AT Status
	// +optional
	ATScheduleStatus ATScheduleStatus `json:"status,omitempty"`
}

Statusに関しては上のようにした。LastScheduleTimeにはCronJobのLastScheduleTimeにならって、Jobを作成した時間をControl Loopで入れることにする。 ATScheduleStatusにはPending(まだJobを実行する時間じゃない),Succeeded(Job成功)、Stale(もうJobを実行させていい時間を過ぎてしまった) のような状態を持たせる。(実はStaleが未実装)

ATのControl Loop

Control Loopを実装するには ReconcilerインターフェイスReconcileメソッドを実装することになる。 Reconcileの定義は以下のような感じで、引数がRequestで戻り値がResulterrorだ。

type Reconciler interface {
    // Reconciler performs a full reconciliation for the object referred to by the Request.
    // The Controller will requeue the Request to be processed again if an error is non-nil or
    // Result.Requeue is true, otherwise upon completion it will remove the work from the queue.
    Reconcile(Request) (Result, error)
}

Requestは特定のオブジェクトとネームスペースの組み合わせだ。 例えば、defalutネームスペースにsampleというATを作った場合には、defalutとsampleの組み合わせをもらえる。 Resultは次にReconcileが呼び出されるまでの時間を指定するものになっている。

まとめると、Reconcileでは、Control Loopの対象となるオブジェクトの名前を受け取るので、それに対する制御を行い 次にReconcileが必要になる時間を返却することになる。

ATのReconcileでは以下のようなことを行った。

  • ATが子供のJobをもっておらず、まだ実行のタイミングでない場合、実行のタイミングでReconcileがまた呼び出されるようにResultを返却
  • ATが子供のJobをもっておらず、実行のタイミングであった場合、子供のJobを作成。
  • ATが子供のJobを持っていた場合、Jobの状態に従ってATの状態(ATStatus)を変更する。

実際のコードは以下。
https://github.com/sato-s/k8s-at/blob/master/controllers/at_controller.go#L47-L108

ATを作ってみる

以下のようにATの定義を用意して実際にATを作成してみた。(CRDとコントローラーのインストールはgithubを参照)

apiVersion: batch.my.domain/v1
kind: AT
metadata:
  name: at-sample
spec:
  schedule: "2020-03-21T13:53:55Z"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: hello
            image: busybox
            args:
            - /bin/sh
            - -c
            - sleep 30; echo Hello from the Kubernetes cluster
          restartPolicy: OnFailure

上をkubectl create -fで作ると以下のようにPending状態のATが作成される。

$ kubectl get at
NAME        STATUS    LASTSCHEDULETIME   SCHDULE
at-sample   Pending                      2020-03-21T13:53:55Z

scheduleに指定した時刻になると、以下のようにJobTemplateで指定されたJobを作成してくれる。

$ kubectl get job
NAME                   COMPLETIONS   DURATION   AGE
at-sample-1584798835   1/1           34s        13h

Jobが未完了の場合、以下のようにATのステータスはRunningになる。

$ kubectl get at
NAME        STATUS    LASTSCHEDULETIME       SCHDULE
at-sample   Running   2020-03-21T13:53:55Z   2020-03-21T13:53:55Z

Jobの完了後、ATのステータスもSucceededになった。

$ kubectl get at
NAME        STATUS      LASTSCHEDULETIME       SCHDULE
at-sample   Succeeded   2020-03-21T13:53:55Z   2020-03-21T13:53:55Z

感想

kubebuilderを使って、CRDとコントローラーを作るのは正直結構苦労したが、それでもここまで簡単に機能を拡張できるのは さすがkubernetesだと思う。CRDはAWS S3のような外部のオブジェクトをkubernetes上のオブジェクトに マッピングしたりなど応用範囲が広いので覚えておいて損がなさそう。 S3も今回のATもそうだが、kubernetes上に存在しないオブジェクトをCRDで作るようにしておくと、関係する資産をすべてkubernetes用のyaml の中に記載できるので、Infrastructure as a Codeのためにもよさそう。