シェルスクリプトで作る、ゆるふわ Kubernetes Operator

この記事は [武蔵野アドベントカレンダー](https://qiita.com/advent-calendar/2017/musashino) の 15 日目の記事です。武蔵野にある施設には1回しか行ったことがない @ktateish が品川からお送りします。

## Kubernetes Operator

Kubernetes 界隈で注目されているもののひとつに、Operator があります。
Operator とは、 Kubernetes 上で、ステートフルなアプリケーションのインスタンス生成／設定／管理をユーザに代わって実行してくれるソフトウェアのことです。

有名な Operator としては、 etcd-operator や prometheus-operator などがあります。例えば、 etcd-operator の場合、 これを一旦導入すれば、次のようなマニフェストを Kubernetes に投入するだけで、所定の etcd クラスタを構築、維持してくれます。

```yaml
apiVersion: "etcd.database.coreos.com/v1beta2"
kind: "EtcdCluster"
metadata:
  name: "example-etcd-cluster"
spec:
  size: 3
  version: "3.2.11"
```
([etcd-operator同梱のexample](https://github.com/coreos/etcd-operator/blob/master/example/example-etcd-cluster.yaml) より引用)

etcd はステートフルなアプリケーションであり、Kubernetes上で動かそうとするとそれなりにアレコレとお世話をする必要があります。とくに動作中のコンテナやノードが故障した場合、

* 新しいメンバーの起動
* クラスタのメンバー更新

などの操作が必要になります。そこで etcd-operator を使うと、上記のような宣言的なマニフェストを投入するだけで、これらの操作を etcd-operator がいい感じにやってくれるわけです。

この Operator, 「トイルをなくす」という、今流行りの SRE 的取り組みとも方向性が一致しており、作りたいと思ってる人も多いんじゃないでしょうか。

しかし、実際に作ろうとすると、けっこう大変です。というのも、Kubernetes 側には Custome Resource Definition (v1.6以前は Third Party Resource) という k8s に拡張APIを定義する仕組みが用意されているだけで、実際にはかなりの規模の実装が必要になるからです。

そこで今回は、 PostgreSQL 用の Operator の PoC をシェルスクリプトでゆるふわに実装してみたので、これを題材に Operator について解説したいと思います。

## まずは Operator を使ってみる

まずはシェルスクリプトで作られた Operator である postgresql-operator を実際に使ってみましょう。中身についてはその後で解説します。

### postgresql-operator のインストール

以下のような postgresql-operator の Deployment のマニフェストを使います。

```yaml
# example/operator-deployment.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: postgresql-operator
spec:
  replicas: 1
  template:
    metadata:
      labels:
        name: postgresql-operator
    spec:
      containers:
        - name: postgresql-operator
          image: ktateish/postgresql-operator:latest
          volumeMounts:
            - name: backup-volume
              mountPath: /backup
      volumes:
        - name: backup-volume
          emptyDir: {}
```

これを、次のように kubectl で Kubernetes クラスタに投入します。

```shell
$ kubectl create -f example/operator-deployment.yaml
deployment "postgresql-operator" created
```

postgresql-operator の pod が動き始めます。

```shell
$ kubectl get pods
NAME                                   READY     STATUS    RESTARTS   AGE
postgresql-operator-3674802606-l54tk   1/1       Running   0          1m
```

postgresqls.exp.wheel.jp という Custom Resource Definition (CRD) がインストールされていれば成功です。

```shell
$ kubectl get crd
NAME                       KIND
postgresqls.exp.wheel.jp   CustomResourceDefinition.v1beta1.apiextensions.k8s.io

```

これにより、 この Kubernetes クラスタでは PostgreSQL という新たなリソースを持つことができるようになりました。

この PostgreSQL リソースは、指定されたスペックのデータベースを運用する責任をもっており、以下のことを運用者に代わって行います。

* Service の作成
* Deployment の作成
* DB のバックアップ
* DB のリストア(障害などで Pod がリスケジュールされた場合)

前者2つはKubernetesの普通の機能ですが、後者2つは単純に postgres のイメージを使っただけのPodでは実現できません。 PostgreSQL リソースはこれらをセットでユーザに提供します。

なお、 PostgreSQL リソースのマニフェストには、データベース名、データベースユーザ、パスワードを宣言的に指定することができます。

### PostgreSQL リソース用マニフェストの投入

PostgreSQL リソースを実際に作成してみます。次のマニフェストを使います。

```yaml
# example/db-for-foo.yaml
apiVersion: "exp.wheel.jp/v1"
kind: PostgreSQL
metadata:
  name: foodb
spec:
  db: foo
  user: foo
  password: foodb-password
---
apiVersion: v1
kind: Secret
metadata:
  name: foodb-password
data:
  postgresql_password: bXlwYXNzd29yZA==
```
なお、 spec.password は、生のパスワードではなく、 data.postgresql_password にパスワードを格納した Secret 名を指定します。ここでは、Secret を同じマニフェストファイルで定義しています。
これを、次のように kubectl で Kubernetes クラスタに投入します。

```shell
$ kubectl create -f example/db-for-foo.yaml
postgresql "foodb" created
secret "foodb-password" created
```

しばらくすると、 Service, Deployment が作成され、Pod が Deployment によって作成されます。

```shell
$ kubectl get svc foodb
NAME      CLUSTER-IP    EXTERNAL-IP   PORT(S)          AGE
foodb     10.32.0.242   <nodes>       5432:31818/TCP   4m
$ kubectl get deploy foodb
NAME      DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
foodb     1         1         1            1           4m
$ kubectl get pods --selector name=foodb
NAME                     READY     STATUS    RESTARTS   AGE
foodb-1427420837-x60l5   1/1       Running   0          4m
```

psql でアクセスして、動作確認できます。

```shell
$ env PGPASSWORD=$(kubectl get secret foodb-password -o jsonpath='{.data.postgresql_password}' | base64 -d) \
    psql -h worker0 \
         -p $(kubectl get svc foodb -o jsonpath='{.spec.ports[0].nodePort}') \
         -Ufoo \
       foo -c 'SELECT version()'
                                             version
--------------------------------------------------------------------------------------------------
 PostgreSQL 10.1 on x86_64-pc-linux-gnu, compiled by gcc (Debian 6.3.0-18) 6.3.0 20170516, 64-bit
(1 row)
```

password と port は kubernetes に問い合わせていますので、どの環境でもこの通りに書けば動作します。
worker0 はワーカーノードを指定していますので、環境に合わせて変更してください。

### データを作成したあと、意図的に Pod を削除し、自動的にリストアしてくれることを確認する

次に Operator がちゃんと仕事をするかどうか確認してみます。
まず、 適当なデータを投入します。

```shell
$ env PGPASSWORD=$(kubectl get secret foodb-password -o jsonpath='{.data.postgresql_password}' | base64 -d) \
    psql -h worker0 \
         -p $(kubectl get svc foodb -o jsonpath='{.spec.ports[0].nodePort}') \
         -Ufoo \
       foo -c "CREATE TABLE hoge (id SERIAL, name TEXT); \
               INSERT INTO hoge (name) VALUES ('foo'); \
               INSERT INTO hoge (name) VALUES ('bar');"
INSERT 0 1
$ env PGPASSWORD=$(kubectl get secret foodb-password -o jsonpath='{.data.postgresql_password}' | base64 -d) \
    psql -h worker0 \
         -p $(kubectl get svc foodb -o jsonpath='{.spec.ports[0].nodePort}') \
         -Ufoo \
       foo -c "SELECT * FROM hoge"
 id | name
----+------
  1 | foo
  2 | bar
(2 rows)
```

バックアップが動作するであろう時間(1分くらい)を待って、おもむろに Pod を削除します

```shell
$ kubectl get pods --selector name=foodb
NAME                     READY     STATUS    RESTARTS   AGE
foodb-1427420837-x60l5   1/1       Running   0          10m
$ kubectl delete pod foodb-1427420837-x60l5
pod "foodb-1427420837-x60l5" deleted
```

もちろんしばらくは Service にアクセスできません。

```shell

$ env PGPASSWORD=$(kubectl get secret foodb-password -o jsonpath='{.data.postgresql_password}' | base64 -d) \
    psql -h worker0 \
         -p $(kubectl get svc foodb -o jsonpath='{.spec.ports[0].nodePort}') \
         -Ufoo \
       foo -c "SELECT * FROM hoge"
psql: could not connect to server: Connection refused
	Is the server running on host "worker0" (172.16.1.152) and accepting
	TCP/IP connections on port 31818?
```

Deployment によって Pod が作成されているのを確認したら、 SELECT してみます。

```shell
$ kubectl get pods --selector name=foodb
NAME                     READY     STATUS    RESTARTS   AGE
foodb-1427420837-xj1vp   1/1       Running   0          1m
$ env PGPASSWORD=$(kubectl get secret foodb-password -o jsonpath='{.data.postgresql_password}' | base64 -d) \
    psql -h worker0 \
         -p $(kubectl get svc foodb -o jsonpath='{.spec.ports[0].nodePort}') \
         -Ufoo \
       foo -c "SELECT * FROM hoge"
 id | name
----+------
  1 | foo
  2 | bar
(2 rows)

```
無事、リストアされているようです。
postgresql-operator の /backup に PVC をマウントしておけば、ノード故障も乗り越えて動作します。(例では EmptyDir をマウントしているので postgresql-operator と foodb が同じノードに乗っている場合、ノードが故障するとデータを失います)

## 中身の解説

まず、スクリプト全体は https://github.com/ktateish/postgresql-operator/blob/master/docker-entrypoint.sh のとおりです。
制御の中心となる処理は次の while ループで、 

* CRD が作成されていなければ作成する
* 登録された当該 CRD のリソースごとに、そのスペックに従って `ensure_service()` を呼び出し、サービスを定常状態に維持する

というものです。

```shell
c=$backup_interval_factor
while :; do
	c=$((c - 1))
	if ! check_crd $crd_name; then
		echo "Create CRD: $crd_name"
		create_crd $crd_name
		sleep 1
		continue
	fi

	for i in $(kubectl get $crd_name -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.end}'); do
		ensure_service $(kubectl get $crd_name $i -o jsonpath='{.metadata.name}{"\t"}{.spec.db}{"\t"}{.spec.user}{"\t"}{.spec.password}{"\n"}') $c
	done

	if [ $c -le 0 ]; then
		c=$backup_interval_factor
	fi

	sleep $interval
done
```

`create_crd()` では、次のようなマニフェストをインストールしています

```yaml
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  # name must match the spec fields below, and be in the form: <plural>.<group>
  name: postgresqls.exp.wheel.jp
spec:
  # group name to use for REST API: /apis/<group>/<version>
  group: exp.wheel.jp
  # version name to use for REST API: /apis/<group>/<version>
  version: v1
  # either Namespaced or Cluster
  scope: Namespaced
  names:
    # plural name to be used in the URL: /apis/<group>/<version>/<plural>
    plural: postgresqls
    # singular name to be used as an alias on the CLI and for display
    singular: postgresql
    # kind is normally the CamelCased singular type. Your resource manifests use this.
    kind: PostgreSQL
    # shortNames allow shorter string to match your resource on the CLI
    shortNames:
    - pg
```

`ensure_service()` では愚直に Service や Deployment の起動を存在を確認しては、なければ作成する、という処理を実行しています。

```shell
ensure_service() {
	local name=$1
	local db=$2
	local user=$3
	local password=$4
	local do_backup=$5

	if ! check_service $name; then
		echo "Service not found: $name"
		create_service $name
		return
	fi

	if ! check_deployment $name; then
		echo "Deployment not found: $name"
		create_deployment $name $db $user $password
		return
	fi

	local pod_name=$(get_pod_name $name)

	if [ -z "$pod_name" ]; then
		echo "Pod not ready: $name"
		# deployment will cerate pod for us
		return
	fi

	if ! check_pod_app_ready $pod_name; then
		echo "Application not ready: $pod_name ($name)"
		get_pod_app_ready $pod_name $name $db $user $password
		return
	fi

	if [ $do_backup -eq 0 ]; then
		backup_pod_app  $pod_name $name $db $user $password
	fi
}
```

### アプリケーション特有の操作

Service の作成と Deployment の作成だけであれば、わざわざ Operator を作る必要はありません。 PostgreSQL というアプリケーションに特有な操作の実行箇所を見ていきます。

Podにリストアが必要かどうかは `ensure_service()` 内の `check_pod_app_ready()` で見ており、 `appReady` というラベルが `ok` という値をもっているかどうかで確認しています。

```shell
check_pod_app_ready() {
	local pod_name=$1

	test "$(kubectl get pod $pod_name -o jsonpath='{.metadata.labels.appReady}')" = "ok"
}
```

ラベル `appReady=ok` がついていなければ、get_pod_app_ready() でデータベースをリストアし、 `appReady=ok` というラベルをつけます。

```shell
get_pod_app_ready() {
	local pod_name=$1
	local name=$2
	local db=$3
	local user=$4
	local password=$5

	echo "Make the application ready: $pod_name ($name)"
	bk="backup/${name}.sql"
	if [ -f "${bk}" -a -s "${bk}" ]; then
		echo "Restore from backup: $name"
		ip=$(pod_ip $pod_name)
		pw=$(app_password $password)
		env PGPASSWORD=$pw psql -q -h $ip -U$user $db -f "${bk}" && \
		kubectl label --overwrite pod $pod_name appReady=ok
	else
		kubectl label --overwrite pod $pod_name appReady=ok
	fi
}
```

また、バックアップについては `backup_pod_app()` で実行しています。

主要な処理は以上です。細かいところは[スクリプト本体](https://github.com/ktateish/postgresql-operator/blob/master/docker-entrypoint.sh) をご覧ください。

## 結局、Operator とはどういうものか

具体例を見ていただいたので、もうおわかりかと思いますが、 Operator とは以下のことを行うソフトウェアです。

* 独自のCRDをインストールする
* そのCRDのリソースがユーザから投入されたら、そのスペックにしたがってアプリケーションをセットアップする（主に Service や Deployment, Pod など、Kubernetesリソースを作成することになります）
* アプリケーション特有の定常的な操作を行う（バックアップなど）
* ノード障害などのイベントが発生したら、それに応じたアプリケーション特有の操作を行う（リストアなど）

本質的には、インフラエンジニアが普段から行っている自動化の営みと、大きな違いはないのですが、ポイントは

* アプリケーションの構築に Kubernetes を利用できること
* ユーザに Kubernetes 経由の統一的なインターフェースで提供できること

というところだと思います。

今回実装した Operator はあくまで PoC であり、実用的なものではありません。Kubernetesとのやり取りにおいては Kubernetes API をポーリングしているので効率が悪いですし、アプリの操作についてもバックアップのタイミングによってはデータベースが巻き戻ってしまうなどの問題があります。Kubernetes との対話には細かい制御が可能なAPIが揃っている Go のクライアントライブラリを用いるべきでしょうし、アプリ側も、PostgreSQLのようなDBの場合だとそもそもレプリケーション機能を備えたものを前提にするなど、アーキテクチャレベルで見直しが必要になるでしょう。そうだとしても、一通り PoC を実装してみるのは問題の理解のために間違いなく有用です。

なお、Operatorが備えるべき性質については、 CoreOS社による解説 [Introducing Operators: Putting Operational Knowledge into Software](https://coreos.com/blog/introducing-operators.html) が参考になります。

### 今後の展望

Operator は主に Kubernetes 内のリソースを操作しますが、これに限定されるものではありません。特にクラウド上ではあらゆるものをAPI経由で操作できるので、それらを利用しない手はありません。今後は CustomResourceDefinition を通じて Kubernetes 内外を問わず、あらゆるコンピューティングリソースを Kubernetes リソースとして見せるようなソフトウェアやサービスが登場することが期待されます。

## まとめ

* Kubernetes Operator という新しいソフトウェアのコンセプトがあるよ
* PoCレベルならシェルスクリプトで比較的簡単に実装できるよ
* 実用レベルに持っていこうとすると大変だね

記事は以上になります。
みなさんが Kubernetes Operator を作成するときの参考になれば幸いです。

## 参考文献

* [Introducing Operators: Putting Operational Knowledge into Software (coreos.com)](https://coreos.com/blog/introducing-operators.html)
* [Operators (coreos.com) ](https://coreos.com/operators/)
* [Extend the Kubernetes API with CustomResourceDefinitions (Kubernetes.io) ](https://kubernetes.io/docs/tasks/access-kubernetes-api/extend-api-custom-resource-definitions/)