Elastic Beanstalk から EKS へ移行した話 (2/3) ~デプロイ編~
こんにちは。
株式会社 POL にてエンジニアをしている山田高寛です。
EKS 移行話の第 2 部を語りたいと思います。
今回は Kubernets リソースのデプロイ編です。
Spinnaker による Deploy
Pod などの Kubernetes リソースのデプロイには Spinnaker を採用しました [1]。Spinnaker は Netflix が開発した Continuous Delivery (CD) プラットフォームです。Spinnaker は AWS のみならず数多くのクラウドプラットフォームに対応しており、更に Kubernetes のデプロイだけではなく、EC2 インスタンスの作成もできます。
Spinnaker の主な機能は複数のデプロイタスク (Stage) をまとめた、それぞれの依存関係を定義したデプロイワークフローを管理・実行することにあります。
上記ワークフローは Web UI 上からも作成できますし、コードとして定義もできます。各種 Stage はデフォルトで下記のようなものが用意されています。
・Kubernetes manifest の適用
・Helm package から Kubernetes manifest の作成
・Manual Approval
・Code Build のトリガー
・条件チェック
デフォルトで用意されている以外にも独自の Stage を作成することもできます。
ワークフローのトリガーは手動でもできますが、トリガーとして Webhook や Pub/Sub、cron などがあります。更にトリガーイベントから渡される Payload を変数としてデプロイワークフロー内のタスクの挙動を変更できます。
Spinnaker ではデプロイ後のリソースの状態確認・管理もできます。
例えば Pod のステータス確認や Pod 内のコンテナの標準出力ログを確認したり、Pod のスケーリングや削除、Deployment のロールバックも Web UI 上で実施できます。また、アプリケーションのバージョンアップ方法も Blue/Green Deployment や Canary Release を実施できます。
正直なところ、まだまだ Spinnaker を触りきれていないので、今後カスタムデプロイタスクの作成などの機能を試していきたいです。実際のデプロイフローや、Spinnaker を使って辛かったところについては後ほど触れたいと思います。
Spinnaker の作成は Halyard というツールを使います。Halyard は Spinnaker を作成するのに必要な設定ファイルの作成を補助したり、実際に Spinnaker を Kubernetes 上なり EC2 上なりにデプロイする CLI ツールです。今回は Halyard を Cloud9 上にインストールして使用しました。また、Halyard が作成した構成ファイルも Cloud9 を通じて Git レポジトリにて管理してあります。
今回 EKS クラスタを 2 つ作成していますが、Spinnaker は複数の Kubernetes クラスタに対してデプロイを実施でき、Spinnaker 自体がリソースを結構使用するので、Spinnaker は 1 つだけ Develop 環境と Staging 環境と同じ EKS クラスタ上に作成しました。Production 環境へのデプロイ時には Spinnaker と Production 環境のクラスタのコントロールプレーンが通信できる必要があるため、Production 環境のクラスタを作成する eksctl で使用する yaml ファイルの publicAccessCIDRs に Spinnaker が利用する IP アドレスを指定しておく必要があります。今回は Spinnaker のコンテナのネットワークをプライベートサブネットにしたかったので、Nat Gateway を利用して IP アドレスを固定化し (Nat Gateway の IP アドレスは Elastic IP しか割り当てられない)、Nat Gateway の IP アドレスを publicAccessCIDRs に指定しました。
少し話がずれるのですが、自分は大学自体 470 という 2 人乗りヨットをやっていたのですが、この Spinnaker と halyard というのがヨットの用語なので驚きました。Spinnaker はヨットにある 3 つの帆のうちの 1 つで、追い風のときにしか出さない帆です。halyard というのは帆を挙げるためのロープで、まさに Spinnaker を挙げる (作成する) のに使うツールなのです。こういうネーミングセンスを自分もできるようになりたいです。
Git ブランチに固有な Develop 環境
移行には直接関係ないのですが、Elastic Beanstalk 時代には Develop 環境がひとつしかなかったため、アプリケーションの動作確認をしたい場合は Develop 環境を譲り合って利用しなければなりませんでした。
機能開発が同時並行したり、動作確認に時間がかかる開発が入った場合には、Develop 環境の解放待ちでリリース時期が遅れたりしていたため開発上の課題となっていました。
そこで Kubernetes の導入に伴って、必要に応じて Develop 環境が作成できるようにして、Develop 環境が混み合ってしまう問題を解決しようとなりました。具体的には、タイトルの通り Git ブランチごとに固有の Develop が作成できるようにしました。
上記が Develop 環境を作成する際のワークフローになります。まず、開発者は GitHub の Pull Request にてラベルを付与します。GitHub レポジトリには GitHub App で作成した bot をインストールしておき、ラベルの付与をもとに CircleCI のワークフロー (パイプライン) をトリガーします。CircleCI では Docker Image の build から ECR 上への push と、アプリケーションの Helm Package の作成から S3 上への保存を行います。S3 に Helm Package がアップロードされると S3 イベントが発火して Amazon SNS に通知され、SNS から更に Amazon SQS へとイベントがキューされます。キューされたイベントは Spinnaker がポーリングし、Develop 環境のデプロイパイプラインをトリガーします。なお、Spinnaker はデフォルトでは AWS の Pub/Sub に対応していないため、手動で Halyard が作成した設定ファイルを編集する必要があります [2]。
Develop 環境は Git ブランチごとに複数作成されますが、Kubernetes の Namespace を使って各ブランチごとにリソースを分離させます。Kubernetes には Namespace という機能があり、これを利用することで単一のクラスタ上でクラスタリソースを分割した仮想クラスタを複数作成できます。Kubernetes には Namespace という機能があり、これを利用することで単一のクラスタ上でクラスタリソースを分割した仮想クラスタを複数作成できます。
また、トラフィックの分離は各ブランチごとにサブドメインが発行され、開発者はブランチ固有のドメインを利用して動作確認します。ブランチごとのドメインの発行は ExternalDNS [3] を利用しています。ExternalDNS とは Kubernetes マニフェストから Route 53 のレコードを操作できるモジュールです。Kubernetes 上に ExternalDNS の Deployment リソースを展開することで利用できます。CircleCI 上で Helm Package を作成するときに ALB Ingress リソースの annotation にブランチに固有なドメインを付与しておくと、External DNS が Route 53 上で A レコードを作成してくれます。
更に、ブランチごとに Database を用意しなければならないので、MySQL コンテナも Namespace ごとに作成しています。データベースの初期化は S3 上にダンプファイルを置いておき、Pod の init container で AWS CLI をつかって S3 からダウンロードして利用しています。本来は EBS CSI ドライバー [4]を利用して、データベース初期化済みの EBS スナップショットから EBS ボリュームを作成して Persistent Volume リソースとして MySQL コンテナにマウントさせることで、初期化時の待ち時間を減らそうとしたのですが、当時は EKS の Kubernetes バージョンが 1.15 が最新でかつ、EKS が Kubernetes のアルファ機能を導入できない [5] ため、Volueme Snapshot が利用できず、諦めていました。現在は EKS の Kubernetes バージョンは 1.17 が最新となっており、1.17 では Volueme Snapshot がベータ機能に昇格したため利用できるようになっているはずです。今後検証してみたいと思います。
Develop 環境が複数立ち上げるようになったため、Develop 環境が立ち上がるにつれ、クラスタリソースが枯渇していきます。そこで Cluster AutoScaler [6] を利用して、EC2 ノードを自動で増強していきます。Cluster Auto Scaler は Pod がキャパシティ不足でスケジューリングされなかったのをトリガーにして、AutoScaling の設定を変更して EC2 ノードをスケールアウトさせます。また、リソース使用量をもとに Pod に対して EC2 ノードが余剰である場合はスケールインを行い、EC2 ノードを最低限必要な台数に自動で調整可能です。ただし、Resoure Request を適切な値にしておかないと、オーバーコミットして Pod が EC2 ノードにスケジューリングされ Cluster Auto Scaler がトリガーされないことがあるので注意が必要です。前回、EKS クラスタの構成のところで話したとおり、Develop 環境の EC2 ノードは Spot Instance であるため、費用は抑えることができます。
動作確認が完了して、Pull Request が無事マージされた場合、マージされたことを GitHub App で作成した bot が検知して対象の Develop 環境を削除します。削除方法は単純に kubectl delete ns コマンドを実行するのですが、コマンドの実行は Lambda が行います。カスタムランタイムを利用して、Lambda が kubectl を利用できるようにしておき、GitHub App から Lambda を呼び出します。kubectl が利用できるカスタムランタイムは AWS から提供されています [7]。
Git Ops の導入
Develop 環境とは異なり、Staging 環境と Production 環境は GitOps [8] でのデプロイ方法を採用しました。GitOps とは eksctl を管理している Weaveworks 社が提唱した思想で、Infrastructure as Code の一種の延長と個人的には捉えています。アプリケーションソースコードの Git レポジトリとは別に、Kubernetes マニフェストを管理する Git レポジトリを用意して、そのレポジトリの状態を Kubernetes クラスタに反映させることがその特徴です。Kubernetes クラスタの状態が Git レポジトリと対応しているため、クラスタ状態の履歴を管理でき、またロールバックも容易に実施できます。
上記のワークフロー図が実際の GitOps による新規アプリケーションのデプロイ手順になります。まず、開発者は GitHub のアプリケーションソースコードの Git レポジトリにて Staging 環境もしくは Production 環境に対応するブランチのコミットを進めます。すると、CircleCI がトリガーされ、Docker Image のビルドと Kubernetes マニフェストを管理するレポジトリに対して Pull Request を作成します。この Pull Request には Pod が利用する Docker Image のタグを変更する内容を含みます。次に、開発者は Kubernetes マニフェストを管理するレポジトリに作成された Pull Request を Approve & Merge します。Pull Request が Merge されると Spinnaker に対して Webhook が発行されます。Spinnaker は Webhook をもとに Kubernetes マニフェストを Staging 環境もしくは Production 環境のクラスタへと適用して、新しいアプリケーションバージョンのデプロイを実施します。
GitOps ではブランチ戦略も考えなければなりません。下記の図は Git ブランチとコミットを用いてデプロイワークフローを表したものです。
まず、Staging 環境用のブランチが release/a.b.c (a.b.c はアプリケーションバージョンで、図上では 1.49.0 → 1.50.0 のバージョンをデプロイすることを前提としている) となり、Production 環境用のブランチが master ブランチです。各機能開発ブランチが release ブランチに merge されたタイミングで CircleCI が稼働します。CircleCI では 上記で述べたように Docker Image のビルドが行われるのと、Kubernetes マニフェストを管理するレポジトリに対して Pull Request を作成します。ここで、Pull Request のブランチをどうするかについて、まず、Kubernetes マニフェストを管理するレポジトリ上でアプリケーションソースコードの Git レポジトリ上の release ブランチと同じ名前のブランチを master ブランチから作成します。次に、作成したブランチに CircleCI のビルド番号を suffix として付与したブランチ名を作成し、そのブランチ上で Kubernetes マニフェストに対する変更点をコミットして、アプリケーションソースコードの Git レポジトリ上の release ブランチと同じ名前の release ブランチに対して作成します。この作成した Pull Request をマージすることで、Webhook が Spinnaker に対して通知され、Spinnaker が Staging 環境の更新を実施します。
Production 環境にリリースしようとなったときは、アプリケーションソースコードの Git レポジトリ上の release/a.b.c ブランチから master ブランチへと merge 操作を実施します。マージされると master ブランチでコミットが進むので、 CircleCI がトリガーされますが、STG 環境の最後の docker イメージをそのまま利用すれば良いので docker ビルドは行わず、Pull Request の作成のみを行います。こうすることで、リリース時間を短縮することが可能です。このとき、Kubernetes マニフェストを管理するレポジトリ上の release/a.b.c から master ブランチに対して Pull Request を作成します。
作成された Pull Request がマージされた段階で、Webhook が Spinnaker に対して通知され、Spinnaker が Production 環境の更新を実施します。
第 2 部はこの辺りにして、次回はメトリクス・ログ収集についてお話したいと思います。
https://note.com/tyrwzl/n/nf6782ba1f422
参考資料
[1] Spinnaker
https://spinnaker.io/
[2] Amazon AWS Pub/Sub - Spinnaker
https://spinnaker.io/setup/triggers/amazon/
[3] kubernetes-sigs/external-dns: Configure external DNS servers (AWS Route53, Google CloudDNS and others) for Kubernetes Ingresses and Services
https://github.com/kubernetes-sigs/external-dns
[4] kubernetes-sigs/aws-ebs-csi-driver: CSI driver for Amazon EBS https://aws.amazon.com/ebs/
https://github.com/kubernetes-sigs/aws-ebs-csi-driver
[5] [EKS] [request]: Allow feature gates to be set on master components · Issue #512 · aws/containers-roadmap
https://github.com/aws/containers-roadmap/issues/512
[6] autoscaler/cluster-autoscaler at master · kubernetes/autoscaler
https://github.com/kubernetes/autoscaler/tree/master/cluster-autoscaler
[7] aws-samples/aws-lambda-layer-kubectl: AWS Lambda Layer with kubectl and Helm
https://github.com/aws-samples/aws-lambda-layer-kubectl
[8] Guide To GitOps
https://www.weave.works/technologies/gitops/