Kubernetes

この記事は英語の原文を日本語に翻訳したものです。原文: https://adoptingerlang.org/docs/production/kubernetes/

翻訳元: adoptingerlang/adoptingerlang 2025-12-18(コミット 899008f

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

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

Kubernetesを動かす

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

ローカル環境

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

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

本番環境

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

クラウドやオンプレミスへのデプロイには、他にも多くの選択肢があります。既存の企業であれば現在サービスをどこでホストしているかなど、選択には様々な要因が絡んできます。幸い、Kubernetesを使えばデプロイが特定のプロバイダーにロックインされることはありません。

デプロイ

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

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

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

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

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

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

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

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

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

詳しくはKubernetesのドキュメントにあるプロセス名前空間の共有を参照してください。

コンテナのリソース

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

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

さらに、CPUのリクエストはcgroupのプロパティcpu.sharesに変換されます。sharesは相対的な重みであり、そのcgroupがどれだけのCPUの取り分を得るかの計算に使われます。デフォルトは1024ですが、これは相対的な重みなので、他のプロセスの値がわからなければ単独では何の意味も持ちません。デフォルトのshare1024を持つプロセスが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を次のように設定します。

+sbwt ${SBWT}

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

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

コンテナの環境変数とConfigMap

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

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

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

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

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

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

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

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

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

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

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

envFrom:
- configMapRef:
    name: configmap

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

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

Initコンテナ

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

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コンテナが検証に使う共有ディレクトリへコピーするために使われます。mediummemoryであるemptyDir型のボリュームを使うのは、このボリュームを何かの永続化に使うのではなく、2つのInitコンテナ間で共有するためだけに使うからです。medium: Memoryを指定しなければemptyDirはホストのファイルシステム上に作られますが、それでも新しいPodごとに最初は空であり、Podが削除されると一緒に削除されます。

readiness・liveness・startupプローブ

Pod内の各コンテナは、readinesslivenessstartupの各プローブを定義できます。プローブは、コンテナ内で実行するコマンドとして、指定したポートとパスへのHTTPGETリクエストとして、あるいは特定のポートへの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で説明したように、各アプリケーションは起動時と逆順で停止され、各アプリケーションのsupervisorツリーも子を逆順で終了させます。とはいえこれは、アプリケーションのシャットダウンを適切かつ丁寧に処理しなくてよいという意味ではなく、あくまでPodの設定自体にはリリースの停止を遅らせるための特別な設定が不要だという意味です。グレースフルシャットダウンを行うのは、それぞれのアプリケーション自身の責務です。例えば、HTTPサーバーを起動するアプリケーションは、stopコールバックが返る前に、開いているリクエストの処理が終わるのを待つべきです。

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

StartupProbe

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

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

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

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リソースには、イメージの新バージョンがデプロイされたときの挙動を設定できるストラテジーがあります。service_discoveryDeploymentはローリングアップデートのストラテジーを使っています。もう一つの選択肢はRecreateで、こちらはKubernetesがまずDeployment内のすべてのPodを終了させてから新しいPodを起動するというものです。RollingUpdateストラテジーでは、KubernetesのDeploymentコントローラーは新しいPodを立ち上げてから古いPodを終了させます。コントローラーに対して、望ましい数を超えていくつのPodを許容するか(maxSurge)、デプロイ中にいくつのPodが利用不能になってよいか(maxUnavailable)を伝える設定変数が2つあります。

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

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

Service

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

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つのポートを公開し、それぞれにdnshttpgrpcという名前を付けていました。Serviceは、公開する各ポートのtargetPortとしてこれらの名前を使います。このリソースをKubernetesクラスタに適用すると、これら3つのポートをリッスンするIPができ、実行中のいずれかのPodにプロキシされます。

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

CoreDNSのようなKubernetesに対応したDNSサービスは、新しいServiceを監視し、各サービスに対して[service-name].[namespace]というDNSレコードを作成します。service-discoveryServiceにある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)を使わずに分散Erlangでノード同士を接続するために、Headless Service(ClusterIPNoneに設定したService)リソースと名前付きポートをどう使うかを見ていきます。

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

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

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

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

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

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

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

$ 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プロジェクトの主要なリソースであるNamespaceDeploymentServiceが含まれます。

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_discoverydeployment/base/deployment.yamlにあるDeploymentリソースは次のようになります。

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

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

$ 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を最後に見ました。

envFrom:
- configMapRef:
    name: configmap

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

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

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

LOGGER_LEVEL=debug

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

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を作成し、それへの参照をすべて更新します。Deploymentspec内のConfigMapへの参照を更新することで、設定が変わったときにPodが確実に再起動されるようになります。単にConfigMapを変更するだけでは、実行中のPodは更新されません。そのため、Kustomizeの出力ではDeploymentは次のようになります。

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

Secretsについても同様です。

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

$ kubectl apply -k deployment/overlays/dev

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

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

Job

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

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ではアルファクラスタを作成することでこの機能を試せますが、こうしたクラスタは30日間しか存続できません。もう一つの方法は、マイグレーションを手動で(あるいはCIパイプラインで実行できるスクリプトで)実行し、デプロイの完了後にJobを削除することです。

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

マイグレーションの検証

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

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は、開発用にローカルでDockerイメージとKubernetesのデプロイを行い、更新していくためのツールです。デフォルトでは、kindmicrok8sなどのローカルなKubernetesに対してのみビルドとデプロイを行い、開発環境を誤って本番に送り込んでしまわないよう配慮されています。

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

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を使います。

$ ctlptl apply -f cluster.yaml

TiltはプロジェクトのルートにあるTiltfileというファイルをもとに動作します。service_discoveryのルートにあるTiltfileを順に見ていくと、まず次から始まります。

default_registry('127.0.0.1:32000')

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

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

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からビルドされます。

FROM busybox as dev_sql

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

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

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

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

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なのは、実行中のイメージ内で単に再コンパイルして再起動するだけでライブアップデートを行えるようにしたいからです。

# 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の章を参照してください)。そのため、更新コマンドは単にappsディレクトリを実行中のコンテナに同期し、compileを実行してリリースを再起動するだけです。

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

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

watch_file('deployment/')

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

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

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

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

# 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を実行すると、

$ tilt up

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

Tiltのコンソール画面

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

TiltのWeb UI画面

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

$ 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

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

$ 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

近日公開予定です。