> Source: https://www.ymotongpoo.com/works/adopting-erlang-ja/production/kubernetes/


Kubernetesはコンテナオーケストレーションシステムです。ノードをまたいで実行し続ける必要がある多数の異なるサービスを持つバックエンドでは、相互の通信やスケーリングが必要になり、オーケストレーションシステムが欠かせなくなります。コンテナの人気の高まりとともに、オーケストレーションにKubernetesを使う流れも広がってきました。

コンテナとKubernetesを使うことで、システム内のあらゆる言語やランタイムに共通のデプロイ、ディスカバリ、スケーリング、監視の手法を持てます。本章ではKubernetesの中心的なコンポーネントを取り上げ、前の章までで構築した`service_discovery`サービスのデプロイと管理にどう使うかを見ていきます。

## Kubernetesを動かす

Kubernetesのデプロイを開発・テストするには、テスト用にローカルでKubernetesを動かす必要があります。以降の節は、ローカルのテスト用Kubernetesとして[kind](https://kind.sigs.k8s.io)を使って書かれていますが、ローカルまたはクラウドのどちらの選択肢でも動作するはずです。本書ではテスト用Kubernetesクラスタの管理に[ctlptl](https://github.com/tilt-dev/ctlptl)を使います。ctlptlは複数のバックエンドに対応しているため、ここで使っているものを好みの環境に簡単に置き換えられます。継続的インテグレーションと本番環境へのデプロイの手順ではGoogle Kubernetes Engineを使います。ただし、内容の大部分は次の2節に挙げるどちらの選択肢でも問題なく動くはずです。

### ローカル環境

- [kind](https://kind.sigs.k8s.io/): Kubernetes in Docker。[Kubernetes SIG](https://github.com/kubernetes-sigs)が支援するプロジェクトで、元々はKubernetes自体のテスト用にDocker内でKubernetesを動かすためのものでした。
- [microk8s](https://microk8s.io/): Ubuntuの開発元であるCanonicalが提供するものですが、多くの[Linuxディストリビューション](https://snapcraft.io/docs/installing-snapd)、[Windows](https://multipass.run/#install)、[MacOS](https://multipass.run/#install)でも動きます。microk8sはインストールも利用開始も簡単で、minikubeのようにVM内で動くのではなくローカルで直接動くため、リソースの消費も少なめです。
- [minikube](https://github.com/kubernetes/minikube): 最も歴史が古く、最も柔軟な選択肢で、現在ではKubernetesプロジェクトの公式な一部にもなっています。`minikube`は様々なハイパーバイザーに対応しており、新しい仮想マシンを作らずに動かすオプションもありますが、それでもLinux VM内で動かすことが推奨されています。
- [k3s](https://k3s.io/)と[k3d](https://github.com/rancher/k3d): k3sは[Rancher Labs](https://rancher.com/)による軽量なKubernetesです。レガシーな機能やデフォルトでない機能を取り除いてサイズを削減し、etcdをSQLite3に置き換えているため、CIやローカルでのテストに適した選択肢になっています。k3dはDocker内でk3sを動かすためのヘルパーです。
- [Docker for Mac Kubernetes](https://docs.docker.com/docker-for-mac/#kubernetes): MacOSで動かす場合に最も手軽に始められます。
- [Docker for Windows](https://docs.docker.com/desktop/kubernetes/): Windowsで動かす場合に最も手軽に始められます。

`microk8s`を除くこれらすべては`ctlptl`でサポートされています。

### 本番環境

大手のクラウドプロバイダーはいずれもマネージドKubernetesクラスタを提供しています。

- [Google Kubernetes Engine](https://cloud.google.com/kubernetes-engine/): 本章では`kind`を使わない場合にGoogle Cloudを使いました。[新規ユーザー登録時に300ドル分のクレジット](https://cloud.google.com/free/)が得られるためです。
- [Digital Ocean Kubernetes](https://www.digitalocean.com/products/kubernetes/)
- [AWS Elastic Container Service for Kubernetes](https://aws.amazon.com/eks/)
- [Azure Kubernetes Service](https://azure.microsoft.com/en-us/services/kubernetes-service/)

クラウドやオンプレミスへのデプロイには、他にも[多くの選択肢](https://kubernetes.io/docs/setup/pick-right-solution/#table-of-solutions)があります。既存の企業であれば現在サービスをどこでホストしているかなど、選択には様々な要因が絡んできます。幸い、Kubernetesを使えばデプロイが特定のプロバイダーにロックインされることはありません。

## デプロイ

Kubernetesでは、コンテナは`Pod`の一部として扱われます。各`Pod`は1つ以上のコンテナと、0個以上の`init-container`を持ちます。`init-container`は他のコンテナが起動する前に一度だけ実行され、完了まで走りきります。アプリケーションに対しては`Deployment`というより上位の抽象を使うため、スケーリングやデプロイのために個々の`Pod`を手動で作る必要はありません。`Deployment`はアプリケーションの`Pod`を宣言的に作成・更新する仕組みです。

各Kubernetesリソースは`yaml`ファイルで定義され、`apiVersion`、リソースの`kind`、名前などの`metadata`、そしてリソースの仕様を含みます。

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: service-discovery
spec:
[...]
```

ここで扱う`Deployment`のspecの項目は`selector`、`replicas`、`template`です。`selector`は、どの`Pod`が`Deployment`に属するかを指定するものです。`matchLabels`は、同じ`Namespace`内でラベル`app`の値が`service-discovery`である`Pod`が`Deployment`に含まれることを意味します。`replicas`は、いくつの`Pod`インスタンスを動かすべきかを宣言します。

```yaml
spec:
  selector:
    matchLabels:
      app: service-discovery
  replicas: 1
  template:
[...]
```

`template`セクションは`Pod`テンプレートであり、`Deployment`が実行する`Pod`の仕様を定義します。

```yaml
template:
  metadata:
    labels:
      app: service-discovery
  spec:
    shareProcessNamespace: true
    containers:
    - name: service-discovery
      image: service_discovery
      ports:
      - containerPort: 8053
        protocol: UDP
        name: dns
      - containerPort: 3000
        protocol: TCP
        name: http
      - containerPort: 8081
        protocol: TCP
        name: grpc
```

まず、`Pod`のmetadataは`Deployment`specの`selector`とマッチするようラベルを設定します。この後の「Kustomizeによるデプロイの簡略化」の節で、この`labels`の二重定義が不要になる方法を見ていきます。次に、`Pod`specにはコンテナのリストがあります。この例では、DNS・HTTP・gRPC経由でアクセスできるようポートを公開するコンテナが1つあります。

{{% message "alert" %}}
[Dockerの章](../docker/)で説明したゾンビプロセス問題(終了したのに親プロセスが`wait()`を呼んでいないためPIDが残り続けるプロセス)は、Kubernetesでコンテナを動かす場合にも当てはまります。これを防ぐ方法の一つが、`service_discovery`プロジェクトでも採用されている、Pod内のコンテナ間でプロセス名前空間を共有する方法です。

これはPod specで`shareProcessNamespace: true`を指定することで実現します。この設定を使うと、コンテナ内で起動したプロセスはPID 1にはなりません。代わりに、Kubernetesの[pauseコンテナ](https://github.com/kubernetes/kubernetes/tree/master/build/pause)がPID 1になります。pauseコンテナは常にPod内の親コンテナとなりますが、この設定によりPod内の全プロセスに対するPID 1となるため、Pod内のどのコンテナから生じたゾンビプロセスも刈り取れるようになります。

詳しくは[Kubernetesのドキュメントにあるプロセス名前空間の共有](https://kubernetes.io/docs/tasks/configure-pod-container/share-process-namespace/)を参照してください。
{{% /message %}}

### コンテナのリソース

`Pod`spec内の各コンテナspecには、メモリとCPUのリソースリクエストとリミットを含められます。リクエストはPodのスケジューリングに使われます。スケジューリングでは、要求されたCPUとメモリが利用可能な(そのノード上の他のPodにまだ要求されていない)ノードを選びます。

```yaml
resources:
  requests:
    memory: "250Mi"
    cpu: "500m"
  limits:
    memory: "1Gi"
    cpu: "2000m"
```

さらに、CPUのリクエストはcgroupのプロパティ`cpu.shares`に変換されます。sharesは相対的な重みであり、そのcgroupがどれだけのCPUの取り分を得るかの計算に使われます。デフォルトは`1024`ですが、これは相対的な重みなので、他のプロセスの値がわからなければ単独では何の意味も持ちません。デフォルトのshare`1024`を持つプロセスが2つあれば、それぞれがCPU時間の半分を得ます。3つあれば、それぞれが3分の1を得ます。

リミットはcgroupのプロパティ`cpu.quota`に変換されます。これはLinuxカーネルのスケジューラがプロセスに対して強制するCPU帯域幅の制御です。帯域幅制御にはピリオド(マイクロ秒単位のある数値)と、そのピリオド内でプロセスが使える最大マイクロ秒数を表すクォータがあります。ピリオドは常に`100000`マイクロ秒なので、上の例のようにCPUリミットを`2000m`に設定した場合、cgroupのクォータは`200_000`に設定され、`100_000`マイクロ秒ごとに2CPU分を使えることを意味します。

ピリオド内でリミットを超過すると、カーネルは次のピリオドになるまでそのプロセスを再度実行させないことでスロットリングします。これはパフォーマンスに大きな影響を与えることがあります。割り当てられたリミット内に収まるために気をつけるべきErlangプログラムの主な要素がいくつかあります。まず、ErlangのVMはマルチスレッドで動作します。VMは、内部プロセスの実行をスケジューリングするスケジューラ用のスレッドを作成します。1CPUコアに制限されている状況で2スレッドを動かすと、割り当てられたクォータを簡単に超えてしまいます。これらのスケジューラは、スケジュールすべきプロセスがなくなったからといって、単純に時間をカーネルに返上するわけでもありません。そうではなく、何かがすぐに再び実行可能になった場合のレイテンシを減らすため、Erlangのスケジューラはビジーウェイトループに入ります。

ビジーウェイトとは、Erlangのスケジューラがスリープする前に、新たな仕事が来るのを待ってタイトなループに入ることです。このタイトなループは実際の仕事を待つだけでCPUを消費するため、カーネルのスケジューラにスロットリングされやすくなり、パフォーマンスがかえって悪化することがあります。そして実際に仕事が発生したときには、ErlangのVMがまったくCPUスライスを得られないピリオドに当たってしまうかもしれません。VMに割り当てられたCPU数より多くのスケジューラを動かしている場合は、さらに悪化します。`2000m`というCPUリミットは2CPUコア分だと考えがちですが、実際にはプロセスを2コアに制限するわけではありません。8個のスケジューラを使っていて、ノード上にも同じ数のコアがあれば、その8個はすべてのコアに分散して並列に動きます。しかしクォータは依然として`200000`のままなので、8個のスケジューラが8コアで時間を使う場合の方が超過しやすくなります。ビジーウェイトがなかったとしても、スケジューラ自体が仕事をこなす必要があり、CPU使用量の制約内に収めようとするときには余計なオーバーヘッドになりえます。

スケジューラのビジーウェイトを無効にするには、`vm.args.src`でVM引数`+sbwt`を次のように設定します。

```shell
+sbwt ${SBWT}
```

{{% message %}}
2020年にリリースされたOTP-23では、ErlangのVMは「コンテナを認識」するようになり、コンテナに割り当てられたリソースに基づいて適切な数のアクティブスケジューラを自動的に設定するようになりました。OTP-23より前は、アクティブなスケジューラ数をコンテナのCPUリミットに合わせて設定するために`+S`引数が必要でした。

さらに、`+sbwt`はOTP-23以降デフォルトで`very_short`になっています。これは改善ではありますが、Kubernetesのような環境で動かす場合は、それでも値を`none`に設定したいことが多いでしょう。ただし、いつものことですが、自分のワークロードに最適な値はベンチマークして確かめてください。
{{% /message %}}

### コンテナの環境変数とConfigMap

Releasesの章で見たように、実行時の設定は`vm.args.src`と`sys.config.src`内での環境変数の置換によって行われます。そのため、これらの変数をコンテナの環境に挿入する必要があります。Kubernetes Pod内の各コンテナは、環境変数の集合を宣言する`env`フィールドを持てます。最も単純なのは、`name`と`value`を明示的に指定する場合です。

```yaml
env:
- name: LOGGER_LEVEL
  value: error
- name: SBWT
  value: none
```

この設定により、値が`error`の環境変数`LOGGER_LEVEL`が作られます。

実行中のコンテナの状態に基づいて設定しなければならない環境変数もあります。その一例が、PodのIPに基づいて`NODE_IP`変数を設定する場合です。

```yaml
env:
- name: NODE_IP
  valueFrom:
    fieldRef:
      fieldPath: status.podIP
```

`fieldRef`の下にある`status.podIP`の宣言は、Podが作成されたときの現在のPodのIPを返します。

`LOGGER_LEVEL`のようなユーザー定義の環境変数は、設定専用のKubernetesリソースである`ConfigMap`でより適切に管理できます。`ConfigMap`はキーと値のペアを含み、ファイル、ファイルを含むディレクトリ、リテラルな値から作成できます。ここでは、`LOGGER_LEVEL`を`error`に設定するためにリテラルな値を使います。

```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: configmap
data:
  LOGGER_LEVEL: error
```

すると`Deployment`リソース側では、`LOGGER_LEVEL`の値を`ConfigMap`への参照にできます。

```yaml
env:
- name: LOGGER_LEVEL
  valueFrom:
    configMapKeyRef:
      name: configmap
      key: LOGGER_LEVEL
```

`ConfigMap`で定義したすべての変数をコンテナの環境変数として取り込むショートカットは`envFrom`で行えます。

```yaml
envFrom:
- configMapRef:
    name: configmap
```

これで、`ConfigMap`で定義されたすべての変数が、個別に指定することなくコンテナに追加されます。`ConfigMap`の定義についての詳細は、この後の「Kustomizeによるデプロイの簡略化」の節を参照してください。

{{% message %}}
`ConfigMap`の値を更新しても、その`ConfigMap`を参照している`Deployment`のコンテナが新しい環境変数で再起動する**ことはありません**。設定変更時にコンテナを更新する方法は、必要な変更を加えた新しい名前の`ConfigMap`を新規に作成し、`Deployment`が`configMapRef`で参照する`ConfigMap`の名前をそちらに変更することです。古い`ConfigMap`はどこからも参照されなくなるため、いずれKubernetesによってガベージコレクトされ、`Deployment`は新しい設定でPodを再作成します。
{{% /message %}}

### Initコンテナ

`service_discovery`のリリースは、状態の保存にPostgresデータベースを使っています。コンテナを実行する前にデータベースが正しくセットアップされていることを保証するため、[Initコンテナ](https://kubernetes.io/docs/concepts/workloads/pods/init-containers/)を使います。各`Initコンテナ`は、Pod内のメインコンテナが実行される前に完了まで実行されるコンテナです。マイグレーションの実行に使うデータベースマイグレーションツール`flyway`は、メインコンテナの実行を許可する前に、データベースが最新の状態であることを検証するために使われます。

```yaml
volumes:
- name: migrations
  emptyDir:
    medium: Memory

initContainers:
- name: flyway
  image: flyway/flyway:9.22
  name: flyway-validate
  args:
  - "-url=jdbc:postgresql://$(POSTGRES_SERVICE):5432/$(POSTGRES_DB)"
  - "-user=$(POSTGRES_USER)"
  - "-password=$(POSTGRES_PASSWORD)"
  - "-connectRetries=60"
  - "-skipCheckForUpdate"
  - validate
  volumeMounts:
  - name: migrations
    mountPath: /flyway/sql
    env:
    - name: POSTGRES_SERVICE
      value: POSTGRES_SERVICE
    - name: POSTGRES_DB
      value: POSTGRES_DB
    - name: POSTGRES_USER
      value: POSTGRES_USER
    - name: POSTGRES_PASSWORD
      value: POSTGRES_PASSWORD

- name: service-discovery-sql
  image: service_discovery
  command: ["/bin/sh"]
  args: ["-c", "cp /opt/service_discovery/sql/* /flyway/sql"]
  volumeMounts:
    - name: migrations
      mountPath: /flyway/sql
```

ボリューム`migrations`はメモリ上にのみ存在し、リリースイメージから現在のSQLマイグレーションファイルを、`flyway`コンテナが検証に使う共有ディレクトリへコピーするために使われます。`medium`が`memory`である`emptyDir`型のボリュームを使うのは、このボリュームを何かの永続化に使うのではなく、2つの`Initコンテナ`間で共有するためだけに使うからです。`medium: Memory`を指定しなければ`emptyDir`はホストのファイルシステム上に作られますが、それでも新しい`Pod`ごとに最初は空であり、`Pod`が削除されると一緒に削除されます。

### readiness・liveness・startupプローブ

`Pod`内の各コンテナは、`readiness`、`liveness`、`startup`の各プローブを定義できます。プローブは、コンテナ内で実行するコマンドとして、指定したポートとパスへのHTTP`GET`リクエストとして、あるいは特定のポートへのTCP接続を試みる形として定義します。

#### LivenessProbe

Kubernetesは失敗したコンテナを再起動しますが、デッドロックのように、プロセスが技術的には生きているのに応答しなくなる場合があります。`livenessProbe`はこうしたケースに使えるもので、応答に失敗すると再起動が促されます。ただし、時間内に応答しないというのは、そのプロセスが有益な作業でビジーになっているだけという可能性もあり、そうしたビジーなプロセスを再起動すると`Deployment`に余計な混乱を招くだけです。その作業はどこかのPodで結局こなさなければならず、コンテナが次々に再起動され続けるカスケード障害につながりかねないからです。このため、`livenessProbe`を含める場合は極力単純にし、タイムアウトと閾値を慎重に選ぶ必要があります。`livenessProbe`を完全に省略し、ノードのクラッシュやメトリクスベースのアラート、オートスケーリングに頼る方が安全なことのほうが多いです。

この目的のために`livenessProbe`を使わないということは、コンテナはkillされないため引き続き利用可能であり、チェックが失敗した理由を調査できるということです。これは、完全にフリーズしていなければシェルをアタッチするか、クラッシュダンプを強制的に書き出させることを意味するかもしれません。非常にリソースが制約された環境では、手動での対応やオートスケーラーが負荷低下後にスケールダウンするのを待つのではなく、デッドロックしたコンテナとそのPodをすぐに片付けるために`livenessProbe`を使いたい場合もあるでしょう。

#### ReadinessProbe

`readinessProbe`は完全に起動するまで`Pod`をサービスの対象外にしておくためのものだ、というのはよくある誤解です。実際にはそうではなく、Kubernetes 1.16でこれを扱うために`startupProbe`という別のプローブが追加されました。`startupProbe`については次の節で詳しく説明します。`readinessProbe`はグレースフルシャットダウンのためにも不要です。シャットダウンが始まると、Kubernetesは`Pod`をサービスから取り除きます。

`readinessProbe`の実際の主な用途は、アプリケーションがリクエストを処理できない間に定期的に何らかの作業をこなす必要がある場合や、一定期間リクエストを受けないことが正当化されるようなメンテナンスや点検を行う必要がある場合です。

Erlangでは、`readinessProbe`の手動トグルを、`persistent_term`や、リモートコンソールに接続してオンオフを切り替えられるような値の共有手段を使って実装できます。Horizontal Pod Auto-Scalerを使っていれば、他のPodが追加のトラフィックを受けるようになった時点で`Deployment`がスケールアップされます。

デッドロックしたノードを調査できるようにするために`livenessProbe`を含めないのであれば、`readinessProbe`も重要になります。デッドロックしたノードはサービスから取り除かれるまで`readinessProbe`に失敗し続けるため、ユーザーがタイムアウトを受けることはなくなりますが、`Pod`自体は調査のために利用可能なまま残ります。

`Pod`の設定では、デプロイ中であれ`livenessProbe`の失敗であれスケールダウンであれ、グレースフルシャットダウンのために特別な設定は必要ありません。Kubernetesがシャットダウンを知らせる`SIGTERM`を送ると、Erlangは`init:stop()`を呼び出します。[Releases](../releases/)で説明したように、各アプリケーションは起動時と逆順で停止され、各アプリケーションのsupervisorツリーも子を逆順で終了させます。とはいえこれは、アプリケーションのシャットダウンを適切かつ丁寧に処理しなくてよいという意味ではなく、あくまで`Pod`の設定自体にはリリースの停止を遅らせるための特別な設定が不要だという意味です。グレースフルシャットダウンを行うのは、それぞれのアプリケーション自身の責務です。例えば、HTTPサーバーを起動するアプリケーションは、`stop`コールバックが返る前に、開いているリクエストの処理が終わるのを待つべきです。

ただし、シャットダウンにかけられる時間には限りがあります。デフォルトでは(`PodSpec`のオプション`terminationGracePeriodSeconds`で設定可能)、Kubernetesが強制的にプロセスを終了させる`SIGKILL`シグナルを送るまで、シャットダウンは最大30秒待たれます。

#### StartupProbe

アプリケーションの起動中にクライアントが「接続拒否」エラーを絶対に受け取らないようにしたい場合は、`readinessProbe`の代わりに`startupProbe`を使うのが望ましいです。`readinessProbe`と異なり、`startupProbe`はパスするまでしかチェックされず、`Pod`が生きている間ずっと定期的にチェックされるわけではありません。起動に何秒、あるいは何分もかからず、クライアントが単にリトライすればよいだけで悪影響を受けないのであれば、必要ありません。`service_discovery`プロジェクトでは、これを実現するために`startupProbe`を使っています。

```yaml
startupProbe:
  httpGet:
    path: /ready
    port: http
  initialDelaySeconds: 3
  periodSeconds: 2
```

リリースの起動順序により、Elliサーバーが起動する前にデータベースへの接続とDNSが利用可能になっていることが保証されているため、このプローブはHTTPサーバーが起動しているかどうかだけをチェックすればよく、次のように書けます。

```erlang
handle('GET', [<<"ready">>], _Req) ->
    {ok, [], <<>>};
```

この関数は`/ready`への`GET`リクエストにマッチし、即座に`200`レスポンスを返します。サービスがHTTPリクエストを受け取って応答できること以外に、機能面の追加チェックはありません。

起動に何秒も、あるいは何分もかかる場合、とりわけ`livenessProbe`が定義されている場合には、`startupProbe`はさらに重要になります。`service_discovery`の場合であれば、一部またはすべてのDNSレコードをローカルキャッシュに読み込むという、時間のかかる機能が考えられます。この読み込みがリソースを大量に消費し、読み込み完了までリクエストがキャッシュではなくデータベースに向かうという以上の悪影響がある場合には、読み込みが完了するまでパスしない`startupProbe`を定義するほうが有益かもしれません。`livenessProbe`も定義されていると、`livenessProbe`が許容される`failureThreshold`を超えて失敗し続けて`Pod`がkillされるため、起動が永遠に完了しないという事態になりかねません。これは起動を試みるすべての`Pod`で起こることになり、`Deployment`は決して成功しないか、`livenessProbe`がたまに閾値を超えずに済む場合でもかなり時間がかかるようになります。

### ローリングデプロイ

Kubernetesの`Deployment`リソースには、イメージの新バージョンがデプロイされたときの挙動を設定できる[ストラテジー](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy)があります。`service_discovery`の`Deployment`はローリングアップデートのストラテジーを使っています。もう一つの選択肢は`Recreate`で、こちらはKubernetesがまず`Deployment`内のすべての`Pod`を終了させてから新しい`Pod`を起動するというものです。`RollingUpdate`ストラテジーでは、Kubernetesの`Deployment`コントローラーは新しい`Pod`を立ち上げてから古い`Pod`を終了させます。コントローラーに対して、望ましい数を超えていくつの`Pod`を許容するか(`maxSurge`)、デプロイ中にいくつの`Pod`が利用不能になってよいか(`maxUnavailable`)を伝える設定変数が2つあります。

```yaml
strategy:
  type: RollingUpdate
  rollingUpdate:
    maxUnavailable: 0
    maxSurge: 25%
```

`service_discovery`は`maxUnavailable`を`0`、`maxSurge`を`25%`に設定しています。これは、`Deployment`が4つのレプリカを持っている場合、まず新しい`Pod`が1つ起動され、その`Pod`がreadyになった時点で古い`Pod`の1つが終了を始め、この流れが各`Pod`について続くことを意味します。プローブが定義されていれば、readyと判断される前にまず`startupProbe`、続いて`readinessProbe`をパスする必要があります。

## Service

`Service`は、アプリケーションをネットワーク上でどう公開するかを定義するリソースです。各`Pod`は自身のIPアドレスを持ち、ポートを公開できます。`Service`は単一のIPアドレス(`Service`の`ClusterIP`)を提供し、そのIP上のポートを、アプリケーションの各`Pod`が公開するポートにマッピングできます。次のリソース定義では、前節で作成した`Pod`にマッチするセレクタ`app: service-discovery`を持つ、`service-discovery`という名前の`Service`が作られます。

```yaml
kind: Service
apiVersion: v1
metadata:
  name: service-discovery
spec:
  selector:
    app: service-discovery
  ports:
  - name: dns
    protocol: UDP
    port: 8053
    targetPort: dns
  - name: http
    protocol: TCP
    port: 3000
    targetPort: http
  - name: grpc
    protocol: TCP
    port: 8081
    targetPort: grpc
```

`Deployment`のコンテナは3つのポートを公開し、それぞれに`dns`、`http`、`grpc`という名前を付けていました。`Service`は、公開する各ポートの`targetPort`としてこれらの名前を使います。このリソースをKubernetesクラスタに適用すると、これら3つのポートをリッスンするIPができ、実行中のいずれかの`Pod`にプロキシされます。

`Pod`のIPを一つずつDNSレコードに追加する代わりにプロキシ(`kube-proxy`)を使って`Pod`にトラフィックをルーティングしているのは、ある`Service`に対して実際に実行されている`Pod`が変化する頻度によるものです。もしDNSを使うのであれば低いか0のTTL(time to live)が必要になり、クライアントがそのTTL値を完全に尊重することに依存してしまいます。すべてのリクエストをプロキシ経由でルーティングするようにしておけば、プロキシのデータさえ更新されていれば正しい`Pod`の集合にルーティングされます。

[CoreDNS](https://coredns.io/)のようなKubernetesに対応したDNSサービスは、新しい`Service`を監視し、各サービスに対して`[service-name].[namespace]`というDNSレコードを作成します。`service-discovery`の`Service`にある3つのポートのように名前付きポートを使う場合には、`_[name]._[protocol].[service-name].[namespace]`という形式のSRVレコードも作られます。`_http._tcp.service-discovery.default`のようなSRVレコードをDNSサービスに問い合わせると、`Service`のDNS名`service-discovery.default`とポート3000が返ってきます。

後述するクラスタリングの節では、[Erlang Port Mapper Daemon (epmd)](http://erlang.org/doc/man/epmd.html)を使わずに分散Erlangでノード同士を接続するために、`Headless Service`(`ClusterIP`を`None`に設定した`Service`)リソースと名前付きポートをどう使うかを見ていきます。

## Kustomizeによるデプロイの簡略化

ここまでで、アプリケーションに使う2つの主要なKubernetesリソースについて説明しました。ここからは、実際にこれらのリソースをどう書いてデプロイするかを見ていきます。ここまでの節で各リソースについて示したYAMLをそのままデプロイに使わない理由は、それが静的であり、時に(`Deployment`で同じラベルの集合を2回定義しなければならないように)冗長で、環境によって異なる設定が必要な場合にリソースを手動で書き換えたり複製したりする必要があるからです。コンテナで使うイメージの更新や、環境によって異なるリソースの名前・名前空間の使用、環境によって異なる`ConfigMap`の値の使用といった変更を、簡単かつわかりやすく行える仕組みが必要です。

Kubernetesリソースを扱うためのオープンソースの選択肢はいくつかあります。最も人気があるのは[Helm](https://helm.sh/)と[Kustomize](https://kustomize.io/)です。どちらもKubernetesプロジェクトの一部ですが、問題への取り組み方は大きく異なります。HelmはYAMLの記述とレンダリングに[Goのテンプレート](https://golang.org/pkg/text/template/)を使います。筆者を含め、YAMLをテンプレート化するのは過度に複雑でエラーを招きやすく、単純に煩わしいと感じる人もいます。幸い、もう一方の選択肢である`Kustomize`はテンプレートに依存していません。

{{% message %}}
プロジェクト自体のデプロイにHelmを使わない場合でも、依存関係のデプロイには依然として役立つことがあります。[Helm Chart](https://github.com/helm/charts)は数多く公開されており、単なる内部利用ではなく外部にプロジェクトを提供したい場合には、Helmを使うのが妥当な選択になることが多いでしょう。

テンプレート化以外にもHelmにはいくつか問題がありましたが、ここでは深入りしません。ただ、それらは最新のメジャーリリースである[Helm v3](https://v3.helm.sh/)で解決が進められており、Helm v3は2019年11月に最初の安定版がリリースされたということだけ述べておきます。ですので、Helmの最新版も自分の目で確かめてみることをおすすめします。
{{% /message %}}

[Kustomize](https://kustomize.io/)は、テンプレートを使わずにKubernetesのYAMLリソースをカスタマイズできる`kubectl`組み込みのツールです(v1.14.0以降)。ここではKustomizeを使って、まず開発環境`dev`向けの設定を作り、次の「ローカル開発でのTilt活用」の節でこれを使ってローカルで`service_discovery`を動かします。

構成は、様々な環境向けのオーバーレイを持つベース設定です。オーバーレイでは、ベースレイヤーのリソースにリソースを追加したり変更を加えたりできます。ベース設定と2つのオーバーレイのディレクトリ構成は次のとおりです。

```shell
$ tree deployment
deployment
├── base
│   ├── default.env
│   ├── deployment.yaml
│   ├── init_validation.yaml
│   ├── kustomization.yaml
│   ├── namespace.yaml
│   └── service.yaml
├── overlays
│   ├── dev
│   │   ├── dev.env
│   │   └── kustomization.yaml
│   └── stage
│       ├── kustomization.yaml
│       └── stage.env
└── postgres
    ├── flyway-job.yaml
    ├── kustomization.yaml
    ├── pgdata-persistentvolumeclaim.yaml
    ├── postgres-deployment.yaml
    └── postgres-service.yaml
```

ベースの`kustomization.yaml`には、`service_discovery`プロジェクトの主要なリソースである`Namespace`、`Deployment`、`Service`が含まれます。

```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: service-discovery
commonLabels:
  app: service-discovery
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
```

`commonLabels`の下にあるラベルは各リソースに追加され、前節での`Deployment`設定を簡略化し、`labels`の項目と`selector`の両方を削除できるようにします。これにより、`service_discovery`の`deployment/base/deployment.yaml`にある`Deployment`リソースは次のようになります。

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: service-discovery
spec:
  replicas: 1
  template:
    spec:
      containers:
      - name: service-discovery
        image: service_discovery
[...]
```

Kustomizeは、リソースをレンダリングする際に`metadata`の下にラベルを挿入し、同時に`spec`の`selector`フィールド`matchLabels`の下にも同じラベルを自動的に挿入します。Kustomizeが何を生成するかを確認するには、ベースに対して`kubectl kustomize`を実行すると、リソースが標準出力に出力されます。

```yaml
$ kubectl kustomize deployment/base
[...]
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: service-discovery
  name: service-discovery
  namespace: service-discovery
spec:
  replicas: 1
  selector:
    matchLabels:
      app: service-discovery
  template:
    metadata:
      labels:
        app: service-discovery
[...]
```

`Deployment`の作成を改善するもう一つの機能が`ConfigMap`の生成です。環境変数とConfigMapの前節では、`ConfigMap`のデータから環境変数を取り込む`Deployment`を最後に見ました。

```yaml
envFrom:
- configMapRef:
    name: configmap
```

Kustomizeの`configMapGenerator`を使うと、`VarName=VarValue`という形式の行を持つ任意のファイルから`ConfigMap`を生成すると宣言できます。

```yaml
configMapGenerator:
- name: configmap
  envs:
  - dev.env
```

`dev.env`の内容は次のとおりです。

```shell
LOGGER_LEVEL=debug
```

`kubectl kustomize deployment/overlays/dev`の出力で見られる、結果として生成されるConfigMapは次のとおりです。

```yaml
apiVersion: v1
data:
  LOGGER_LEVEL: debug
kind: ConfigMap
metadata:
  labels:
    overlay: dev
  name: configmap-dev-2hfc445577
  namespace: service-discovery-dev
```

名前が単なる`configmap`ではなく`configmap-dev-2hfc445577`になっている点に注目してください。Kustomizeは、内容が変わると異なる名前で新しい`ConfigMap`を作成し、それへの参照をすべて更新します。`Deployment`spec内の`ConfigMap`への参照を更新することで、設定が変わったときに`Pod`が確実に再起動されるようになります。単に`ConfigMap`を変更するだけでは、実行中の`Pod`は更新されません。そのため、Kustomizeの出力では`Deployment`は次のようになります。

```yaml
- envFrom:
  - configMapRef:
      name: configmap-dev-2hfc445577
```

`Secrets`についても同様です。

Kustomizeによって生成されたリソースを一度に適用するには、`kubectl apply`に`-k`オプションを渡します。

```shell
$ kubectl apply -k deployment/overlays/dev
```

このコマンドは`dev`オーバーレイに基づいてリソースを生成し、Kubernetesクラスタに適用します。

## データベースマイグレーション

### Job

Kubernetesの`Job`は1つ以上の`Pod`を作成し、指定した数だけ正常に完了するまでそれらを実行します。`service_discovery`のデータベースマイグレーションの場合、`Job`は[Flyway](https://flywaydb.org/)を実行するコンテナが1つの`Pod`であり、デプロイ対象の`service_discovery`イメージから`SQL`ファイルをコピーする`initContainer`と`Volume`を共有しています。`initContainers`であるということは、Podのメインコンテナが起動する前に、すべてが完了まで実行され成功する(終了コード0で終わる)必要があるということです。そのため、`Job`の`Pod`のメインコンテナが`flyway migrate`を実行できるようになる前に、マイグレーションを扱うコンテナ(実際には`Deployment`の実行に使うものと同じ、`service_discovery`のフルリリースを含むイメージです)が、共有`Volume`のディレクトリ`/flyway/sql`の下にマイグレーションのコピーを済ませている必要があります。

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  labels:
    service: flyway
  name: flyway
spec:
  ttlSecondsAfterFinished: 0
  template:
    metadata:
      labels:
        service: flyway
    spec:
      restartPolicy: OnFailure
      volumes:
      - name: migrations
        emptyDir:
          medium: Memory

      containers:
      - args:
        - "-url=jdbc:postgresql://$(POSTGRES_SERVICE):5432/$(POSTGRES_DB)"
        - -user=$(POSTGRES_USER)
        - -password=$(POSTGRES_PASSWORD)
        - -connectRetries=60
        - -skipCheckForUpdate
        - migrate
        image: flyway/flyway:9.22
        name: flyway
        volumeMounts:
        - name: migrations
          mountPath: /flyway/sql
        env:
        - name: POSTGRES_SERVICE
          value: POSTGRES_SERVICE
        - name: POSTGRES_DB
          value: POSTGRES_DB
        - name: POSTGRES_USER
          value: POSTGRES_USER
        - name: POSTGRES_PASSWORD
          value: POSTGRES_PASSWORD

      initContainers:
      - name: service-discovery-sql
        image: service_discovery
        command: ["/bin/sh"]
        args: ["-c", "cp /opt/service_discovery/sql/* /flyway/sql"]
        volumeMounts:
          - name: migrations
            mountPath: /flyway/sql
```

`Job`をマイグレーションに使う際の問題点は、完了した`Job`のイメージを更新して再実行させるようなKubernetes YAMLリソースの適用ができないことです。異なる`service_discovery`イメージを使って同じ名前の`Job`を適用すると、エラーになります。この制限に対処する方法はいくつかあります。

一つの選択肢は、Kubernetes 1.16時点ではまだアルファ機能ですが、`Job`のspecで`ttlSecondsAfterFinished: 0`を設定することです。この設定により、`Job`は完了直後に削除の対象になります。そうすると、次のデプロイで新しいKubernetesリソースが適用される際に、前回のデプロイの`Job`を更新しようとするのではなく、新しい`Job`が作成されます。

`ttlSecondsAfterFinished`オプションはまだアルファ機能であり、手動での有効化が必要です。Google Cloudでは[アルファクラスタ](https://cloud.google.com/kubernetes-engine/docs/concepts/alpha-clusters)を作成することでこの機能を試せますが、こうしたクラスタは30日間しか存続できません。もう一つの方法は、マイグレーションを手動で(あるいはCIパイプラインで実行できるスクリプトで)実行し、デプロイの完了後に`Job`を削除することです。

`Job`をメインの`kubectl apply`とは別に実行する利点は、`Job`が失敗した場合にデプロイを止められることです。

### マイグレーションの検証

データベースのマイグレーションが正常に完了するまで`service_discovery`コンテナを起動させたくない場合は、`flyway`の`validate`コマンドを使えます。`flyway validate`を実行するコンテナは、チェック対象のデータベースがすべてのマイグレーションを実行済みであれば成功します。結果として得られるコンテナの構成は前節でマイグレーションを行ったときとほぼ同じですが、`migrate`の代わりにコマンドが`validate`になり、`flyway`コンテナとマイグレーションを共有ボリュームにコピーするコンテナの両方が、この場合は`initContainers`になります。

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: service-discovery
spec:
  replicas: 1
  template:
    spec:
      volumes:
      - name: migrations
        emptyDir: {}
      initContainers:
      - name: service-discovery-sql
        image: service_discovery
        volumeMounts:
          - name: migrations
            mountPath: /flyway/sql
        command: ["/bin/sh"]
        args: ["-c", "cp /opt/service_discovery/sql/* /flyway/sql"]

      - image: flyway/flyway:9.22
        name: flyway-validate
        args:
        - "-url=jdbc:postgresql://$(POSTGRES_SERVICE):5432/$(POSTGRES_DB)"
        - "-user=$(POSTGRES_USER)"
        - "-password=$(POSTGRES_PASSWORD)"
        - "-connectRetries=60"
        - "-skipCheckForUpdate"
        - validate
        volumeMounts:
        - name: migrations
          mountPath: /flyway/sql
        env:
        - name: POSTGRES_SERVICE
          value: POSTGRES_SERVICE
        - name: POSTGRES_DB
          value: POSTGRES_DB
        - name: POSTGRES_USER
          value: POSTGRES_USER
        - name: POSTGRES_PASSWORD
          value: POSTGRES_PASSWORD
```

各Initコンテナは順番どおりに完了まで実行されてから次が起動するため、この場合は順序がとても重要です。もし`/flyway/sql`へマイグレーションファイルをコピーするコンテナが`initContainers`のリストの先頭になく、Kubernetesが次のコンテナを起動する前にそのコンテナの正常な完了を要求するのであれば、`flyway`が実行される前にマイグレーションがボリュームにコピーされないことになります。

## ローカル開発でのTilt活用

[Tilt](https://tilt.dev/)は、開発用にローカルでDockerイメージとKubernetesのデプロイを行い、更新していくためのツールです。デフォルトでは、`kind`や`microk8s`などのローカルなKubernetesに対してのみビルドとデプロイを行い、開発環境を誤って本番に送り込んでしまわないよう配慮されています。

Tiltを始める最も簡単な方法は、[ctlptl](https://github.com/tilt-dev/ctlptl)を使ってDockerイメージレジストリも一緒に動く[kind](https://kind.sigs.k8s.io)クラスタを作成することです。これは、Kubernetesの各Podが使うイメージを保存するために使われます。`ctlptl`を使った`kind`の場合、クラスタ設定ファイルのサポートによってレジストリを簡単に有効化できます。

```yaml
apiVersion: ctlptl.dev/v1alpha1
kind: Registry
name: ctlptl-registry
port: 5005
---
apiVersion: ctlptl.dev/v1alpha1
kind: Cluster
product: kind
registry: ctlptl-registry
name: kind-adoptingerlang
```

クラスタを作成するには`ctlptl apply`を使います。

```shell
$ ctlptl apply -f cluster.yaml
```

Tiltはプロジェクトのルートにある`Tiltfile`というファイルをもとに動作します。[service_discovery](https://github.com/adoptingerlang/service_discovery)のルートにある`Tiltfile`を順に見ていくと、まず次から始まります。

```python
default_registry('127.0.0.1:32000')
```

`default_registry`は、Tiltがビルドしたdockerイメージをどこにpushするかを設定するもので、この例では`microk8s`で有効化されているレジストリが使われています。実際にはこの行は必須ではありません。有効なレジストリを持つ`microk8s`が使われていることをTiltが検知し、自動的にそのレジストリを使うよう自身を設定するからです。

次に、いくつかのDockerイメージのビルドが設定されます。まずは`service_discovery_sql`からです。

```python
custom_build(
    'service_discovery_sql',
    'docker buildx build -o type=docker --target dev_sql --tag $EXPECTED_REF .',
    ['apps/service_discovery_postgres/priv/migrations'],
    entrypoint="cp /app/sql/* /flyway/sql"
)
```

このイメージは、`Dockerfile`のターゲット`dev_sql`からビルドされます。

```dockerfile
FROM busybox as dev_sql

COPY apps/service_discovery_postgres/priv/migrations/ /app/sql/
```

このイメージにはSQLマイグレーションファイルだけが含まれており、`entrypoint`で`cp`を使えるように`busybox`が使われています。`custom_build`の3番目の引数`['apps/service_discovery_postgres/priv/migrations']`は、そのディレクトリ内のファイル(この場合はSQLマイグレーションファイル)が変わったときにイメージを再ビルドするようTiltに指示します。このイメージが`Tiltfile`の後半のkustomizeデプロイでどう使われるかは、この後見ていきます。

[custom_build](https://docs.tilt.dev/custom_build.html)という関数が使われている点に注目してください。用途に合えば、Tiltにはより単純にDockerイメージをビルドできる`docker_build`という関数もあります。`service_discovery`の場合は`Dockerfile`内に特定の`targets`があり、`buildx`の利用を確実にしたかったため`custom_build`を使っています。

次にビルドされるイメージは、`Deployment`が使うメインイメージである`service_discovery`です。

```python
custom_build(
    'service_discovery',
    'docker buildx build -o type=docker --target dev_release --tag $EXPECTED_REF .',
    ['.'],
    live_update=[
        sync('rebar.config', '/app/src/rebar.config'),
        sync('apps', '/app/src/apps'),
        run('rebar3 as tilt compile'),
        run('/app/_build/tilt/rel/service_discovery/bin/service_discovery restart')
    ],
    ignore=["rebar.lock", "apps/service_discovery_postgres/priv/migrations/"]
)
```

`rebar.lock`が明示的に無視されている点に注目してください。これは、実際には変更がないにもかかわらず時折書き換わることがあり、Tiltは変更が実際に発生したかどうかの比較を行わないため、ローカルでRebar3を実行するたびに`live_update`の手順が不要に走ってしまうからです。マイグレーションファイルも、別のDockerイメージで扱われるため無視されています。

ターゲットが`dev_release`なのは、実行中のイメージ内で単に再コンパイルして再起動するだけで[ライブアップデート](https://blog.tilt.dev/2019/04/02/fast-kubernetes-development-with-live-update.html)を行えるようにしたいからです。

```dockerfile
# image to use in tilt when running the release
FROM builder as dev_release

COPY . .
RUN rebar3 as tilt release

ENTRYPOINT ["/app/_build/tilt/rel/service_discovery/bin/service_discovery"]
CMD ["foreground"]
```

`live_update`の手順は、ファイルが変更されるたびにコンテナ内で実行されます。Rebar3のプロファイル`tilt`はリリースビルドに`dev_mode`を使っているため、リリース内のコンパイル済みモジュールを更新するには`compile`を実行するだけでよく、リリース全体を再ビルドする必要はありません(`dev_mode`とリリースビルドの詳細は[Releasesの章](../releases/)を参照してください)。そのため、更新コマンドは単に`apps`ディレクトリを実行中のコンテナに同期し、`compile`を実行してリリースを再起動するだけです。

最後に、デプロイするKubernetesリソースが`TiltFile`内で設定され、kustomizeのファイルが変わるたびに再実行するウォッチャーを設定します。

```python
k8s_yaml(kustomize('deployment/overlays/dev'))

watch_file('deployment/')
```

Tiltはkustomizeを標準でサポートしているため、`kustomize('deployment/overlays/dev')`で`dev`オーバーレイをレンダリングし、それを`k8s_yaml`に渡すことで、Tiltにどのkubernetesリソースをデプロイし追跡するかを伝えます。

`dev`オーバーレイの`kustomization.yaml`が`base`オーバーレイと重要な点で異なるのは、Postgresのkustomizeリソースが含まれていることと、`base`のリソースにマージされるパッチが含まれていることです。

```yaml
bases:
- ../../base
- ../../postgres
patchesStrategicMerge:
- flyway_job_patch.yaml
```

`fly_job_patch.yaml`は、Tiltの構成に合わせてFlywayジョブを設定するために使われます。

```yaml
# For tilt we make an image named service_discovery_sql with the migrations.
# This patch replaces the image used in the flyway migration job to match.
apiVersion: batch/v1
kind: Job
metadata:
  labels:
    service: flyway
  name: flyway
spec:
  template:
    spec:
      initContainers:
      - name: service-discovery-sql
        image: service_discovery_sql
        volumeMounts:
          - name: migrations
            mountPath: /flyway/sql
        command: ["/bin/sh"]
        args: ["-c", "cp /app/sql/* /flyway/sql"]
```

このパッチは、`Job`リソース内のイメージ名を、`Tiltfile`内の最初の`custom_build`イメージと同じ名前である`service_discovery_sql`に変更します。Tiltは`custom_build`のDockerビルドコマンドの環境で`$EXPECTED_REF`として設定する最新のタグでイメージを更新し、`Job`を再実行します。これにより、ローカルのKubernetesで`service_discovery`が動いている間に新しいマイグレーションが追加されると、自動的に検知されてデータベースに対して実行され、開発用クラスタをローカルの開発環境と同期させ続けられます。

`tilt`を実行すると、

```shell
$ tilt up
```

`Tiltfile`内で`k8s_yaml`に渡された各リソースを立ち上げる状況と、それに関連するログを示すコンソールUIが立ち上がります。

![Tiltのコンソール画面](tilt-console.png)

Tiltは同じ情報を示すページをブラウザで自動的に開きもします。

![TiltのWeb UI画面](tilt-webui.png)

`kubectl`を使うと、`service_discovery`のIPを調べられます。

```shell
$ kubectl get services --namespace=service-discovery-dev
NAME                    TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                      AGE
postgres-dev            ClusterIP   10.152.183.116   <none>        5432/TCP                     16m
service-discovery-dev   ClusterIP   10.152.183.54    <none>        8053/UDP,3000/TCP,8081/TCP   16m
```

そして`curl`と`dig`を使って、実行中のservice_discoveryとやり取りし、正しく動作しているか確認できます。

```shell
$ curl -v -XPUT http://10.152.183.54:3000/service \
    -d '{"name": "service1", "attributes": {"attr-1": "value-1"}}'
$ curl -v -XGET http://10.152.183.54:3000/services
[{"attributes":{"attr-1":"value-1"},"name":"service1"}]
```

## クラスタリング

近日公開予定です。

## StatefulSets

近日公開予定です。

