CircleCIでローリングデプロイを設定する方法
Content Marketing Manager
ローリングデプロイとは、実行中のアプリケーションインスタンスをバッチ単位で更新し、アプリケーションがトラフィックを処理し続けている間に古いインスタンスを新しいインスタンスに置き換えていくデプロイ手法です。
この考え方は、アプリケーションの複数インスタンスを実行できるあらゆるシステムに当てはまりますが、Kubernetesではデフォルトのデプロイ戦略として組み込まれています。Kubernetesは、置き換え先のPodが設定済みのReadiness Probeに合格してから古いPodを終了させるため、準備ができていないインスタンスにリクエストが届くことはありません。
CI/CDワークフロー(コードの変更を自動的にビルド、テスト、デプロイする仕組み)では、ローリングデプロイは最終ステップとして自然に組み込めます。CircleCIはこのパイプラインを自動化します。コードがリポジトリにプッシュされると、CircleCIがイメージをビルドし、Kubernetesマニフェストを適用して、ロールアウトを監視します。
このチュートリアルでは、CircleCIパイプライン内で自動化したKubernetesのローリングデプロイを設定する手順を説明します。ゼロダウンタイムデプロイのためにmaxSurgeとmaxUnavailableを設定したKubernetesマニフェスト、イメージをビルドしてデプロイを適用するCircleCIの設定、デプロイ追跡、ロールバック、デプロイ後の監視までを扱います。
前提条件
始める前に、以下を用意してください。
- CircleCIアカウント(無料プランで利用できます)
- Kubernetesクラスター(このチュートリアルではGKEを使用しますが、EKS、AKS、またはminikubeやkindなどのローカルクラスターでも動作します)
- クラスターに接続できるようにインストール・設定済みの
kubectl - サンプルイメージをビルドするためにローカルにインストールしたDocker
- コンテナレジストリとして使用するDocker Hubアカウント
- GitHubからクローンしたサンプルリポジトリ:CIRCLECI-GWP/kubernetes-rolling-deployment-circleci
GKEクラスターの作成方法は、Google Cloudのクイックスタートガイドで説明されています。リポジトリを接続して最初のパイプラインを実行する手順は、CircleCI入門を参照してください。
Kubernetesにおけるローリングデプロイの仕組み
Kubernetesは、Deploymentの更新(新しいイメージタグ、環境変数の変更、Podテンプレートへのあらゆる変更)を受け取ると、新しいReplicaSetを作成します。続いてDeploymentコントローラーが両方のReplicaSetを同時に実行し、新しいReplicaSetを段階的にスケールアップしながら、古いReplicaSetをスケールダウンしていきます。そのペースを制御するのがmaxSurgeとmaxUnavailableという2つのパラメーターです。このチュートリアルでは、ステップ2でこれらを設定します。
Kubernetesは、古いPodを終了させる前に、置き換え先のPodがReadiness Probeに合格するのを待ちます。Probeに一向に合格しない場合、ロールアウトは停止し、古いPodがトラフィックの処理を続けます。ロールアウト中は両方のバージョンが並行して稼働するため、変更には後方互換性が必要です。
ロールバックも逆方向に同じ仕組みで動作します。Kubernetesは、現在のReplicaSetをスケールダウンしながら、以前のReplicaSetをスケールアップし直します。
ローリングデプロイでは、トラフィックの振り分けを制御することはできません。Readiness Probeに合格した新しいPodは、すぐにトラフィックを受け取り始めます。割合ベースでトラフィックを制御する必要がある場合は、カナリアリリースのほうが適しています。比較については、カナリアリリースとブルーグリーン・デプロイメント: ダウンタイム短縮に最適なデプロイ戦略は?を参照してください。
ステップ1:サンプルアプリケーションをビルドしてコンテナ化する
サンプルアプリケーションは、依存関係のないNode.js製HTTPサーバーです。次の3つのエンドポイントを提供します。
GET /は、実行中のバージョン、Podのホスト名、タイムスタンプを表示するスタイル付きのHTMLステータスページを返します。GET /apiは、同じ情報をJSONで返します。GET /healthは、ReadinessおよびLiveness Probe用に{"status":"ok"}を返します。
バージョンはAPP_VERSION環境変数から取得され、この値はKubernetesマニフェストによってデプロイ時に設定されます。ロールアウト中に2つのPodのGET /apiにアクセスすると、異なるバージョンの値が返るため、更新の様子を確認できます。
ソースコード全体はサンプルリポジトリにあります。Dockerfileはnode:20-alpineを使用し、非rootユーザーで実行します。
FROM node:20-alpine
WORKDIR /app
COPY package.json .
COPY index.js .
EXPOSE 3000
USER node
CMD ["node", "index.js"]
プッシュする前に、ローカルでビルドしてテストします。
docker build -t rolling-tutorial-app:local ./app
docker run -p 3000:3000 rolling-tutorial-app:local
# In another terminal:
curl localhost:3000/api
# {"version":"1.0.0","hostname":"...","timestamp":"..."}
curl localhost:3000/health
# {"status":"ok"}
ステップ2:Kubernetesマニフェストを作成する
Deploymentマニフェスト
k8s/deployment.ymlのDeploymentマニフェストでは、CircleCIパイプラインがデプロイ時に値を埋め込むenvsubstのプレースホルダーを使用しています。envsubstはファイル内の${VARIABLE}参照を環境変数の値に置き換えるため、パイプラインはマニフェストを適用する前にイメージタグとDocker Hubのユーザー名を注入できます。
apiVersion: apps/v1
kind: Deployment
metadata:
name: rolling-tutorial-app
namespace: default
annotations:
circleci.com/project-id: ${CIRCLE_PROJECT_ID}
circleci.com/operation-timeout: 10m
labels:
app: rolling-tutorial-app
circleci.com/component-name: rolling-tutorial-app
circleci.com/version: ${IMAGE_TAG}
spec:
replicas: 3
selector:
matchLabels:
app: rolling-tutorial-app
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: rolling-tutorial-app
circleci.com/component-name: rolling-tutorial-app
circleci.com/version: ${IMAGE_TAG}
spec:
containers:
- name: rolling-tutorial-app
image: ${DOCKERHUB_USERNAME}/rolling-tutorial-app:${IMAGE_TAG}
ports:
- containerPort: 3000
env:
- name: APP_VERSION
value: "${IMAGE_TAG}"
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 15
periodSeconds: 10
押さえておきたい主な設定は次のとおりです。
maxUnavailable: 0とmaxSurge: 1:置き換え先の準備が整うまでPodがオフラインになることはなく、追加で実行されるPodは一度に1つだけです。レプリカが3つの場合、ロールアウト全体は3サイクルで完了します。readinessProbe:Podにトラフィックをルーティングするかどうかを制御します。Kubernetesは、新しいPodがこのチェックに合格するまで古いPodを終了させません。initialDelaySecondsがアプリの起動時間に対して短すぎると、ロールアウトが停止します。livenessProbe:実行中なのに応答しなくなったPodを検出します。チェックに失敗すると、KubernetesがPodを再起動します。circleci.com/*のラベルとアノテーション:CircleCIのデプロイ追跡機能は、実行中のPodからこれらを読み取ります。Kubernetesが変更不可として扱うspec.selector.matchLabelsには含めないでください。詳細なリファレンスはコンポーネント設定のドキュメントを参照してください。
Serviceマニフェスト
k8s/service.ymlのServiceは、app: rolling-tutorial-appセレクターに一致するPodにトラフィックをルーティングする、安定したネットワークエンドポイントを作成します。
apiVersion: v1
kind: Service
metadata:
name: rolling-tutorial-app
namespace: default
spec:
selector:
app: rolling-tutorial-app
ports:
- port: 80
targetPort: 3000
type: LoadBalancer
LoadBalancerタイプは外部IPをプロビジョニングするため、クラスターの外部からアプリケーションにアクセスできるようになります。ローリングアップデート中、Serviceは古いPodと新しいPodの両方にトラフィックをルーティングし、バージョンを区別しません。
ステップ3:CircleCIパイプラインを作成する
リポジトリを接続する
CircleCIで、左サイドバーの Projects をクリックし、リポジトリを見つけて Set Up Project をクリックします。既存の.circleci/config.ymlを使用するには、Fastest オプションを選択します。詳しい説明はCircleCI入門を参照してください。
コンテキストを作成する
パイプラインには、リポジトリに含めるべきではないシークレットが必要です。CircleCIのコンテキストには、実行時にジョブから利用できる環境変数を保存できます。rolling-tutorialという名前のコンテキストを作成しましょう。
- CircleCIで Organization Settings > Contexts に移動します
- Create Context をクリックし、
rolling-tutorialという名前を付けます - 次の3つの環境変数を追加します
| 変数 | 値 |
|---|---|
DOCKERHUB_USERNAME |
Docker Hubのユーザー名 |
DOCKERHUB_PASSWORD |
Docker Hubのパスワードまたはアクセストークン |
KUBECONFIG_DATA |
クラスター用のBase64エンコード済みkubeconfig |
GKEでKUBECONFIG_DATAを生成するには、次のコマンドを実行します。
gcloud container clusters get-credentials <cluster-name> --zone <zone> --project <project-id>
cat ~/.kube/config | base64 | tr -d '[:space:]'
EKS、AKS、その他のプロバイダーを使用する場合は、各プロバイダーのドキュメントに従ってkubectlのアクセスを設定し、生成されたkubeconfigを同じ方法でエンコードします。
パイプラインの設定
パイプラインには2つのジョブがあります。build-and-pushはDockerイメージをビルドしてDocker Hubにプッシュし、deployはマニフェストをクラスターに適用してロールアウトを監視します。
version: 2.1
jobs:
build-and-push:
docker:
- image: cimg/base:stable
steps:
- checkout
- setup_remote_docker:
docker_layer_caching: true
- run:
name: Build Docker image
command: |
docker build -t $DOCKERHUB_USERNAME/rolling-tutorial-app:$CIRCLE_SHA1 ./app
- run:
name: Push to Docker Hub
command: |
echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
docker push $DOCKERHUB_USERNAME/rolling-tutorial-app:$CIRCLE_SHA1
deploy:
docker:
- image: cimg/base:stable
steps:
- checkout
- run:
name: Install kubectl
command: |
KUBECTL_VERSION=$(curl -L -s https://dl.k8s.io/release/stable.txt)
curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/kubectl
- run:
name: Configure kubeconfig
command: |
mkdir -p ~/.kube
echo "$KUBECONFIG_DATA" | tr -d '[:space:]' | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
- run:
name: Install envsubst
command: sudo apt-get update -qq && sudo apt-get install -y gettext-base
- run:
name: Apply Kubernetes manifests
command: |
export IMAGE_TAG=$CIRCLE_SHA1
envsubst < k8s/deployment.yml | kubectl apply -f -
kubectl apply -f k8s/service.yml
- run:
name: Monitor rollout
command: kubectl rollout status deployment/rolling-tutorial-app --timeout=5m
workflows:
build-deploy:
jobs:
- build-and-push:
context: rolling-tutorial
- deploy:
requires:
- build-and-push
context: rolling-tutorial
filters:
branches:
only: main
いくつか注意点があります。
- イメージタグとしての
$CIRCLE_SHA1:Gitのコミットハッシュ全体をタグにすることで、すべてのイメージを、それを生成したコミットまで正確にたどれます。 deployはmainでのみ実行:プルリクエストのビルドではbuild-and-pushが実行されますが、デプロイはトリガーされません。kubectl rollout status --timeout=5m:ロールアウトが完了するかタイムアウトするまで待機します。新しいPodがReadiness Probeに合格しなければ、コマンドは0以外の終了コードで終了し、ジョブは失敗します。
次のステップでは、CircleCIのDeploysダッシュボードでデプロイを追跡するために、この設定にデプロイマーカーを追加します。
ステップ4:可視化のためにデプロイマーカーを追加する
デプロイマーカーは、各デプロイのメタデータ(どのバージョンを、いつ、どのパイプラインとコミットからデプロイしたか)を記録し、CircleCIのDeploysダッシュボードにコンポーネントと環境ごとのタイムラインとして表示します。
プロジェクトを登録する
マーカーを記録するには、事前にプロジェクトをDeploys機能に接続しておく必要があります。CircleCIでプロジェクトを開き、左サイドバーの Deploys をクリックします。セットアップウィザードの案内に従って、GitHub Appとの連携をインストールし、最初の環境を作成します。この作業はプロジェクトごとに1回だけ必要です。
ウィザードにはAIアシストのオプションがあり、既存の.circleci/config.ymlを読み取って、デプロイマーカーのコマンドを追加する場所を提案してくれます。変更内容はプルリクエストとして作成されます。このツールは任意で自分の設定ファイルに対して実行できます。このチュートリアルの例でも使用しました。
マーカーのステップを追加する
.circleci/config.ymlのdeployジョブに、次のステップを追加します。release planステップはデプロイ作業の前に、release updateとrelease logのステップはロールアウトの完了後に配置します。
# Add before "Install kubectl"
- run:
name: Plan deployment
command: |
circleci run release plan "${CIRCLE_JOB}" \
--environment-name="production" \
--component-name="rolling-tutorial-app" \
--target-version="$CIRCLE_SHA1"
# Add after "Monitor rollout"
- run:
name: Update deployment status to running
command: circleci run release update "${CIRCLE_JOB}" --status=RUNNING
- run:
name: Log deploy marker
command: |
circleci run release log \
--component-name=rolling-tutorial-app \
--environment-name=production \
--target-version=$CIRCLE_SHA1
- run:
name: Update deployment status to success
command: circleci run release update "${CIRCLE_JOB}" --status=SUCCESS
when: on_success
- run:
name: Update deployment status to failed
command: circleci run release update "${CIRCLE_JOB}" --status=FAILED
when: on_fail
release planは、作業を始める前にデプロイを「計画済み」として登録します。ロールアウト後、release update --status=RUNNINGでデプロイを実行中としてマークし、続いてrelease logでイベントを記録します。最後に、ジョブの成否に応じてrelease updateでSUCCESSまたはFAILEDを設定し、ライフサイクルを完了させます。--component-nameと--environment-nameはダッシュボードでのデプロイのグループ化方法を制御し、--target-versionは個々のデプロイを識別します。
デプロイマーカーのドキュメントでは、ライフサイクル全体を追跡するrelease plan/release updateと、シンプルにイベントを記録するrelease logの両方のアプローチを詳しく説明しています。
何回かデプロイすると、Deploysダッシュボードにproduction環境のrolling-tutorial-appコンポーネントのタイムラインが表示されます。各エントリーは、対応するパイプラインとコミットにリンクしています。同じ画面から直接ロールバックをトリガーすることもできます。
デプロイマーカーを含む完全な.circleci/config.ymlは、サンプルリポジトリにあります。
ステップ5:ローリングアップデートをトリガーする
mainに変更をプッシュして、ローリングアップデートの動作を確認しましょう。app/index.jsの目に見える部分(タイトルやバージョン文字列)を編集し、コミットしてプッシュします。
git add app/index.js
git commit -m "Update app title to trigger rolling update"
git push origin main
CircleCIで、プロジェクトの Pipelines ビューを開きます。build-deployワークフローが自動的に開始されます。両方のジョブが緑色(成功)で完了するはずです。
パイプラインが完了したら、Serviceの外部IPを取得し、新しいバージョンが稼働していることを確認します。
kubectl get svc rolling-tutorial-app
# Note the EXTERNAL-IP column
curl http://<EXTERNAL-IP>/api
# {"version":"<commit-sha>","hostname":"rolling-tutorial-app-...","timestamp":"..."}
curl http://<EXTERNAL-IP>/health
# {"status":"ok"}
外部IPがわかれば、サービスが稼働しているかを確認できます。デプロイが成功したことを示すシンプルなページが表示されるはずです。
次回変更をプッシュしたときにロールアウトをリアルタイムで確認するには、次のコマンドを実行します。
kubectl rollout status deployment/rolling-tutorial-app
このコマンドは、Podが作成・終了されるたびにローリングアップデートの各段階を出力し、最後にdeployment "rolling-tutorial-app" successfully rolled outと表示します。
ロールアウトが停止した場合は、kubectl describe pod <new-pod-name>を実行し、EventsセクションでReadiness Probeの失敗を確認します。PodがCrashLoopBackOffの状態になっている場合は、kubectl logs <pod-name>を実行してクラッシュの原因を確認します。maxUnavailable: 0を設定しているため、どちらの場合も古いPodがトラフィックの処理を続けます。
ロールアウトの検証とトラブルシューティング
すべてのPodが実行中になっても、ロールアウトが完了したとは限りません。デプロイ前後5分間のエラー率とレイテンシーのパーセンタイル(p50、p95、p99)を比較すると、機能テストでは見逃してしまうリグレッションを検出できます。CircleCIのDeploysダッシュボードにはデプロイのタイミングが記録されるため、リリースとPrometheus、Grafana、Datadogでのメトリクスの変化を簡単に関連付けられます。
Kubernetes側では、デプロイ後にPodの再起動を確認します。
kubectl get pods -l app=rolling-tutorial-app
RESTARTSの数が増えている場合、Podがクラッシュループに陥っています。kubectl describe pod <pod-name>を実行してEventsセクションでProbeの失敗やOOM Killを確認するか、kubectl logs <pod-name>を実行してアプリケーションのクラッシュ出力を確認します。
よくあるロールアウトの問題は次のとおりです。
- ロールアウトが進行中のまま止まる:多くの場合、Readiness Probeの設定ミスが原因です。パスが間違っている、ポートが一致していない、
initialDelaySecondsが短すぎる、といったケースです。kubectl describe pod <new-pod-name>でProbeの失敗イベントを確認できます。 - CrashLoopBackOff:新しいイメージが起動時にクラッシュしています。
maxUnavailable: 0を設定していれば、新しいPodが失敗している間も古いPodがトラフィックの処理を続けます。kubectl logs <pod-name>でクラッシュの原因を確認してください。 - 複数のバージョンが混在してリクエストを処理する:ローリングアップデート中は、古いPodと新しいPodの両方が同時にトラフィックを受け取ります。これは想定どおりの動作ですが、変更に後方互換性がない場合(APIフィールドの名前変更、レスポンスキーの削除、互換性のないセッション形式など)はエラーの原因になります。ローリングデプロイでは、変更に後方互換性があることが前提です。この制約を満たせない変更には、ブルーグリーン・デプロイメントのほうが適しています。
ステップ6:ロールバックパイプラインを設定する
Kubernetes Deploymentのロールバックでは、同じローリングアップデートのプロセスを通じて以前のReplicaSetが再びアクティブになります。.circleci/rollback.ymlのロールバックパイプラインを使うと、CircleCIのDeploysダッシュボードからこの操作を自動化できます。
version: 2.1
jobs:
rollback:
docker:
- image: cimg/base:stable
environment:
COMPONENT_NAME: << pipeline.deploy.component_name >>
ENVIRONMENT_NAME: << pipeline.deploy.environment_name >>
TARGET_VERSION: << pipeline.deploy.target_version >>
steps:
- run:
name: Install kubectl
command: |
KUBECTL_VERSION=$(curl -L -s https://dl.k8s.io/release/stable.txt)
curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
chmod +x kubectl && sudo mv kubectl /usr/local/bin/kubectl
- run:
name: Configure kubeconfig
command: |
mkdir -p ~/.kube
echo "$KUBECONFIG_DATA" | tr -d '[:space:]' | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
- run:
name: Plan rollback release
command: |
circleci run release plan "${CIRCLE_JOB}" \
--component-name=${COMPONENT_NAME} \
--environment-name=${ENVIRONMENT_NAME} \
--target-version=${TARGET_VERSION} \
--rollback
- run:
name: Roll back deployment
command: kubectl rollout undo deployment/rolling-tutorial-app
- run:
name: Monitor rollback rollout
command: kubectl rollout status deployment/rolling-tutorial-app --timeout=5m
- run:
name: Update rollback status to SUCCESS
command: circleci run release update "${CIRCLE_JOB}" --status=SUCCESS
when: on_success
- run:
name: Update rollback status to FAILED
command: circleci run release update "${CIRCLE_JOB}" --status=FAILED
when: on_fail
cancel-rollback:
docker:
- image: cimg/base:stable
steps:
- run:
name: Update rollback status to CANCELED
command: circleci run release update "${CIRCLE_JOB}" --status=CANCELED
workflows:
rollback:
jobs:
- rollback:
context: rolling-tutorial
- cancel-rollback:
context: rolling-tutorial
requires:
- rollback:
- canceled
いくつか注意点があります。
pipeline.deploy.*パラメーター:CircleCIがDeploysダッシュボードからロールバックをトリガーすると、コンポーネント名、環境、ターゲットバージョンが自動的に注入されます。release plan --rollback:ダッシュボードにこの処理をロールバックとして登録し、通常のデプロイと区別します。cancel-rollbackジョブ:ロールバックジョブが明示的にキャンセルされた場合にのみ実行されるため、「進行中」のまま残った古いエントリーがダッシュボードに溜まることはありません。
ロールバックパイプラインを接続する
まず、パイプライン定義を作成します。CircleCIで Project Settings > Project Setup > Add Pipeline に移動します。設定ソースとチェックアウトソースにプロジェクトのリポジトリを指定し、設定ファイルのパスを.circleci/rollback.ymlに設定します。CircleCIがパイプラインをトリガーしてpipeline.deploy.*パラメーターを注入できるように、(リポジトリにファイルを追加するだけでなく)必ずここでパイプラインを作成してください。
次に、Project Settings > Deploys に移動し、Rollback Pipeline のドロップダウンで新しいパイプラインを選択します。
これで、Deploysダッシュボードから直接ロールバックをトリガーできるようになります。セットアップ手順の全体は、ロールバックパイプラインのドキュメントを参照してください。
ロールバックをトリガーする
ロールバックするには、Deploysダッシュボードを開き、rolling-tutorial-appの現在のデプロイを見つけて Rollback をクリックします。CircleCIがロールバックパイプラインをトリガーし、以前のバージョンのメタデータを自動的に注入します。
パイプラインが完了したら、以前のバージョンが再び実行されていることを確認します。
curl http://<EXTERNAL-IP>/api
# {"version":"<previous-commit-sha>","hostname":"rolling-tutorial-app-...","timestamp":"..."}
Deploysダッシュボードには、ロールバックが実行したパイプラインにリンクされた別のエントリーとして表示されます。
ローリングデプロイを使うべきとき(と使うべきでないとき)
ローリングデプロイは、変更に後方互換性があり、インフラコストを重視する場合に適しています。複製環境を用意する必要がなく、追加のツールなしで標準のKubernetesだけで動作し、ロールバックも数秒から数分で完了します。
次のような場合には適していません。
- 部分的なロールアウトの間に以前のバージョンと共存できない、APIやスキーマの破壊的変更が含まれる場合
- 即時のロールバックが必要な場合(ブルーグリーン・デプロイメントではトラフィックを切り替えるだけでロールバックできますが、ローリングデプロイでは更新を逆方向に再実行するため時間がかかります)
- カナリアテストのために割合ベースでトラフィックを制御する必要がある場合(CircleCIとArgo Rolloutsによるプログレッシブデリバリーを参照)
ローリング、ブルーグリーン、カナリアなどの戦略の比較については、Deployment strategies: types, trade-offs, and how to chooseを参照してください。
まとめ
このチュートリアルでは、イメージのビルドからロールバックまで、ローリングデプロイのパイプラインを構築しました。同じCircleCIパイプラインの構成は、アプリケーションのデプロイ要件の拡大に合わせて、ブルーグリーン、カナリア、プログレッシブデリバリーのワークフローにも対応できます。
さっそく始めるには、無料のCircleCIアカウントに登録してリポジトリを接続し、このチュートリアルのパイプライン設定を出発点として活用してください。その他の戦略や設定オプションについては、デプロイのドキュメントで説明しています。