Railsアプリ開発のためのDocker/Kubernetes入門5 Kubernetes応用編

RailsアプリケーションをKubernetes(以後、k8s)で運用できるようにするための手順を書きます。 この記事はシリーズ連載記事の第五回です。

前回の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

前提

  • macOSでの作業を前提としています。
  • 使用したツールのバージョンなどは 初回 の記事を参照してください。
  • ツールのインストール手順は 第三回 の記事を参照してください。

準備

まずサンプルアプリのコードをチェックアウトして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

サンプルコードは全て下記のディレクトリにあります。

k8s/manifests-step2/

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:setupJobとして定義し、アプリを最初にk8sにデプロイした時に一度だけ実行する。
  • rails db:migratepumaの起動前に毎回実行する。(複数の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

内容はpumasidekiqDeploymentと似ています。 まず同じようにdemoappイメージを指定します。 また、.spec.template以下の内容もほぼ同じですが、livenessProbeなどの項目が無い分Deploymentよりもシンプルな内容になります。

Job固有の設定値について簡単に補足します。

  • backoffLimitのデフォルト値は6です。4を指定しているのは単にサンプルとして例示するためで深い意味はありません。
  • restartPolicyOnFailureNeverを指定できます。 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を実行するとpumaDeploymentが更新され、 ローリングアップデートが開始します (puma=demoapp:0.0.2pumaは、puma-deploy.yamlで定義したコンテナのnameです)。

この際、puma-deploy.yamlreplicasが2だと下記のような動作になり、rails db:migrateの競合は起きません。

  1. 新しいPodを一つ起動開始
  2. 1のPodが起動完了し、古いPodのうち一つが停止し、さらに新しいPodを一つ起動開始
  3. 2のPodが起動完了し、残りの古いPodが停止

replicasが4だと下記のような動作になり、高い確率でrails db:migrateが競合します。(puma-deploy.yamlを編集して試してみてください)

  1. 古いPodを一つ停止すると同時に新しいPodを二つ起動開始
  2. 1のPod二つがほぼ同時に起動完了し、古いPodがさらに停止され、新しいPodがさらに二つ起動開始
  3. 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%です。 これについては本ドキュメントではこれ以上説明しないので、詳細は下記の資料を参照してください。

Writing a Deployment Spec

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秒で足りない場合は、 Deploymentspec.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)

サンプルコードは全て下記のディレクトリにあります。

k8s/manifests-step3/

Step2の時点ではMySQLとRedisのデータ領域が永続化されていないため、 何らかの障害でPodが停止するとDBの内容が全て消えるという制約があります。

Step2のマニフェストでAPIオブジェクト一式を作成した後、mysqlPodkubectl 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のボリュームタイプはプラグインとして実装されていて、すでに主要なネットワークストレージとパブリッククラウドのストレージサービスに対応しています。

Types of Persistent Volumes

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.typeRecreateを指定している点に注意してください。 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オブジェクトがあります。 このオブジェクトはステートフルなアプリケーションを管理するためのものと位置付けられています。

StatefulSets

そのため、一見するとMySQLやRedisのようなサービスを管理する際にはDeploymentではなくStatefulSetを使うべきではと思われるのですが、 StatefulSetが必要になるのは Set の名が示す通りMySQLのマスター/スレーブ構成など複数のコンテナでクラスタを組む場合です。

Step3の例のように単一のコンテナによるステートフルなアプリケーションの場合、 Deploymentを使って.spec.strategy.typeRecreateにするだけで十分です。

ということが公式のドキュメントにも書いてあるのでリンクを貼っておきます。
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

サンプルコードは全て下記のディレクトリにあります。

k8s/manifests-step4/

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 controllers

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という企業によって運営されています。

http://nip.io/

下記のようにサブドメインに相当する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.confpuma-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であれば簡単です。

まとめ

下記のオブジェクトについての最低限の説明と、 これらを使ってpumasidekiqmysqlredisのコンテナをk8s上で管理するための実例を示しました。

  • Deployment
  • Service
  • ConfigMap
  • Secret
  • Job
  • PersistentVolumeClaim
  • Ingress

k8sに関しては、次のステップとして下記について調べるのが良いと思います。

  • ConfigMapSecretの内容をボリュームとしてPodにマウントする方法(MySQLやRedisの設定ファイルの管理など)
  • CronJobオブジェクトによる定期実行ジョブの定義方法(DBのバックアップなど)
  • CPUやメモリの上限の設定方法

なお、今回はYAMLファイルとしてマニフェストを管理する方法を示しましたが、 この構成においては下記の課題があります。

  • ステージング環境やQA環境など、一部だけ設定を変更した環境を作るのが難しい
  • Secretの定義ファイルにBase64エンコードした値を読み書きするのが面倒

これらの課題に対するアプローチとして、次回Helm を使う方法を紹介します。