サイドカーコンテナを含む Kubernetes Job を完了させる


## TL;DR

- Kubernetes でマルチコンテナな Pod はよく使われる
	- Cloud SQL Proxy はサイドカーコンテナとして動く前提
- Job の完了には Pod を構成するプロセスが完了する必要がある
	- PID Namespace は未実装
		- シグナルが送れない
	- 完了しないと不意に再開する
- サイドカーコンテナを殺すためのプログラムを書いてみた

## サイドカーコンテナパターン

Kubernetes は複数のコンテナからなる Pod を最小の単位として扱う。Google の社内コンテナ管理システムの Borg にも同様の仕組みがあり、その経験から「[Design Patterns for Container-based Distributed Systems](https://www.usenix.org/conference/hotcloud16/workshop-program/presentation/burns)」でコンテナデザインパターンとして複数コンテナの連携のパターンを定義している。
このコンテナデザインパターンの中でも頻出するのが、メインのコンテナに機能性の拡張をするためにコンテナ間の共有ボリュームを利用するサイドカーコンテナパターンだ。

## 問題 サイドカーコンテナを含む Job が完了しない

GKE を使っていると下記のように Cloud SQL Proxy をサイドカーコンテナとして動かす Job が頻出する。
(注: Pod 内コンテナ間の内部ネットワーク通信を使って外部に接続するアンバサダーパターンも類似しているが、[https://cloud.google.com/sql/docs/container-engine-connect#before_you_begin](ドキュメントでサイドカーだと言っている)のでここではサイドカーとする。)

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: test-job
spec:
  template:
    spec:
      restartPolicy: OnFailure
      containers:
      - name: mysql
        image: mysql:5.7
        command: ["mysql", "$(MYSQL_DB_NAME)", "--execute", "insert into job_test values()"]
        env:
          - name: MYSQL_UNIX_PORT
            value: /share/my-project:asia-northeast1:sandbox-cloudsql
          - name: MYSQL_DB_NAME
            value: job-test
        volumeMounts:
          - name: share
            mountPath: /share
      - name: sql-proxy
        image: b.gcr.io/cloudsql-docker/gce-proxy:1.05
        command: ["/cloud_sql_proxy", "-dir", "/share", "-projects", "my-project"]
        env:
          - name: GOOGLE_APPLICATION_CREDENTIALS
            value: /secret/sql-admin.json
        volumeMounts:
          - name: certs
            mountPath: /etc/ssl/certs
            readOnly: true
          - name: service-account
            mountPath: /secret
            readOnly: true
          - name: share
            mountPath: /share
      volumes:
      - name: certs
        hostPath:
          path: /etc/ssl/certs
      - name: share
        emptyDir: {}
      - name: service-account
        secret:
          secretName: service-account-secret
```

しかし、上のマニフェストから Job を作成すると下記のような結果となる。

```
$ kubectl apply -f job.yaml --namespace job-test
job "test-job" created

$ kubectl get job,pod --namespace job-test
NAME            DESIRED   SUCCESSFUL   AGE
jobs/test-job   1         0            30s

NAME                READY     STATUS      RESTARTS   AGE
po/test-job-kug33   1/2       Completed   1          30s
```

Job は SUCCESSFUL にならず、2つのコンテナのうち1つは終了せずに残っている。
残っているのは明示的に終了していない `sql-proxy` コンテナだ。

### 弊害

終了していないことで何が困るのか？例として2つあげる。

- Job の完了を待って次のアクションをするようなフローを想定しているソフトウェアが動かなくなる。
	- [Helm Hooks](https://github.com/kubernetes/helm/blob/master/docs/charts_hooks.md) は Job の完了に依存しているので、完了しないといつまでも先に進めなくなる。
- Job を構成する Pod は完全には完了していない扱いなので、終了した際に再起動してしまう。
	- 上記の例だと、再起動される度に insert が実行されてしまう。
	- DB アクセスする Job の Pod が未完了で溜まったところで Node トラブルなどが起こるとデータベースに大きな負荷が掛かったり、最悪データが壊れることもある。

## 一つの解決法


http://stackoverflow.com/a/38628708/7301398
この回答で Google の Tim Hockin が Stack Overflow で「emptyDir ボリュームにサイドカーに死ねって教えるファイルを置けば良いじゃん」と言っていたので、そのように動作するプログラムをかんたんに書いてみた。

https://github.com/aktsk/guillotine
業務時間中に書いて業務で使うつもりだったものの特にサポートするつもりはないので参考程度にどうぞ。

### 使ってみる

`guillotine` の動作を解説すると下記のようになる。

- `GUILLOTINE_WATCHED_FILE` 環境変数に `emptyDir` ボリュームとして共有されているパスを指定
- サイドカーコンテナは `guillotine` を通してプロセスを起動
- `Job` の主な処理を行うコンテナは完了時に `GUILLOTINE_WATCHED_FILE` にファイルを作成
  - `GUILLOTINE_WATCHED_FILE` にファイルが作成された時点で `guillotine` は子プロセスごと死にサイドカーコンテナが終了

`guillotine` を使って前述の `job.yaml` を書き換えてみると下記のようになる。

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: test-job2
spec:
  template:
    spec:
      restartPolicy: OnFailure
      containers:
      - name: mysql
        image: mysql:5.7
        command: ["sh", "-c", "mysql $(MYSQL_DB_NAME) -e 'insert into job_test values()' && touch $(GUILLOTINE_WATCHED_FILE)"]
        env:
          - name: MYSQL_UNIX_PORT
            value: /share/my-project:asia-northeast1:sandbox-cloudsql
          - name: MYSQL_DB_NAME
            value: job-test
          - name: GUILLOTINE_WATCHED_FILE
            value: /share/completed
        volumeMounts:
          - name: share
            mountPath: /share
      - name: sql-proxy
        image: asia.gcr.io/my-project/gce-proxy-guillotine:1.05
        command: ["/guillotine", "/cloud_sql_proxy", "-dir", "/share", "-projects", "my-project"]
        env:
          - name: GOOGLE_APPLICATION_CREDENTIALS
            value: /secret/sql-admin.json
          - name: GUILLOTINE_WATCHED_FILE
            value: /share/completed
        volumeMounts:
          - name: certs
            mountPath: /etc/ssl/certs
            readOnly: true
          - name: service-account
            mountPath: /secret
            readOnly: true
          - name: share
            mountPath: /share
      volumes:
      - name: certs
        hostPath:
          path: /etc/ssl/certs
      - name: service-account
        secret:
          secretName: service-account-secret
      - name: share
        emptyDir: {}
```

マニフェストから Job を作成してみると下記のようになる。

```
$ kubectl apply -f job2.yaml --namespace job-test
job "test-job2" created

$ kubectl get job,pod --namespace job-test
NAME             DESIRED   SUCCESSFUL   AGE
jobs/test-job2   1         1            6s
```

ちゃんと SUCCESSFUL になるようになった。
`--show-all` を付けて表示される完了済の Pod を delete しても再起動しないことも確認した。

## 別解・将来的に可能になる解

- Pod 内のコンテナで Downward API から自分自身の Pod 名を取得し API クライアントから他のコンテナの完了を知ることが可能
	- https://kubernetes.io/docs/user-guide/downward-api/
	- https://kubernetes.io/docs/user-guide/accessing-the-cluster/
- Pod 内のコンテナ間での PID Namespace の共有についての議論
	- https://github.com/kubernetes/kubernetes/issues/1615
- Pod 内のコンテナに状態の通知ができるようになる API の議論
	- https://github.com/kubernetes/kubernetes/issues/24957