CS1の石井です。
プロジェクトでCDKを運用して約半年ぐらい経ちました。
CI/CDパイプラインとCDKを運用する試み今回が初めてで当初は色々なトラブルが発生していました。
今回の記事はそのトラブルの中でパイプラインが無限ループした事象をまとめたブログ記事となります。
対象読者
CDKのワープショップを完了させている人を前提としています。
1. はじめに
弊プロジェクトではAWSのインフラストラクチャをコードで表現し、簡単にデプロイや管理を行うためのツールとして、AWS CDKを採用しています。
また、CI/CDパイプラインを用いてインフラコードをデプロイしており、CI/CDパイプラインはpipelines
モジュールを利用することで簡単に作成・管理することが可能となります。
CDKとpipelinesモジュールの簡単な紹介
AWS CDK: AWS Cloud Development Kit(CDK)は、クラウドリソースをプログラムのコードとして定義できるオープンソースのソフトウェア開発キットです。TypeScript, Python, Java, などの言語を使用してAWSリソースをモデル化・プロビジョニングすることができます。
pipelinesモジュール: CDKの
pipelines
モジュールは、CDKアプリケーションのためのCI/CDパイプラインを簡単に定義・デプロイするための高水準の抽象化を提供しています。このモジュールを使用することで、複数のステージやアカウントに跨るデプロイメントを簡単に管理することができます。
この記事の目的
この記事では、CDKのpipelines
モジュールを使用している際に、特定の状況下で発生する可能性のある「無限ループ」の問題について記載します。
このパイプラインの無限ループを発生させると、パイプラインの構成変更に関わる部分、KMSやIAMの再作成によりAWS Configの利用料が上がってしまいます。
本記事の内容で無限ループの防止につながれば幸いです。
2. 無限ループの発生条件
無限ループが発生する条件や事象を再現するための詳細を探ることは、問題を解決または回避する鍵となります。この章では、実際のコードや設定をもとに、この無限ループの事象がどのような条件下で発生するのかを深堀りします。
事象を再現する具体的な条件の詳細
- 環境別(例: Stg, Live)にパイプラインを作成する設定が存在する場合。本記事ではステージング環境をstg、本番環境をliveと呼称します。
- 特定のブランチにのみ変更をpushすると、関連するパイプラインだけが更新されるような設定が存在する。
- 更新されたパイプラインが他のパイプラインも更新してしまうような状況。
無限ループさせるコード
上記の無限ループが発生する条件を再現するためのコード例です:
# ディレクトリ作成とCDKのinit mkdir looppipeline && cd $_ npx cdk init --language=typescript npm i -D esbuild npm install @types/aws-lambda touch lib/sample-lambda.handler.ts
// bin/looppipeline.ts #!/usr/bin/env node import 'source-map-support/register'; import * as cdk from 'aws-cdk-lib'; import { LooppipelineStack } from '../lib/looppipeline-stack'; const app = new cdk.App(); new LooppipelineStack(app, 'LooppipelineStack', { env: { account: "XXXXXX", region: "ap-northeast-1", }, });
// bin/looppipeline.ts import { Stack, StackProps, Stage, StageProps } from "aws-cdk-lib"; import { Construct } from "constructs"; import { aws_codecommit as codecommit, pipelines as pipelines, } from "aws-cdk-lib"; import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; import * as apigateway from "aws-cdk-lib/aws-apigateway"; export class LooppipelineStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); const repo = new codecommit.Repository(this, `ccr-contentsdeploy`, { repositoryName: "looprepo", }); const StgPipeline = new pipelines.CodePipeline(this, "StgPipeline", { pipelineName: "StgLoopPipeline", crossAccountKeys: true, dockerEnabledForSynth: true, synth: new pipelines.CodeBuildStep("Synth", { input: pipelines.CodePipelineSource.codeCommit(repo, "stg"), installCommands: ["npm ci"], commands: ["npm run build", "npx cdk synth -c stage=stg"], }), }); StgPipeline.addStage( new AppStage(this, `StgDeployStage`, { env: { account: "XXXXXX", region: "ap-northeast-1", }, }) ); const LivePipeline = new pipelines.CodePipeline(this, "LivePipeline", { pipelineName: "LiveLoopPipeline", crossAccountKeys: true, dockerEnabledForSynth: true, synth: new pipelines.CodeBuildStep("Synth", { input: pipelines.CodePipelineSource.codeCommit(repo, "live"), installCommands: ["npm ci"], commands: ["npm run build", "npx cdk synth -c stage=live"], }), }); LivePipeline.addStage( new AppStage(this, `LiveDeployStage`, { env: { account: "YYYYYYY", region: "ap-northeast-1", }, }) ); } } export class AppStage extends Stage { constructor(scope: Construct, id: string, props: StageProps) { super(scope, id, props); new DeployStack(this, "id", props); } } export class DeployStack extends Stack { constructor(scope: Construct, id: string, props: StackProps) { super(scope, id, props); const stage: 'stg' | 'live' = this.node.tryGetContext('stage') as 'stg' | 'live' ; // Lambda const sampleLambda = new NodejsFunction(this, `SampleLambdaFor${stage}`, { entry: "lib/sample-lambda.handler.ts", environment: { STAGE: `${stage}` } }); // API Gateway const sampleApi = new apigateway.RestApi(this, `SampleApiFor${stage}`); const lambdaIntegration = new apigateway.LambdaIntegration(sampleLambda); sampleApi.root.addMethod('GET', lambdaIntegration); } }
// lib/sample-lambda.handler.ts import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> { const stage = process.env.STAGE; // 環境変数'STAGE'を読み取る return { statusCode: 200, body: JSON.stringify({ message: `Hello, World ${stage}!` }), headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }; }
サンプルのパイプラインコード解説
シンプルなHello wroldが帰ってくるLambdaとそれを実行するApi Gatewayのスタックと、そのスタックをデプロイするためのパイプラインを構成するスタックです。
図にすると以下のような構成を作成するCDKです。
※デプロイ先のXXXXとYYYYはデプロイできるAWSアカウントに書き換えてください。 XXXXXはstgのApiGateway + Lambdaをデプロイするアカウントとパイプラインを構成するアカウントの二つを兼任しています。
なお、YYYYYのアカウントとパイプラインを構成するアカウント間に(上記の例で言えばXXXXX)AdministratorAccessの信頼関係がない場合は、下記のコマンドで信頼関係を作成しておいてください。
npx cdk bootstrap \ --profile YYYYYアカウントのプロファイル名 \ --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess \ --trust XXXXX \ aws://YYYYY/ap-northeast-1
パイプラインのデプロイとCodeCommitへのpush
サンプルのLambdaとApigatewayをSTG環境に作成するために、まずはパイプラインをデプロイします。
npx cdk deploy # gitコミット git checkout -b live git checkout -b stg git add . git commit -m 'first commit'
次にCodeCommitのstgブランチにファイルをpushしてパイプライン経由でコードをデプロイします。
# remoteの設定とpush git remote add origin プロファイル名@codecommit::ap-northeast-1://looprepo git push --set-upstream origin stg # liveブランチへstgの内容をマージしてpush git checkout live git merge stg git push --set-upstream origin live
デプロイ完了して、ApigatewayのURLにアクセスすれば、HelloWorldが帰ってくるはずです。
これで無限ループを発生させられるサンプルの準備が整いました。
3. 無限ループを発生させるシナリオの実践
ここからは実際に無限ループをさせてみます。 仮にプロジェクトのルールとしてstgブランチを更新して「stg環境で動作が確認できたコードのみ本番用のliveブランチにマージする」というルールで運用しているとしてブランチを更新してみます。
lambdaコードの更新
まずはlambdaコードの更新をしてみます。
本番環境のliveブランチにマージしてコードを流すというルールで運用しているという前提で、まずはstgブランチのlambdaのコードを更新してみます。
// lib/sample-lambda.handler.ts import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> { const stage = process.env.STAGE; // 環境変数'STAGE'を読み取る return { statusCode: 200, body: JSON.stringify({ message: `Hello, World ${stage}! stage!` // 文章の末尾にstage!という文字列を追加しただけです }), headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }; }
デプロイが完了してApigatewayにブラウザでアクセスすると以下のように表示されます。
問題なくLambdaは更新できたようなので、本番環境のブランチにマージしてみます。
git checkout live git merge stg git push origin live
同様にlive用のパイプラインが起動し、デプロイが完了されるとApigatewayからのレスポンスは下記のようになります。
パイプラインのコードを更新
Lambdaの更新自体は想定通り実行できました。次はパイプライン自体のコードを更新してみます。
本来であればデプロイしたApigatewayのエンドポイントを確認するため、ドメインに対するcurlを入れれば良いのですが、サンプルのため一旦yahoo.co.jpにcurlするコマンドを挿入してみます。
まずはstgブランチをチェックアウトしてパイプラインのコードを更新してみます。
// bin/looppipeline.ts const stgStage = StgPipeline.addStage( new AppStage(this, `StgDeployStage`, { env: { account: "XXXXX", region: "ap-northeast-1", }, }) ); stgStage.addPost( new pipelines.ShellStep('RunCurlCommand', { commands: [ 'curl https://yahoo.co.jp' ], }) ); 〜中略〜 const liveStage = LivePipeline.addStage( new AppStage(this, `LiveDeployStage`, { env: { account: "YYYYY", region: "ap-northeast-1", }, }) ); liveStage.addPost( new pipelines.ShellStep('RunCurlCommand', { commands: [ 'curl https://yahoo.co.jp' ], }) );
プロジェクトのルールとしてはstgでの動作確認が終えたらliveにマージするというルールで運用しています。 そのためまずはstgでコミットしてpushしてみます。
pushを行うと当然ですがstg用のパイプラインが作動します。
パイプラインは先ほど記載した図の通り、UpdatePipelineというステージがパイプラインの更新を行います。
なお、UpdatePipelineステージのbuildspecは以下のような記載があり、パイプライン用のスタックである「LooppipelineStack」をデプロイしていることがわかります。
※--require-approval=neverはIAM系の作成確認の応答を行わないオプションです。
{ "version": "0.2", "phases": { "install": { "commands": [ "npm install -g aws-cdk@2" ] }, "build": { "commands": [ "cdk -a . deploy LooppipelineStack --require-approval=never --verbose" ] } } }
パイプライン自体のコードはUpdatePipelineステージにより更新が始まります。
ただ、stgパイプラインのUpdatePipelineが完了すると、live側も動き出してしまいます。
これは「cdk -a . deploy LooppipelineStack」で実行した時にLooppipelineStack自体にstgとliveの二つの環境のパイプラインが記載されているためだと考えられます。
※cdk deployでパイプライン自体がなぜ動き出してしまうのか、理由は定かではありませんが EventBridgeとパイプラインが紐付けているため動いてしまっているのかなぁという所感です。(この挙動を抑止する術を知っている方がいれば教えてください)
liveパイプラインによるUpdatePipeline
意図せずlive側のパイプラインが動いてしまいました。
そのため、stgブランチに追加した「curlを実行するステップ」の修正内容が反映されていない 一つ前のLambda内容を更新した内容でUpdatePipelineが実行されてしまいます。
liveブランチは自身のCDKコードのdeployを行い、deployを行えばstgブランチも先ほどと同様にstg用のパイプラインが動き出してしまいます。 下記の順番で処理するため、liveとstgのコードでお互いを更新しあう状態が生まれてしまいました。
- stgパイプラインが実行
- stgパイプラインでUpdatePipelineがパイプライン更新用のコードでパイプラインを更新
- stgとliveパイプラインが起動
- liveパイプラインでUpdatePipelineがパイプライン更新前のコードでパイプラインを更新
- stgとliveパイプラインが起動
- 2に戻る
図に記載すると以下のようなイメージです。
無限ループを止める
このままでは相互にパイプラインを更新し合いConfigの利用料が上がってしまうので止めます。
止める方法は単純にliveブランチにstgの内容をマージしてpushするだけです。
4. 回避策
プロジェクトのルールとして「stgでのデプロイが確認できたらliveにマージする」という運用ルールではUpdatePipelineを使っている以上、このルールを適用すると確実にループしてしまいます。
では、回避策を考えると以下の選択肢があるかと思います。
- パイプラインの更新は環境に影響ないからstgとliveを一気にアップデートする
- stgのUpdatePipelineによるlive側のパイプラインの起動を手動で止める
- live側のパイプラインでソースステージから移行を無効にする設定を行う
- selfmutationをどちらのパイプラインもfalseにしてパイプラインの更新は手動で行う
- live側だけselfmutationをfalseにする
最後の「live側だけselfmutationをfalseにする」という選択肢以外はどうもパッとしないので弊プロジェクトでは最終的にこの選択肢になりました。
stg側からアップデートを行う、というルールであればstg側でアップデートが掛かっても無限ループはしなさそうです。
パイプラインを更新して、再度lambdaの更新を行い回避策が機能するか確認してみます。
検証1: Lambdaの更新
早速liveのパイプラインを更新してみます。 下記の内容をstgで更新したらすぐにliveにもマージしてpushし、liveのパイプラインは手動で停止しておきます。
const LivePipeline = new pipelines.CodePipeline(this, "LivePipeline", { pipelineName: "LiveLoopPipeline", crossAccountKeys: true, selfMutation: false, dockerEnabledForSynth: true, synth: new pipelines.CodeBuildStep("Synth", { input: pipelines.CodePipelineSource.codeCommit(repo, "live"), installCommands: ["npm ci"], commands: ["npm run build", "npx cdk synth -c stage=live"], }), });
次にstgブランチ側で再度lambdaを更新します
// lib/sample-lambda.handler.ts import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> { const stage = process.env.STAGE; // 環境変数'STAGE'を読み取る return { statusCode: 200, body: JSON.stringify({ message: `Hello, HogeWorld ${stage}! stage!` // HogeWorldに変更しました }), headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }; }
stgパイプラインで無事にデプロイできたら下記のようにHogeWorldで表示されます。
stgでの確認が取れたので、liveにもマージしてパイプラインでデプロイします。
selfmutationをfalseにしただけなので、特にトラブルなく更新できました。
どちらのパイプラインもLambdaの更新は良さそうです。
検証2: パイプライン自体の更新
では次にパイプラインの更新を行ってみます。 stgのブランチで先ほどのcurlコマンドの宛先を以下のように変更してみます。
stgStage.addPre( new pipelines.ShellStep('RunCurlCommandPre', { commands: [ 'curl https://yahoo.co.jp' ], })), stgStage.addPost( new pipelines.ShellStep('RunCurlCommandPost', { commands: [ 'curl https://google.co.jp' ], }) ); 〜〜中略〜〜 liveStage.addPre( new pipelines.ShellStep('RunCurlCommandPre', { commands: [ 'curl https://yahoo.co.jp' ], })), liveStage.addPost( new pipelines.ShellStep('RunCurlCommandPost', { commands: [ 'curl https://google.co.jp' ], }) );
無事stg用のパイプラインが流れ終わり最後のcurlを行うステップもstgとlive共に正常に更新されたようです。
stg用パイプライン
live用パイプライン
注意点
ただし、この構成は「必ずstgパイプラインから更新しないとliveは必ず失敗する」という特性を持ったパイプラインとなります。
「stgから更新するのが当たり前なんだし、理由はともかくそれはそれでいいんじゃない?」と思うかもしれませんが、緊急でliveだけ更新することになったとき慌ててしまわないように記載しておきます。
検証3: liveのみLambdaを更新してデプロイする
stgを経由しないとliveが必ず失敗するという特性を確認してみます。例によってLambdaを更新してみます。
// lib/sample-lambda.handler.ts import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> { const stage = process.env.STAGE; // 環境変数'STAGE'を読み取る return { statusCode: 200, body: JSON.stringify({ message: `Hello, HogeWorld ${stage}! stage!` // HogeWorldに変更しました }), headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } }; }
これをliveブランチで更新してpushしてみます。
UpdatePipelineはパイプラインの更新による部分のため、Lambdaのデプロイ程度なら正常に完了してくれそうですが・・・
なぜか失敗してしまいました。CFnのエラー内容を確認しみると「S3 Error Code: NoSuchKey」と書いてあります。
エラー内容の深掘り
CFnでLambdaのデプロイが失敗したのは明らかなんですが、何がどうNGなんでしょうか。まずはテンプレートを確認してみます。
"SampleLambdaForlive38B39C0A": { "Type": "AWS::Lambda::Function", "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-461233751674-ap-northeast-1", "S3Key": "d84b233ede2e59c1a263fc151fd87187ef5b39ff47461aeb0d1ebc16c853fee8.zip" },
Lambdaは「d84b233ede2e59c1a263fc151fd87187ef5b39ff47461aeb0d1ebc16c853fee8.zip」というS3がないため失敗したらしいのですが・・・
実際には配置されているようです。
なんだかよくわからないので、一旦ローカルでsynthしてみます。
synthした結果、同じテンプレートでも以下のように表示されており、参照するS3Keyがなぜか違っています。
"SampleLambdaForlive38B39C0A": { "Type": "AWS::Lambda::Function", "Properties": { "Code": { "S3Bucket": "cdk-hnb659fds-assets-461233751674-ap-northeast-1", "S3Key": "c1e148fafa18654496d23cc0a619555f11175b9f9a88d1b3270d0fec30458e76.zip". // 本来はこのzipファイル名で参照しなくてはならない },
pipelines モジュールでは、Lambda 関数のコードはパイプラインの Assets ステージにて S3 バケットにアップロードされます。
Assets ステージの CodeBuild アクションでは、BuildSpec 内に、Lambda 関数のコードのオブジェクト名が含まれています。
そのため、Lambda 関数のコードを変えるたびに Assets ステージの BuildSpec の内容も更新する必要があり、UpdatePipelineをfalseにしたliveでは失敗してしまうということです。
実際にAssetsのステージのbuildspecを見てみると下記の記載になっており、「d84b233ede2e59c1a263fc151fd87187ef5b39ff47461aeb0d1ebc16c853fee8」という旧Lambdaのzipファイルが指定されていました。
{ "version": "0.2", "phases": { "install": { "commands": [ "npm install -g cdk-assets@2" ] }, "build": { "commands": [ "cdk-assets --path \"assembly-LooppipelineStack-LiveDeployStage/LooppipelineStackLiveDeployStageidAD3D60B0.assets.json\" --verbose publish \"d84b233ede2e59c1a263fc151fd87187ef5b39ff47461aeb0d1ebc16c853fee8:YYYYYYYY-ap-northeast-1\"" ] } } }
そしてAssetsのBuildspecを更新するのはstg側のUpdatePipelineしかいないため、live側のみ更新しても失敗してしまったという事でした。
なお、CFn上で実体があるのにS3NosuchKeyになっていた理由ですが
CFnの画面から見えているテンプレートは、一つ前の成功時のCFnテンプレートが表示されているだけです。
実際に実行したCFnは「c1e148fafa18654496d23cc0a619555f11175b9f9a88d1b3270d0fec30458e76.zip」を指定したCFnであり、S3上に「c1e148fafa18654496d23cc0a619555f11175b9f9a88d1b3270d0fec30458e76.zip」がないためNoSuchKeyと出力されていました。
図にすると以下のようなイメージです
検証4: 再度stgを経由してデプロイする
無論、liveの更新内容をstgにマージしてデプロイすることは可能です。 実際にマージしてデプロイすれば以下のように正常に反映されます。
そしてこれをliveブランチにもマージすればデプロイできますが、念の為Assesステージのbuildspecを確認すると、下記の記載になっていました。
{ "version": "0.2", "phases": { "install": { "commands": [ "npm install -g cdk-assets@2" ] }, "build": { "commands": [ "cdk-assets --path \"assembly-LooppipelineStack-LiveDeployStage/LooppipelineStackLiveDeployStageidAD3D60B0.assets.json\" --verbose publish \"c1e148fafa18654496d23cc0a619555f11175b9f9a88d1b3270d0fec30458e76:YYYYY-ap-northeast-1\"" ] } } }
先ほどローカルでsynthした時と同じzip名が指定されており問題なさそうであり、デプロイも正常にできました。
UpdatePipelineの作用はパイプラインに対する更新であればパイプライン自体が動き出すのですぐにわかるのですが
ステージの持つbuildspecの内容は飽くまでCodebuildの内容のためパイプラインは作動しないということなのでしょう。
※なお、余談ですがLambdaを別ファイルで作成して、そのファイルをnodejsモジュールなどでデプロイするとAssetsステージが必要になります。
そのため、Stackファイルの中にLambdaモジュールを使ってインラインで埋め込んでしまえば、いきなりliveブランチから流すことも可能です。
個人的に面倒になる上に特に意味もないので、素直にstgから流すルールで運用するのがよろしいかと思います。
まとめ
以上でCodePipelineが無限ループした時の事象と対策についてまとめた記事の紹介を終わります。
正直なところ、CodePipelineが無限ループするという事象が想像できなく、またCDKも使い始めだったためSelfmutationの扱いもあやふやだったので非常に焦りました。
一つ一つ細かくみていけば大した話じゃないんですが、同じようなブランチの運用でCDKのデプロイを考えている人の参考になればと思います。