こんにちは。CacooチームのYAMLエンジニアの木村(@cohhei)です。「readiness」は「ready」の名詞形で「レディネス」と読むことをわりと最近知りました。今回はプルリクエスト向けに検証環境が構築される仕組みを作ったので紹介します。
忙しい人のためのざっくりとした説明
- プルリクエストが作られたら自動で検証環境が構築される仕組みをつくりました。
- 環境まるごと作るわけではなく、フロントエンドJavaScript配信用のPodだけに対応しています。
- レビュアーやテスターはクエリパラメーターが追加されたURLにアクセスするだけでその検証環境にアクセスできます。
- プルリクエストをマージする前にそのバージョンを非エンジニアでも試すことができます。
- プルリクエストごとに環境が作られるので、複数の開発プロジェクトが同時に走っているときに便利です。
- コミットしてプルリクエストを作るだけですぐ試せるので、エンジニアとデザイナー・PdMのやりとりが活発になりました。
- KubernetesのDNSやCronJobの仕組みを応用しました。
図1 この記事をわかったつもりになれるポンチ絵
目次
検証環境のつらみ
ここで話題の中心となるのは検証環境です。現場によってはステージング環境と呼ばれることもあると思います。あるいはテスト環境と呼ばれ、ステージング環境とは完全に区別している場合もあります。ここでは以下のような特徴を持つ環境を検証環境と呼ぶことにします。
- 本番環境と(ほぼ)同じ構成だが本番環境とは切り離されている
- 本番環境とは異なるバージョンのアプリケーションが動作している
- 開発者以外もテストやレビューのために利用する
- 検証環境での動作確認を経たバージョンが本番環境へやがてリリースされる
エンジニア以外も利用できて複数人が同じ環境を使えるので、開発プロジェクトでは欠かせない存在。それが検証環境です。
みんなが使える故に生じる問題もあります。みんなが検証のために検証環境のバージョンを自分のコントロール下に置きたくなって要望が衝突してしまう問題です。
開発現場では複数の開発プロジェクトが同時に走っています。ブランチAで機能A、ブランチBで機能Bの開発が進んでいて、developブランチの最新のコミットが検証環境に自動的にデプロイされるとします。
図2-1 検証環境とブランチ
あるとき、マネージャーが言いました。「機能Aの動きを検証環境で確認したい」。ブランチAをdevelopブランチにマージすればいいのですが、ブランチAはまだまだ開発中で、コードレビューもしていないのでマージしたくありません。コードを管理している開発リーダーは一時的にブランチAを検証環境に出すことにしました。
図2-2 検証環境の一時的な変更
あるとき、デザイナーが言いました。「機能Bのデザインを検証環境で確認したい」。機能Bは開発が概ね終わっているので、開発リーダーはブランチBをdevelopブランチにマージしてしまいました。
図2-3 検証環境とトピックブランチのマージ
あるとき、マネージャーが言いました。「機能Aが使えなくなってるんだけど戻せる?」。検証環境にはブランチBをマージしたdevelopブランチのバージョンがデプロイされています。ブランチAもdevelopブランチにマージしたいのですが、コードレビューもまだ済んでいない上、ブランチBの変更とコンフリクトしてしまっています。
図2-4 検証環境変更できません
開発リーダーは暗黒面に堕ちてしまいました。
図2-5 暗黒面に堕ちるフロントエンドリーダーの画像
説明をわかりやすくするため、この例では問題を極端に単純にしています。実際のブランチ管理はもうちょっとちゃんとやっています。暗黒面に堕ちなくても解決策はいくらでもありそうです。しかし、現実の問題はもっと複雑な場合が多いでしょう。
この問題は検証環境がひとつしかないことに起因しています。もし検証環境が複数あったらどうなるでしょうか。あるいはプルリクエストを作るたびに自動で新しい検証環境が作られたらどうなるでしょうか。便利な気がしませんか。だから、作りました。
自動構築の仕組み
ここではPodの自動構築・削除をどのように実現しているかを紹介します。これらはあくまで現時点における暫定的な方法です。もっとうまいやり方があるかもしれませんし、要件が違えば取りうる方法も変わってくるはずです。
概要
「プルリクエストが作られたら自動でKubernetesのPodが増えて明示的にそれにアクセスできるようにする」というのがやりたいことです。あとは無限にPodが増え続けないように作成したPodを消せばよさそうです。自動構築されるのは一部のPodのみなので、namespaceをわける必要はありません。
要件をざっくりとまとめるとこんな感じでしょうか。
- プルリクエストを作成したらCIが自動実行される
- CI(Jenkins)のジョブの中でコンテナイメージのビルドとPodの作成を実行する
- そのPodにアクセスするためのクエリパラメーターを決定する
- そのクエリパラメーターつきのリクエストは2で作成したPodに流す
- Podを一定期間で自動で削除する
図3-1 要件
Cacooの場合、editor-clientというPodがフロントエンド(JavaScript)を持っていて、editor-apiというAPIサーバーがそれを中継しています。プルリクエストに対応したバージョンのコードを持った特殊なeditor-clientを自動作成し、editor-apiがクエリパラメーターによって通常のeditor-clientか特殊なeditor-clientにリクエストを振り分ければよさそうです。
この仕組はあくまでフロントエンドJavaScriptをクエリパラメーターで使い分ける仕組みのため、バックエンドは共通のAPIを使います。
図3-2 APIサーバーによるPodの振り分け
Podのテンプレート
Podの自動作成のためにテンプレートとなるファイルが必要となります。なぜならプルリクエストごとに作られるPodは内包するフロントエンドのコードを除きほとんど同じ役割だからです。
editor-client-tempは、プルリクエスト用Podのテンプレートファイルです。以下にその一部を載せておきます。
apiVersion: v1 kind: Pod metadata: name: editor-client-temp #Pod作成時に変更 labels: app: editor-client-temp spec: hostname: editor-client-temp #Pod作成時に変更 subdomain: editor-client-temp restartPolicy: Never affinity: (省略) containers: - env: (省略) image: (省略) #Pod作成時に変更 imagePullPolicy: Always livenessProbe:(省略) name: editor-client-temp ports:(省略) resources:(省略) startupProbe:(省略) command: ["/usr/bin/timeout"] args: ["30d", "/httpd/entrypoint.sh"]
(省略)と書かれているところは、今回の解説とは関係ありません。お使いの環境やアプリケーションに合わせた設定をしておきましょう。
Pod作成時に変更と書かれている行は、CIによってPodが作成されるときにプルリクエストごとに異なる値に書き換えられます。
このテンプレートではまずmetadataでnameとlabelsを設定しています。ラベルを設定しておくことで、この仕組みで自動作成されたPodをまとめて管理できます。
# プルリクエストで作成されたPodの一覧を表示 kubectl get pods -l app=editor-client-temp
specにはhostnameとsubdomainを設定しています。実際にPodを作成するときはhostnameはプルリクエストごとに異なる値に変更されます。これを設定することで特定のPodにアクセスすることができます。例えば、defaultというnamespace内のexampleというsubdomainとpod-01というhostnameが設定されたPodにアクセスするには、下記のようなドメインを利用することができます。あとはクライアントから直接リクエストを受けるサーバー(ここではeditor-api)側でどのPodを使うかを振り分ければクエリパラメーターでPodを指定する仕組みを作れます。
pod-01.example.default.svc.cluster.local
図4 通常のService経由でのPodへのアクセスとhostnameとsubdomainを利用した特定のPodへのアクセス
コンテナイメージもプルリクエストごとに作られるので、実際に作られるPodではテンプレートとは毎回異なるイメージタグが使われることになります。
hostnameを使ったPodへの アクセスはServiceを経由する必要があるので、あらかじめ作っておきます。下記のファイルを作ってkubectl applyします。
apiVersion: v1 kind: Service metadata: name: editor-client-temp namespace: (省略) spec: selector: app: editor-client-temp type: ClusterIP ports: - name: http protocol: TCP port: (省略) targetPort: http
あとは、このテンプレートをCIから利用できる場所に置いておくことでPodを自動で作成できます。
CIによるPodの作成
テンプレートとなるファイルができたら、それを使ってPodを作成します。kubectl patchはkubernetesのリソースを部分的に変更するためのコマンドです。カレントディレクトリにpod.yamlがあれば下記のコマンドで変更できます。
kubectl patch --local=true –-output yaml –-filename pod.yaml --type json --patch ${JSON} | kubectl apply -f -
–localはリモートではなくローカルのリソースに対してパッチを適用するためのオプションです。今回はkubernetesクラスター上にすでにあるPodに対して変更を加えるのではなく、新規にPodを作成します。なので一旦ローカルに対して結果を出力する必要があります。
–outputは出力先を設定するためのオプションです。yamlを設定することで標準出力にPodのマニフェストファイルがYAML形式で出力されます。ここではパイプでkubectl applyコマンドに流すことで結果をそのまま実行しています。
–filenameはパッチ元となるファイルのパスを指定するためにオプションです。
–typeはパッチタイプを指定するためのオプションです。jsonにすることでJSON Patch(RFC 6902)でのパッチを実行できます。
–patchはパッチの内容を指定するためのオプションです。前節で述べたように、変更箇所は3箇所なので、値は下記のようになるはずです。$tagはプルリクエストごとに固有なIDであればどんな値でも大丈夫です。このタグをクエリパラメーターとして利用します。$imageNameはCIでビルドしたコンテナイメージです。おそらく通常のイメージリポジトリにタグだけ異なる値を付与したものをプッシュすることになると思います。
[ { "op": "replace", "path": "/metadata/name", "value": "editor-client-${tag}" }, { "op": "replace", "path": "/spec/hostname", "value": "${tag}" }, { "op": "replace", "path": "/spec/containers/0/image", "value": "${imageName}" } ]
kubectl patchによるKubernetesリソースの変更についての詳細は公式ドキュメントを参照してください。
このコマンドでPodを作成できることがわかりました。ただ、このままだとあらかじめkubectlをセットアップしておいたマシン上でしか実行できません。どのKubernetesクラスターに接続するかの情報もありませんし、クレデンシャル情報もありません。
Kubernetesクラスターにアクセスするためのアカウント(Service Account)を作成し、そのトークンを使うようにしましょう。
Service Account とトークンの取得
KubernetesにはService Accountという仕組みがあり、スクリプトやPod内部からKubernetesを操作するための権限を管理できます。
図5 Service Accountを利用した認証とRoleによる権限の制限
ここでは、CI上でPodを作成するためのeditor-client-creatorというService Accountを作成し、そのアカウントに対してPodの取得や作成などの権限を与えます。CI上ではそのアカウントのトークンを使うことでkubectl patchなどのコマンドが実行可能になります。
Service Accountについての詳細はドキュメントを参照してください。
下記のファイルをkubectl applyしてService AccountとRole、Role Bindingを作成しましょう。
--- apiVersion: v1 kind: ServiceAccount metadata: name: editor-client-creator namespace: (省略) --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: editor-client-creator-role namespace: (省略) rules: - apiGroups: - "" resources: - pods verbs: - get - list - create - delete - patch --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: editor-client-creator-role-binding namespace: (省略) subjects: - kind: ServiceAccount name: editor-client-creator namespace: (省略) roleRef: kind: Role name: editor-client-creator-role apiGroup: rbac.authorization.k8s.io
Service Accountのトークン情報を取得するには、まず下記のコマンドを実行してトークンの名前(実体はSecret)を取得します。
kubectl describe serviceaccount editor-client-creator
実行すると次のような情報が得られるので、TokensにあるSecret名を使います。
Name: editor-client-creator Namespace: cacoo Labels: argocd.argoproj.io/instance=dev2-editor-client Annotations: <none> Image pull secrets: <none> Mountable secrets: ${Secret名} Tokens: ${Secret名} Events: <none>
Secretの中身を見るには次のコマンドを実行します。
kubectl describe secrets ${Secret名}
そこで得たトークンをCIのジョブから呼び出せる場所に置いておけば、CI上からkubectlコマンドを実行できるようになります。ヌーラボではJenkinsを使っているので、Jenkins Credentialsの仕組みを利用して登録しました。
前節のコマンドに下記のオプションを追加すると、作成したService Accountの権限で実行できます。$TOKENは先程取得したトークン、$SERVERはKubernetesクラスターのAPIサーバーのURL、$NAMESPACEはPodを作成するnamespaceが入ります。
--token=${TOKEN} --server ${SERVER} --namespace ${NAMESPACE} --insecure-skip-tls-verify=true
CIの実行環境に対して永続的にService Accountの情報を残しておいて–tokenや–serverオプションを省略できるようにしてもいいのですが、ここではアカウント情報をCI環境に残したくなかったのでコマンド実行時に毎回トークンを渡すようにしています。
すべてをまとめると次のようなコマンドになります。なっげぇ。
kubectl \ --token=${TOKEN} \ --server ${SERVER} \ --namespace ${NAMESPACE} \ --insecure-skip-tls-verify=true \ patch \ --local=true \ –-output yaml \ –-filename pod.yaml \ --type json \ --patch '[{"op":"replace","path":"/metadata/name","value":"editor-client-${tag}"},{"op":"replace","path":"/spec/hostname","value":"${tag}"},{"op":"replace","path":"/spec/containers/0/image","value":"${imageName}"}]' \ | kubectl \ --token=${TOKEN} \ --server ${SERVER} \ --namespace ${NAMESPACE} \ --insecure-skip-tls-verify=true \ apply -f -
いざ、実行!
さて、準備は整いました。CIからPodを作成してみましょう。ここまでの手順に間違いがなければPodは作成されているはずです。
$ k get pods -l app=editor-client-temp NAME READY STATUS RESTARTS AGE editor-client-1234 1/1 Running 0 3m
次に一定時間後にPodが意図通り終了するかどうかを確認しましょう。テンプレートファイルを変更して、timeoutコマンドの時間オプションを短い期間に変更して再実行してみましょう。
apiVersion: v1 kind: Pod metadata: name: editor-client-temp labels: app: editor-client-temp spec: hostname: editor-client-temp subdomain: editor-client-temp restartPolicy: Never affinity: (省略) containers: - env: (省略) image: (省略) imagePullPolicy: Always livenessProbe:(省略) name: editor-client-temp ports:(省略) resources:(省略) startupProbe:(省略) command: ["/usr/bin/timeout"] args: ["30d", "/httpd/entrypoint.sh"] #30d を 1m などに変更
1分ほど待ってPodが消えているかを確認してみます。
$ k get pods -l app=editor-client-temp NAME READY STATUS RESTARTS AGE editor-client-1234 0/1 Error 0 3m
おや、なんか残ってしまっていますね。終了したPodを削除するジョブを定期実行させることで、プロセスが終了したPodを自動削除します。KubernetesのCronJobを利用します。
CronJob によるお掃除
KubernetesのCronJobはJobの作成をスケジューリングできる機能です。そのJob内でPodの削除を実行できるようにします。
Jobによって作成されるPodに対してPod操作の権限を付与することでJob内でkubectl deleteコマンドを実行できます。
図6 pod-cleanerによるPod削除のイメージ
まずは例によってPod削除のためのService Account(pod-cleaner)とRole(pod-cleaner-role)を作成します。
apiVersion: v1 kind: ServiceAccount metadata: name: pod-cleaner namespace: cacoo --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: pod-cleaner-role namespace: cacoo rules: - apiGroups: - "" resources: - pods verbs: - list - delete --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: pod-cleaner-role-binding namespace: cacoo subjects: - kind: ServiceAccount name: pod-cleaner namespace: cacoo roleRef: kind: Role name: pod-cleaner-role apiGroup: rbac.authorization.k8s.io
次にCronJob(pod-cleaner)を作成します。scheduleにおなじみのフォーマットで実行したいタイミングを書けば、あとはJobが実行されます。PodのspecにあるserviceAccountNameに先程作成したService Accountを指定すれば、権限が付与されます。
プルリクエストごとに自動作成されるPodにはラベルが付与されているので、–selectorオプションでapp=editor-client-tempを設定すれば、他のPodには影響を与えません。また、–field-selector status.phase!=Runningで停止したPodのみ削除されます。
apiVersion: batch/v1
kind: CronJob
metadata:
name: pod-cleaner
spec:
schedule: "0 0 * * *"
jobTemplate:
spec:
template:
metadata:
labels:
app: pod-cleaner
spec:
containers:
- name: pod-cleaner
image: bitnami/kubectl:1.20
command: ["/opt/bitnami/kubectl/bin/kubectl"]
args:
["delete", "pods", "--selector", "app=editor-client-temp", "--field-selector", "status.phase!=Running"]
restartPolicy: Never
serviceAccountName: pod-cleaner
以上の仕組みを構築し、プルリクエストごとにPodが作成され、一定期間後に削除されるはずです。また、hostnameによるPodへの振り分けを実装しておけば、クエリパラメーターで利用する環境を変更できるはずです。つまり、プルリクエストごとの検証環境が実現します。
うれしさ
この仕組みのおかげで開発者が簡単に検証環境を作ることができるようになりました。しかも、その環境をURLをコピペするだけで簡単にチームメンバーに共有できます。対応しているのは一部のフロントエンドだけですが、Cacooチームではそこが最も頻繁に、多くのエンジニアによって変更が加えられます。つまり、コードのブランチ管理が煩雑になりがちで、かつレビューもたくさん必要な箇所でもあります。そこにこういった自動構築の仕組みを入れることのインパクトは大きかったようです。開発リーダーも暗黒面に堕ちないですみそうです。
実際、チームメンバーから多くの反響が寄せられました。特にフロントエンドエンジニアから大変好評です。以下は実際にいただいた意見です。
- 「動作検証がスムーズになった」
-
- そのための仕組みですからね。いい感じになってよかったです。
-
- 「試してみたけどめっちゃいいですねーこれ」
-
- いい感じにできてよかったです。
-
- 「追加コミットしたら新しく作られるっぽいのも更にいいですねー」
-
- PRのトピックブランチに追加コミットをプッシュすると、古いバージョンのPodが削除されて更新するようにしてます。いい感じです。
-
- 「oh my god YES」
-
- 海外メンバーに「検証環境つらいときあるよね。自動構築の仕組み作ったよ」と伝えたら「オーマイ」と言われてしまいました。
-
- 「I ❤️ you Kimura」
-
- Me too.
-
- 「❤️」
-
- 弊チームは愛にあふれています。
-
- 「Bravissimo!!! 」「bravissimo veramente」
-
- Grazie.
-
思っていたより開発チームから反響があって、「作ってよかった」と思えました。すぐに使ってもらえてるようで、止まったりバグがあるとすぐに「使えなくなってる?どうして?」と連絡が来るようになりました。申し訳なく思いつつも、逆に使ってもらえてる感を感じられて嬉しかったのを覚えています。
おわりに
Cacooの検証環境における問題とPodの自動構築の仕組みについてまとめました。読み手のみなさまの開発チームの改善の一助になってくれれば大変うれしいです。最後まで読んでいただきありがとうございました。
今回の一連の仕組みを構築するにあたって相談に乗っていただき、様々なKubernetesの知見を共有してくださったSREの中原さんと、CacooチームのKubernetes環境を普段から改善し続けてくださっている同じくSREの渡邉さんに感謝します。