Table of Contents
RailsアプリケーションをKubernetes(以後、k8s)で運用できるようにするための手順を書きます。 この記事はシリーズ連載記事の第五回です。
- 第一回 Docker編
- 第二回 Docker Compose/Dockerfile編
- 第三回 Kubernetes入門編
- 第四回 Kubernetes基礎編
- 第五回 Kubernetes応用編
- 第六回 Helm編
前回のKubernetes基礎編では、
Step1として第2回Docker Compose/Dockerfile編
のdocker-compose-preview.yml
に相当する構成をDeployment
, Service
, ConfigMap
, Secret
の4種のAPIオブジェクトで記述しました。
マニフェストファイル一式はサンプルコードの k8s/manifests-step1/ ディレクトリにあります。
この構成には下記に挙げる三つの制約があります。
- pumaコンテナを複数起動すると
rails db:setup
が並列実行されてエラーになる。 - MySQLやRedisのデータが永続化されていないため、コンテナを停止するとデータも消える。
- pumaに外部からアクセスするためのエンドポイントを本番環境で運用するのが難しい。
今回はこれらの制約をStep2からStep4で解消していきます。
サンプルコードは全て下記のリポジトリにあります。
https://github.com/kwhrtsk/rails-k8s-demoapp
前提
準備
まずサンプルアプリのコードをチェックアウトしてminikubeを起動してください。 (前回と同じなのですでにやっている人は読み飛ばしてください)
# サンプルアプリのコードをチェックアウト
$ git clone https://github.com/kwhrtsk/rails-k8s-demoapp.git
$ cd rails-k8s-demoapp
# minikubeを起動
$ minikube start --cpus=3 --memory=2048 --vm-driver=hyperkit --disk-size=12g
# kubernetesのダッシュボードをオープン
$ minikube dashboard
Step2: Job
サンプルコードは全て下記のディレクトリにあります。
Step1の構成ではpumaのレプリカの数を1に指定していましたが、
一定以上の性能を出そうとするとレプリカの数は2以上に設定する必要があります。
ところが、このままの構成でpumaコンテナを複数起動すると、rails db:setup
が並列実行されてエラーが発生します。
次のような手順でこれを確認できます。
まずpuma-deploy.yaml
を書き換えてreplicasを2にします。
diff --git a/k8s/manifests-step1/puma-deploy.yaml b/k8s/manifests-step1/puma-deploy.yaml
index 003f484..c17f9bb 100644
--- a/k8s/manifests-step1/puma-deploy.yaml
+++ b/k8s/manifests-step1/puma-deploy.yaml
@@ -7,7 +7,7 @@ metadata:
app: demoapp
component: puma
spec:
- replicas: 1
+ replicas: 2
selector:
matchLabels:
app: demoapp
次にstern
でログを監視しつつ、別の端末でkubectl apply
を実行し、APIオブジェクトを作成します。
$ cd k8s/manifests-step1
# デプロイ済みのオブジェクトがある場合は削除
$ cat *.yaml | kubectl delete -f -
# ログを監視
$ stern "demoapp.*"
# 別の端末で実行
$ cat *.yaml | kubectl apply -f -
タイミング依存なのでエラーが発生しない場合もありますが、それなりの確率で下記のようなエラーがsternの端末上に表示されるはずです。
demoapp-puma-749c456c87-2wk5c puma + ./bin/wait-for demoapp-redis 6379
demoapp-puma-749c456c87-2wk5c puma + ./bin/rails db:setup_if_not_yet
demoapp-puma-749c456c87-lwwb7 puma + ./bin/wait-for demoapp-redis 6379
demoapp-puma-749c456c87-lwwb7 puma + ./bin/rails db:setup_if_not_yet
demoapp-puma-749c456c87-lwwb7 puma Database 'demoapp_production' already exists
demoapp-puma-749c456c87-2wk5c puma Database 'demoapp_production' already exists
demoapp-puma-749c456c87-lwwb7 puma -- create_table("messages", {:options=>"ENGINE=InnoDB DEFAULT CHARSET=utf8", :force=>:cascade})
demoapp-puma-749c456c87-2wk5c puma -- create_table("messages", {:options=>"ENGINE=InnoDB DEFAULT CHARSET=utf8", :force=>:cascade})
demoapp-puma-749c456c87-lwwb7 puma -> 0.0505s
demoapp-puma-749c456c87-2wk5c puma -> 0.0925s
demoapp-puma-749c456c87-2wk5c puma rails aborted!
demoapp-puma-749c456c87-2wk5c puma ActiveRecord::RecordNotUnique: Mysql2::Error: Duplicate entry '20180411125010' for key 'PRIMARY': INSERT INTO `schema_migrations` (version) VALUES (20180411125010)
2つのpuma用のPodがほぼ同じタイミングで./bin/rails db:setup_if_not_yet
を実行していることがわかります。
このRakeタスクは第二回 Docker Compose/Dockerfile編で説明しましたが、
下記のような内容です。
# lib/tasks/db.rake
namespace :db do
desc "Invoke db:setup task only if the task has never been invoked yet."
task setup_if_not_yet: [:environment] do
begin
if !ActiveRecord::SchemaMigration.table_exists?
# データベースは存在するが、必ずあるはずのテーブルがない
Rake::Task["db:setup"].invoke
exit 0
end
rescue ActiveRecord::NoDatabaseError
# データベースが存在しない
Rake::Task["db:setup"].invoke
exit 0
end
end
end
rails db:setup
の初回実行時に作成されるテーブルの有無を確認して、テーブルが存在しない場合のみrails db:setup
を実行していますが、
テーブルの確認とタスクの実行の間がクリティカルセクションになっているため、何らかの方法で排他制御しなければrails db:setup
が2重に実行される可能性があります。
前述のエラーはまさにこれが原因です。
MySQLやRedisを使ってロックを取るなどこれを排他制御することは技術的には可能なのですが、
rails db:setup
のように一度きりしか実行しないような処理を記述するには Job
というAPIオブジェクトが適任ですので、これを例に使い方を説明します。
ところで、典型的なRailsのワークフローには、DBの初期化を行うrails db:setup
に加えて、スキーマの変更などを行うマイグレーション用のタスクrails db:migrate
があります。
一見するとこれもJob
で管理するのが良さそうなのですが、Job
は一度APIオブジェクトを作成するとDeployment
のようにイメージを変更できないという制約があります。
そのため、新しいマイグレーションを作成するたびにJob
オブジェクトも新規に作成する必要があり、運用が煩雑になります。
また、rails db:setup
とは異なり、rails db:migrate
には(少なくともDBがMySQLかPostgreSQLの場合には)
RDBMSのロック機構を使ってマイグレーションが多重実行されないように排他制御する仕組みが組み込まれているため、
puma
の起動前に毎回実行しても実質的な問題が発生しません。
そこで Step2 では全体の構成を下記のように変更します。
rails db:setup
はJob
として定義し、アプリを最初にk8sにデプロイした時に一度だけ実行する。rails db:migrate
はpuma
の起動前に毎回実行する。(複数のpuma
コンテナにより同時に起動される可能性があるが問題ない)
まずは rails db:setup
を実行するための Job
のマニフェストを書きます。
# k8s/manifests-step2/setup-db-job.yaml
---
apiVersion: batch/v1 # Deploymentのapps/v1とは異なる点に注意
kind: Job
metadata:
name: demoapp-setup-db
spec:
backoffLimit: 4 # ジョブを失敗したと見なすまでの再試行回数
template:
metadata:
labels:
app: demoapp
component: setup-db
spec:
restartPolicy: Never # OnFailureにするとbackoffLimitが効かない場合があるのでNeverにする
containers:
- name: setup-db
image: demoapp:0.0.1
command:
- ./bin/setup-db
envFrom:
- configMapRef:
name: demoapp-rails-env
- secretRef:
name: demoapp-rails-env
内容はpuma
やsidekiq
のDeployment
と似ています。
まず同じようにdemoapp
イメージを指定します。
また、.spec.template
以下の内容もほぼ同じですが、livenessProbe
などの項目が無い分Deployment
よりもシンプルな内容になります。
Job
固有の設定値について簡単に補足します。
backoffLimit
のデフォルト値は6です。4を指定しているのは単にサンプルとして例示するためで深い意味はありません。restartPolicy
はOnFailure
かNever
を指定できます。OnFailure
の場合はジョブが失敗したときにPod
はそのままで内部のコンテナだけ作り直します。Never
の場合はPod
ごと作り直します。 今回の場合どちらでも良いのですが、既知の問題によりNever
だとbackoffLimit
が無視されるケースがあると公式ドキュメントにも書いてあるのでNever
を指定しています。
詳細は下記のドキュメントを参照してください。
command
に指定している ./bin/setup-db
は下記のような内容で、
単にmysql
の起動を待ち受けてから(Step1ではpuma
で実行していた)rails db:setup_if_not_yet
を実行します。
#!/bin/bash -xu
cd $(dirname $0)/..
./bin/wait-for $MYSQL_HOST 3306
./bin/rails db:setup_if_not_yet
また、puma-deploy.yaml
も次のように修正します。
--- k8s/manifests-step1/puma-deploy.yaml 2018-05-11 23:30:49.000000000 +0900
+++ k8s/manifests-step2/puma-deploy.yaml 2018-05-11 23:57:17.000000000 +0900
@@ -7,7 +7,7 @@
app: demoapp
component: puma
spec:
- replicas: 1
+ replicas: 2
selector:
matchLabels:
app: demoapp
@@ -24,7 +24,7 @@
image: demoapp:0.0.1
imagePullPolicy: IfNotPresent
command:
- - ./bin/setup-db-and-start-puma
+ - ./bin/start-puma
livenessProbe:
httpGet:
path: /health_check/full
replicas
を2に増やし、command
を./bin/start-puma
に変更しています。
./bin/start-puma
は下記のような内容です。
#!/bin/bash -x
cd $(dirname $0)/..
trap "pkill -P $$" EXIT
./bin/wait-for $MYSQL_HOST 3306
./bin/wait-for $REDIS_HOST 6379
./bin/rails db:try_migrate
./bin/pumactl start
Step1ではpumactl
コマンドの実行前に./bin/rails db:setup_if_not_yet
を実行していましたが、
代わりにrails db:try_migrate
を実行します。このRakeタスクは下記のような定義です。
# lib/tasks/db.rake
namespace :db do
desc "Wait for db:setup task to complete."
task wait_for_setup_completion: [:environment] do
loop do
begin
if !ActiveRecord::InternalMetadata.table_exists?
# データベースは存在するが、必ずあるはずのテーブルがない
sleep 1
elsif !ActiveRecord::InternalMetadata.where(key: :environment).exists?
# テーブルはあるがレコードが存在しない
sleep 1
else
break
end
rescue ActiveRecord::NoDatabaseError
# データベースが存在しない
sleep 1
end
end
end
desc "Invoke db:migrate task and ignore errors caused by parallel execution."
task try_migrate: [:wait_for_setup_completion] do
begin
Rake::Task["db:migrate"].invoke
rescue ActiveRecord::ConcurrentMigrationError => e
Rails.logger.info "Skip migrations because another migration process is currently running."
end
end
end
行数は多いですがやっていることはrails db:setup
の完了を待ってからrails db:migrate
を実行しているだけです。
ただし、db:migrate
タスクでActiveRecord::ConcurrentMigrationError
という例外が発生した場合は、
エラー終了しないように捕捉してINFOレベルでログに書き出すようにしています。
この例外は、マイグレーションが多重起動された際に、ロックを取れなかったプロセス側が発生させる例外で、実害はありません。 単にそのプロセスではマイグレーションがスキップされたということを示しています。
アプリイメージの更新とローリングリスタートの動作確認
Step2での修正はJob
の追加とpuma
用のDeployment
の定義の変更の2点だけですが、
これを機にdemoapp
のイメージを更新してpuma
用のDeployment
に反映し、Pod
がローリングリスタートされる様子を確認してみましょう。
demoapp
の更新は新規マイグレーションを含む内容にしてみます。
まずStep2のマニフェストでdemoapp
をminikubeにデプロイします。
# Railsアプリのイメージをビルド
$ (eval $(minikube docker-env) && docker build . -t demoapp:0.0.1)
$ cd k8s/manifests-step2
# APIオブジェクトを作成
$ cat *.yaml | kubectl apply -f -
# puma deploymentの起動が完了するまで待機
$ kubectl rollout status deploy demoapp-puma
# puma serviceのエンドポイントをブラウザでオープン
$ minikube service demoapp-puma
次に、messages
テーブルにlikes
というカラムを追加するだけの内容で新しいマイグレーションを作ります。
# RAILS_ROOTに移動してローカル環境にMySQLとRedisを起動しDBを初期化
$ cd ../../
$ rm -r tmp/mysql tmp/redis
$ docker-compose up -d
# しばらく待ってから
$ ./bin/rails db:setup
# 新しいマイグレーションを作成して、ローカル環境にマイグレーション実行
$ ./bin/rails g migration AddLiksToMessage likes:integer
$ ./bin/rails db:migrate
新しいdemoapp
のイメージを作りデプロイします。
# タグを0.0.2に変えてRailsアプリのイメージをビルド
$ (eval $(minikube docker-env) && docker build . -t demoapp:0.0.2)
# 新しいイメージをデプロイして更新が完了するのを待つ
$ kubectl set image deploy/demoapp-puma puma=demoapp:0.0.2
$ kubectl set image deploy/demoapp-sidekiq sidekiq=demoapp:0.0.2
$ kubectl rollout status deploy demoapp-puma
$ kubectl rollout status deploy demoapp-sidekiq
事前に別の端末で下記のコマンドをそれぞれ実行しておくとローリングリスタートに伴うPod
の入れ替わりの様子を確認しやすいと思います。
# puma用のPodのログを表示
$ stern "demoapp-puma-.*"
# APIオブジェクトの変化を監視して表示
$ kubectl get deployments --watch
$ kubectl get replicasets --watch
$ kubectl get pods --watch
watchコマンドを用いる方法もおすすめです。watchコマンドは brew install watch
でインストールできます。
# 更新頻度を1秒に指定
$ watch -n 1 kubectl get deployments,replicasets,pods
kubectl set image deploy/demoapp-puma puma=demoapp:0.0.2
を実行するとpuma
のDeployment
が更新され、
ローリングアップデートが開始します
(puma=demoapp:0.0.2
のpuma
は、puma-deploy.yaml
で定義したコンテナのname
です)。
この際、puma-deploy.yaml
のreplicas
が2だと下記のような動作になり、rails db:migrate
の競合は起きません。
- 新しい
Pod
を一つ起動開始 - 1の
Pod
が起動完了し、古いPod
のうち一つが停止し、さらに新しいPod
を一つ起動開始 - 2の
Pod
が起動完了し、残りの古いPod
が停止
replicas
が4だと下記のような動作になり、高い確率でrails db:migrate
が競合します。(puma-deploy.yaml
を編集して試してみてください)
- 古い
Pod
を一つ停止すると同時に新しいPod
を二つ起動開始 - 1の
Pod
二つがほぼ同時に起動完了し、古いPod
がさらに停止され、新しいPod
がさらに二つ起動開始 - 2の
Pod
二つがほぼ同時に起動完了し、最後のPod
が停止
rails db:migrate
がほぼ同時に起動された場合には、遅い方のPod
のログに下記のようなメッセージが残るはずです。
INFO -- : Skip migrations because another migration process is currently running.
なお、Deployment
の更新の際のPod
の入れ替えはこのように徐々に新しいものに入れ替えるような動作になっていますが、
これはマニフェストの.spec.strategy
で変更することができます。
puma-deploy.yaml
の例では特に指定していないのでデフォルトのRollingUpdate
になっていますが、
Recreate
を指定すると古いPod
を全て削除してから新しいPod
を立ち上げるようになります。これについてはStep3で取り上げます。
また、replicas
が4のケースだと、一時的に起動中のPod
の数が5つに、READYな状態のPod
は3つになっている点に注意してください。
急激なサービスのスループット低下とインフラ負荷の増加を防ぐため、レプリカの数が多い場合はデフォルトでこのような動作をするようになっています。
ローリングアップデート中におけるREADYな状態のPod
の割合と起動中(準備中含む)のPod
の割合は、
それぞれ.spec.strategy.rollingUpdate.maxUnavailable
と.spec.strategy.rollingUpdate.maxSurge
で指定できます。
デフォルト値はいずれも25%です。
これについては本ドキュメントではこれ以上説明しないので、詳細は下記の資料を参照してください。
graceful stop
k8sのようにBlue-Greenデプロイメントでアプリケーションの更新を行う場合、 新しいインスタンスを立ち上げつつ古い方のインスタンスは順次停止します。 この際、実行中の処理はできるだけ強制終了せずに、 完了するまで待ってからアプリケーションプロセスを停止することが望ましいです。
pumaとsidekiqの場合は次のようなことを意味します。
- puma: 処理中のリクエストのレスポンスを返し終わってから停止する。
- sidekiq: 処理中のジョブが全て完了してから停止する。
一般的にこのような停止処理のことをgraceful stopと呼びます。
前節で述べたローリングリスタートの際には、k8sは停止するPod
の全てのコンテナにSIGTERM
シグナルを送信し、
コンテナのメインプロセスが停止するのを待ちます。
そのため、コンテナで起動するプロセスはSIGTERM
を受け取った時にgraceful stopするように実装しておく必要があります。
pumaとsidekiqはいずれもSIGTERM
を受け取った際にgraceful stopする仕様なので、このままでも問題ない場合もあるのですが、
下記の点に気をつける必要があります。
- シェルスクリプトでラップしている場合は、
trap
コマンドなどでシグナルを捕捉して子プロセスにもSIGTERM
を送ること。 サンプルは第2回を参照してください。 - sidekiqは
SIGTERM
を受け取ってからジョブの強制終了まで8秒しか待たない。 これが短すぎる場合は起動時の-t
オプションで変更しておくこと。 サンプルは第4回の記事を参照してください。 - k8sがコンテナを強制停止するまでの猶予期間が30秒で足りない場合は、
Deployment
のspec.template.spec.terminationGracePeriodSeconds
で変更しておくこと。 (参考)
pumaとsidekiqのシグナルの扱いについては下記のドキュメントを参照してください。
Makefileのサンプル
Step2のディレクトリにもMakefileを置いてあります。
本節に出てきた各種操作は(マイグレーションの作成以外は)対応するタスクを定義してあるので、
ローリングアップデートの動作を確認する際にはmake
コマンドを使うと簡単にオペレーションを実行することができます。
$ cd k8s/manifests-step2
# タグを指定してイメージをビルドし、APIオブジェクト一式を作成(TAGは省略可)
$ make TAG=0.0.1
# タグを指定してイメージをビルドし、puma用Deploymentに反映
$ make TAG=0.0.2 update
# APIオブジェクトの削除
$ make clean
MakefileはStep1のものをベースにいくつか修正を加えた内容です。
miniube-docker-build
は、指定されたタグが存在する場合はビルドをスキップするようにしています。
# k8s/manifests-step2/Makefile
SHELL = /bin/bash
ifeq ($(TAG),)
tag := 0.0.1
else
tag := $(TAG)
endif
all:
$(MAKE) minikube-docker-build
$(MAKE) kubectl-apply
$(MAKE) kubectl-rollout-status
$(MAKE) minikube-service
clean: kubectl-delete
update:
$(MAKE) TAG=$(tag) minikube-docker-build
$(MAKE) TAG=$(tag) deploy
minikube-docker-build:
eval $$(minikube docker-env) && \
if [ "$$(docker image ls -q demoapp:$(tag))" == "" ]; then \
docker build ../../ -t demoapp:$(tag); \
fi
kubectl-apply:
cat *.yaml | kubectl apply -f -
kubectl-rollout-status:
kubectl rollout status deploy demoapp-puma
kubectl rollout status deploy demoapp-sidekiq
minikube-service:
minikube service demoapp-puma
kubectl-delete:
cat *.yaml | kubectl delete -f -
deploy:
kubectl set image deploy/demoapp-puma puma=demoapp:$(tag)
kubectl set image deploy/demoapp-sidekiq sidekiq=demoapp:$(tag)
kubectl rollout status deploy demoapp-puma
kubectl rollout status deploy demoapp-sidekiq
stern:
stern demoapp.*
Step3: PersistentVolumeClaim(PVC)
サンプルコードは全て下記のディレクトリにあります。
Step2の時点ではMySQLとRedisのデータ領域が永続化されていないため、
何らかの障害でPod
が停止するとDBの内容が全て消えるという制約があります。
Step2のマニフェストでAPIオブジェクト一式を作成した後、mysql
のPod
をkubectl delete
コマンドで削除すると、
puma
のヘルスチェックが失敗するようになるのを確認できます。
$ cd k8s/manifests-step2
# APIオブジェクトを作成
$ cat *.yaml | kubectl apply -f -
# puma deploymentの起動が完了するまで待機
$ kubectl rollout status deploy demoapp-puma
# MySQL用のPodを削除
$ kubectl delete pod -l "app=demoapp,component=mysql"
# pumaのログを確認
$ stern "demoapp-puma-.*"
(省略)
demoapp-puma-5cdbdbfc76-d6ftm puma I, [2018-05-13T12:11:56.303988 #33] INFO -- : [026a124c-fedb-4423-8587-40bec3481cde] health_check failed:
demoapp-puma-5cdbdbfc76-d6ftm puma
demoapp-puma-5cdbdbfc76-d6ftm puma Migrations are pending. To resolve this issue, run:
demoapp-puma-5cdbdbfc76-d6ftm puma
demoapp-puma-5cdbdbfc76-d6ftm puma bin/rails db:migrate RAILS_ENV=production
そこでStep3では、MySQLとRedisにPersistentVolume
(以後、PV
)というAPIオブジェクトを割り当ててデータボリュームとして使用し、
DBの内容がPod
の生死に関わらず維持されるようにします。
PV
は永続ストレージを抽象化したAPIオブジェクトです。
PV
の実体はNFSやGlusterFSのようなネットワークストレージでも良いし、
GCP上であればGCEのPersistentDisk、AWSであればEBSを使うこともできます。
これをボリュームタイプと呼びます。
PV
のボリュームタイプはプラグインとして実装されていて、すでに主要なネットワークストレージとパブリッククラウドのストレージサービスに対応しています。
MySQLのようなデータベースをk8sで運用するためには、障害でPod
やk8sノードがクラッシュした場合に、
新しいPod
にデータ領域を引き継ぐ必要があります。
k8sでは先に挙げたようなドライバ経由でk8sクラスタの外にデータを保存することによってこれを実現しています。
PV
オブジェクトは直接マニフェストを書いて作ることもできますが、
PersistentVolumeClaim
(以後、PVC
)というAPIオブジェクトのDynamic Provisioningという機能を経由して作る方が簡単なので、
本ドキュメントではその方法で説明します。
まずMySQL用のPVC
オブジェクトのマニフェスト mysql-pvc.yaml
を作成します。
# k8s/manifests-step3/mysql-pvc.yaml
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: demoapp-mysql
labels:
app: demoapp
component: mysql
spec:
accessModes:
- ReadWriteOnce # 同時に一つのプロセスからしか読み書きしないモードを指定
resources:
requests:
storage: 8Gi # 確保する永続ストレージのサイズを指定
storageClassName: standard # 後述
.spec.storageClassName
にはStorageClass
というAPIオブジェクトの名前を指定します。
StorageClass
の詳細については後述します。
次に mysql-deploy.yaml
へこのPVC
を結びつけます。
コメントをつけている行は、Step2での定義に追加した部分です。
# k8s/manifests-step3/mysql-deploy.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: demoapp-mysql
labels:
app: demoapp
component: mysql
spec:
replicas: 1
selector:
matchLabels:
app: demoapp
component: mysql
strategy: # 更新時の動作を指定
type: Recreate # 古いPodを停止してから新しいPodを起動
template:
metadata:
labels:
app: demoapp
component: mysql
spec:
restartPolicy: Always
volumes: #
- name: data # .volumeMountsの.nameで指定
persistentVolumeClaim: #
claimName: demoapp-mysql # PVCオブジェクトの.metadata.nameを指定
initContainers: # 前処理用のコンテナ
- name: "remove-lost-found" # データディレクトリの"lost+found"ディレクトリを削除
image: "busybox:1.25.0"
command:
- rm
- -fr
- /var/lib/mysql/lost+found
volumeMounts:
- name: data
mountPath: /var/lib/mysql
containers:
- name: mysql
image: mysql:5.7.21
livenessProbe:
tcpSocket:
port: 3306
readinessProbe:
tcpSocket:
port: 3306
volumeMounts: #
- name: data # .volumesの.nameを指定
mountPath: /var/lib/mysql # MySQLのデータディレクトリを指定
envFrom:
- configMapRef:
name: demoapp-mysql-env
- secretRef:
name: demoapp-mysql-env
このようにPVC
オブジェクトを指定すると、Deployment
の作成時にStorageClass
に応じたPV
が自動的に作成され、
指定したパスにマウントされます。
この例では mysql-pvc.yaml
で定義したPVC
によって作られたPV
を
MySQLのデータディレクトリである/var/lib/mysql
にマウントしています。
.spec.template.spec.initContainers
は前処理用のコンテナの定義です。
メインのコンテナを起動する前に自動的に実行されます。
この例ではMySQLのデータディレクトリである/var/lib/mysql
ディレクトリに
lost+found
というディレクトリがあれば削除するという処理を入れています。
PVC
で確保したボリュームの中身は後述するStorageClass
によって異なる場合があるのですが、
mysql
イメージは/var/lib/mysql
ディレクトリが空でない場合、DBの初期化処理がエラーで失敗します。
minikubeの場合は空なのですが、GKEのようにlost+found
ディレクトリが最初から存在する場合もあるので、
ある程度どこでも動くようにinitContainers
でこれを取り除いています。
また、.spec.strategy.type
にRecreate
を指定している点に注意してください。
Step2でも説明しましたが、これは更新時のPod
の入れ替えの挙動を指定するパラメータです。
デフォルトはRollingUpdate
になっており、古いPod
を停止する前に新しいPod
を起動します。
MySQLは同一のデータディレクトリを複数のサーバが参照するとエラーになるため、
Recreate
を指定して二つ以上のPod
が同時に起動しないようにしています。
Redisについてもほぼ同様に、redis-pvc.yaml
を追加してredis-deploy.yaml
を更新しています。
特筆すべき点はないので説明は割愛します。詳細はサンプルコードを参照してください。
StorageClassについて
StorageClass
は、永続ストレージの設定をまとめたオブジェクトです。
設定とはボリュームタイプやPV
の削除時にデータオブジェクトを残すかどうかといった項目です。
minikubeの場合、クラスタの作成時に自動的にstandard
という名前のStorageClass
が作られるのでそれを指定しています。
このオブジェクトの定義は、minikube dashboard
上または下記のコマンドで確認できます。
$ kubectl get storageclass standard -o yaml
重要な部分を抜粋すると下記のような内容です。
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
annotations:
storageclass.beta.kubernetes.io/is-default-class: "true"
name: standard
provisioner: k8s.io/minikube-hostpath
reclaimPolicy: Delete
volumeBindingMode: Immediate
.provisioner
がボリュームプラグインの種類を示しています。
minikube-hostpath
はシングルノードでの検証用のもので、minikube VM上のローカルストレージをデータの保存先として使用します。
GCPのGKEでk8sクラスタを作った場合も、自動的にstandard
という名前のStorageClass
が作られますが、
下記の通り.provisioner
はGCEのPersistentDiskが指定されているため、
マルチノードクラスタでの運用に耐えるものとなっています。
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
annotations:
storageclass.beta.kubernetes.io/is-default-class: "true"
name: standard
provisioner: kubernetes.io/gce-pd
parameters:
type: pd-standard
reclaimPolicy: Delete
重要なのは、PVC
オブジェクトでは単にstandard
という名前だけを指定しており、
ボリュームプラグインのようなクラスタ環境に依存するパラメータを一切記述していないという点です。
これにより、この例のようにminikubeとGKEのような異なるプラットフォームであっても、
それぞれの環境に応じて適切に設定されたStorageClass
が同じ名前で用意されていれば、
このマニフェストはいずれのクラスタにもデプロイ可能になります。
StatefulSetについて
k8sにはStatefulSet
というAPIオブジェクトがあります。
このオブジェクトはステートフルなアプリケーションを管理するためのものと位置付けられています。
そのため、一見するとMySQLやRedisのようなサービスを管理する際にはDeployment
ではなくStatefulSet
を使うべきではと思われるのですが、
StatefulSet
が必要になるのは Set の名が示す通りMySQLのマスター/スレーブ構成など複数のコンテナでクラスタを組む場合です。
Step3の例のように単一のコンテナによるステートフルなアプリケーションの場合、
Deployment
を使って.spec.strategy.type
をRecreate
にするだけで十分です。
ということが公式のドキュメントにも書いてあるのでリンクを貼っておきます。
Run a Single-Instance Stateful Application
ステートフルなサービスの運用について
Railsアプリケーションをクラウド上のk8sクラスタ上で運用する場合には、 MySQLやRedisのような永続データを持つサービスはコンテナ化せず、 クラウドベンダのマネージドサービスを使うという方法も考えられます。
特に可用性などの要件が厳しい場合には、簡単にマルチAZでマスタースレーブ構成を運用できるAmazon RDSのようなサービスは魅力的な選択肢です。
一方、可用性やデータロスト耐性に対する要求がそこまで厳しくない場合には、Step3で示したような構成も悪くない選択肢です。
Pod
やk8sノードに障害が発生した場合には、新しいPod
が即座に作成されて永続ボリュームの内容を引き継ぎます。
Amazon EBSやGCE Persistent Diskのようなストレージサービスを永続ボリュームとして使う場合、定期的なスナップショットを取るのは簡単です。
確かにマルチ構成のAmazon RDSと比較すると障害発生時のダウンタイムは長くなる可能性が高いし、 永続ボリュームに障害が発生した場合には直前のスナップショットまでデータロストするリスクがありますが、 それを許容できるような場合にはこの程度の構成で十分とも言えます。
Step4: Ingress
サンプルコードは全て下記のディレクトリにあります。
Step3の時点では、Railsアプリ(puma
プロセス)の外部向けのインタフェースとして、
NodePort
タイプのService
オブジェクトを使っていました。
この方法ではk8sクラスタを構成する全てのノードにエンドポイントが用意されます。
minikubeのようにシングルノード構成の検証用クラスタではこれで十分ですが、
マルチノード構成の本番環境ではそのまま運用するのは難しいでしょう。
少なくとも負荷分散や可用性担保のためには前段にロードバランサが必要になります。
そうするとノードの追加や縮退の際にロードバランサの設定も変更する必要が生じ、管理が煩雑になります。
そのため、このような用途でService
オブジェクトを使う場合には、通常はLoadBalancer
というタイプを指定します。
LoadBalancer
を指定した場合にどのような仕組みで接続用のエンドポイントが用意されるかはk8sのデプロイ先の環境によって異なります。
例えばAWS上であればELBを使うことができます。一方、minikubeはLoadBalancer
に完全には対応していません。
そのため、LoadBalancer
タイプでService
を作ると下記のようにEXTERNAL-IP
がいつまでもpending
の状態のまま変わらず、
ダッシュボードでも準備中のアイコンが表示され続けます。
% kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
demoapp-mysql ClusterIP 10.99.99.47 <none> 3306/TCP 1m
demoapp-puma LoadBalancer 10.98.211.117 <pending> 3000:32320/TCP 1m
demoapp-redis ClusterIP 10.111.218.160 <none> 6379/TCP 1m
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 8m
参考: docker - kubernetes service external ip pending - Stack Overflow
一方、k8sには負荷分散やSSLターミネーション、ホスト名やリクエストパスによるバーチャルホストなど、
外部向けのエンドポイントを抽象化することにより特化した機能を持つIngress
というオブジェクトがあります。
これらの機能の一部はLoadBalancer
タイプのService
でも実現できますが、
AWSのELBなどバックエンドごとに固有のannotation
で指定する必要があり、
Ingress
に比べると可搬性の面で劣ります。
そこでこのStep4では外部向けのエンドポイントにIngress
を使うように構成を変更します。
LoadBalancer
タイプのService
の使い方については説明しません。詳細は下記のドキュメントを参照してください。
Ingress コントローラのインストール
PersistentVolume
同様、Ingress
もまたその実装がk8sクラスタのデプロイ先に依存する機能です。
GKEではGCPのネットワーク機能を使って実装されていますが、
その他の環境ではIngress
コントローラをPod
としてk8sクラスタ上に動かしておく必要があります。
Ingress
コントローラの中にはGLBC(ingress-gce)のようにクラウドプロバイダ固有の機能を使ったものと、
NGINX Ingress Controller(ingress-nginx)のようにどこでも動作するものがあります。
幸いにしてminikubeではNGINX Ingress Controller
をaddonとして簡単にインストールすることができます。
本ドキュメントで示すマニフェストを試す場合には、minikubeインスタンスの起動後に下記のコマンドを実行してください。
$ minikube addons enable ingress
動作確認の手順
このドキュメントでは、下記のような構成を目指します。
puma
サービスへhttps://demoapp-puma.$(minikube ip).nip.io/
というホスト名で接続できるようにする。- サーバ証明書には自己署名のダミー証明書を使用する。
Ingress
でSSL終端して、Service
にはHTTPで接続する。
Step3以前と比べて少し手順が変わっています。各コマンドの詳細は次節以降で説明します。
# Railsアプリのイメージをビルド
$ (eval $(minikube docker-env) && docker build . -t demoapp:0.0.1)
$ cd k8s/manifests-step4
# ダミーのサーバ証明書を作成
COMMON_NAME=demoapp-puma.$(minikube ip).nip.io
openssl req -new -x509 -nodes -keyout server.key -days 3650 \
-subj "/CN=${COMMON_NAME}" \
-extensions v3_req \
-config <(cat openssl.conf | sed s/\${COMMON_NAME}/$COMMON_NAME/) > server.pem
unset COMMON_NAME
# 証明書をSecretオブジェクトとして登録
kubectl create secret tls demoapp-puma-tls --key server.key --cert server.pem
# APIオブジェクトを作成
export MINIKUBE_IP=$(minikube ip)
cat *.yaml | sed s/\${MINIKUBE_IP}/$MINIKUBE_IP/ | kubectl apply -f -
# puma deploymentの起動が完了するまで待機
$ kubectl rollout status deploy demoapp-puma
# puma Ingressのエンドポイントをブラウザでオープン
$ open https://demoapp-puma.$(minikube ip).nip.io/
# 作成したAPIオブジェクトを削除
cat *.yaml | kubectl delete -f -
kubectl delete secret demoapp-puma-tls
Makefileを置いてあるので下記のコマンドで代替できます。
$ cd k8s/manifests-step4
# Railsアプリのイメージをビルド
$ make minikube-docker-build
# ダミーのサーバ証明書を作成しSecretオブジェクトとして登録
$ make kubectl-create-secret-tls
# APIオブジェクトを作成
$ make kubectl-apply
# puma deploymentの起動が完了するまで待機
$ make kubectl-rollout-status
# puma Ingressのエンドポイントをブラウザでオープン
$ make open
単にmake
とだけ入力すると順番に全部実行します。
$ make
ダミーのサーバ証明書の作成
HTTPSで接続できるようにするためにはサーバ証明書が必要です。
kube-lego
というモジュールを使えばIngress
とLet’s Encryptを連携して動的に証明書を作成するようなこともできるのですが、
今回はもっとシンプルにopenssl
コマンドで自己署名証明書(いわゆるオレオレ証明書)を作ります。
まず下記のような内容でopensslの設定ファイルを用意します。 Chromeではバージョン58以降、subjectAltName(SAN)が設定されていない証明書はエラー扱いになりました。 下記の設定はSANを設定するためのものです。
# k8s/manifests-step4/openssl.conf
[req]
distinguished_name=req_distinguished_name
req_extensions=v3_req
[v3_req]
basicConstraints=CA:FALSE
keyUsage=nonRepudiation, digitalSignature, keyEncipherment
subjectAltName=@alt_names
[req_distinguished_name]
[alt_names]
DNS.1=${COMMON_NAME}
次に下記のようなコマンドで証明書(server.pem
)と鍵(server.key
)を作成します。
COMMON_NAME=demoapp-puma.$(minikube ip).nip.io
openssl req -new -x509 -nodes -keyout server.key -days 3650 \
-subj "/CN=${COMMON_NAME}" \
-extensions v3_req \
-config <(cat openssl.conf | sed s/\${COMMON_NAME}/$COMMON_NAME/) > server.pem
unset COMMON_NAME
ホスト名は前述の通りdemoapp-puma.$(minikube ip).nip.io/
とします。
$(minikube ip)
部分はminikubeインスタンスの立ち上げごとに変わる可能性があるため、
設定ファイルはテンプレートとして扱い、${COMMON_NAME}
と記述されている部分をsed
コマンドで書き換えています。
内容は下記のコマンドで確認できます。
$ openssl x509 -text < server.pem
(一部抜粋)
Certificate:
Signature Algorithm: sha256WithRSAEncryption
Issuer: CN=demoapp-puma.192.168.64.25.nip.io
Subject: CN=demoapp-puma.192.168.64.25.nip.io
X509v3 extensions:
X509v3 Subject Alternative Name:
DNS:demoapp-puma.192.168.64.25.nip.io
macOSの場合、server.pem
をキーチェーンに追加して信頼するようにしておけばブラウザの警告を抑止できます。
後で忘れずに削除してください。
なお、Step4にもMakefileを置いているので、証明書の作成は下記のコマンドで代替できます。
$ cd k8s/manifests-step4
$ make server.pem
# minikube ipが変わったら必ず作り直すこと
$ make --always-make server.pem
証明書用のSecret
オブジェクトの作成
Ingress
で先ほど作成した証明書と鍵を使うためには、Secret
オブジェクトに登録する必要があります。
これまでに紹介したSecret
オブジェクトのようにYAML形式のマニフェストファイルを書いても良いのですが、
TLS用の証明書と鍵をファイルから登録することについてはkubectl
コマンドがサポートしているため、
下記のように登録します。
kubectl create secret tls demoapp-puma-tls --key server.key --cert server.pem
# または
make kubectl-create-secret-tls
Ingress
オブジェクト
# k8s/manifests-step4/puma-ing.yml
---
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: demoapp-puma
annotations:
kubernetes.io/ingress.allow-http: "false" # HTTPでのアクセスを禁止
# GKEで確保済みの静的IPアドレスを指定する場合は下記のようにする
# kubernetes.io/ingress.global-static-ip-name: rails-k8s-demoapp
spec:
tls:
- hosts:
- demoapp-puma.${MINIKUBE_IP}.nip.io # このTLS証明書で受けるホスト名のリスト
secretName: demoapp-puma-tls # 証明書と鍵のSecretオブジェクトの名前
rules:
- host: demoapp-puma.${MINIKUBE_IP}.nip.io # ホスト名
http:
paths:
- backend:
serviceName: demoapp-puma # バックエンドのServiceの名前とポート
servicePort: 3000
${MINIKUBE_IP}
となっている部分は、kubectl
コマンドに渡す前にsed
コマンドで置換します。
$ export MINIKUBE_IP=$(minikube ip)
$ cat *.yaml | sed s/\${MINIKUBE_IP}/$MINIKUBE_IP/ | kubectl apply -f -
より実践的な課題として、GKEにデプロイした場合の設定方法については下記のドキュメントを参照してください。
Ingress での HTTP 負荷分散の設定 | Kubernetes Engine のドキュメント
ワイルドカードDNS
nip.io
というのはワイルドカードDNSと呼ばれるサービスのドメインの一つです。
Exentrique Solutionsという企業によって運営されています。
下記のようにサブドメインに相当するIPアドレスを動的に返してくれるため、 プライベートアドレスを使ったテストに便利です。
$ dig +short 192.168.64.25.nip.io
192.168.64.25
$ dig +short www.192.168.64.25.nip.io
192.168.64.25
先ごろ発表された Jenkins X でも使われていました。
nip.io
はPowerDNSとカスタムスクリプトの組みわせで実装されているとのことです。
nip.io
自体はOSSではないのですが、Dockerで動作するnip.io
クローンが存在します。
https://github.com/resmo/nip.io
今回のようにTLSの上で通信する場合にはセキュリティリスクは限定的だと思いますが、
DNSを外部に依存したくない場合には自前でnip.io
クローンを運用するかdnsmasqなどの利用を検討してください。
envsubst
openssl.conf
やpuma-ing.yaml
では、$(minikube ip)
に相当する部分をsed
コマンドで環境変数に置換していました。
このようにテキストファイルの一部を環境変数で置換する場合には envsubst
というコマンドを使うとさらに簡単です。
macOSの場合はHomebrewでgettextパッケージでインストールできますが、
パスを通すためにはbrew link
に--force
オプションをつけて実行する必要があります。
$ brew install gettext
$ brew link gettext --force
今回の場合は置換の対象が一つと少なくsed
での代替が容易だったので、
手順全体の依存を減らすためにあえて採用しませんでしたが、
マニフェストを全体をテンプレート化して多数のパラメータを注入できるようにしたいのであれば、
採用を検討しても良いかもしれません。
ただし、そのような目的のためには次回紹介するHelmを使う方がおすすめです。
例えばSecret
のデータエントリはBase64でエンコードする必要がありますが、
envsubst
でそれを実現しようとすると煩雑な手順になりますが、Helmであれば簡単です。
まとめ
下記のオブジェクトについての最低限の説明と、
これらを使ってpuma
、sidekiq
、mysql
、redis
のコンテナをk8s上で管理するための実例を示しました。
Deployment
Service
ConfigMap
Secret
Job
PersistentVolumeClaim
Ingress
k8sに関しては、次のステップとして下記について調べるのが良いと思います。
ConfigMap
やSecret
の内容をボリュームとしてPod
にマウントする方法(MySQLやRedisの設定ファイルの管理など)CronJob
オブジェクトによる定期実行ジョブの定義方法(DBのバックアップなど)- CPUやメモリの上限の設定方法
なお、今回はYAMLファイルとしてマニフェストを管理する方法を示しましたが、 この構成においては下記の課題があります。
- ステージング環境やQA環境など、一部だけ設定を変更した環境を作るのが難しい
Secret
の定義ファイルにBase64エンコードした値を読み書きするのが面倒