皆様こんにちは、ソーシャル経済メディア「NewsPicks」(Platform Engineering Team チーム)エンジニアの北見です。
弊社ではIaaC として CDK を採用しており、アプリケーションのデプロイフローの構築にも利用しています。
StepFunction は AWS の提供する機能で、複数のTaskから構成されおり、一連の処理フローを管理する上で大変便利です。

今回はデプロイフローとしての StepFunction の利用例をご紹介します。
CDKを使ったStepFunctionによるデプロイフローの構築例
今回は
コミットidを受け取り、アプリケーションの成果物がECRにあるかを確認
→ECSのサービスを作成 or 更新
→ Slack通知(成功 or 失敗)
の流れでアプリケーションをデプロイする StepFunction を
1. StepFunction の Task をclassとして定義し、フォルダ分けする
2. Lambdaの実行Taskは、実行するlambda関数と同じ階層に配置し、名前を xxx.handler.ts とする
という順序で構築してみましょう。
1. StepFunction の Task をclassとして定義し、フォルダ分けする
CDK の StepFunction で実行される各 Task は、 aws-cdk-lib/aws-stepfunctions-tasks から import できるクラスに大体提供されています。
そこで各実行 Task を class として定義し、フォルダ分けしてあげると分かりやすいです。

例えば check-artifact フォルダ以下に chack-artifact.ts ファイルを用意し
import * as tasks from "aws-cdk-lib/aws-stepfunctions-tasks"; export class CheckArtifactTask extends tasks.LambdaInvoke { constructor(scope: Construct, id: string) { ... } }
といった Task Class をexportします。
2. Lambdaの実行Taskは、実行するlambda関数と同じ階層に配置し、名前を xxx.handler.ts とする
デプロイフローで何かしたいとき、使用頻度が最も高い機能の1つとして lambda があると思います。
この lambda を StepFunction に組み込む場合、以下の様に tasks.LambdaInvoke を継承したクラスと、lambda のハンドラ本体を同じフォルダに配置し、名前を揃えておくと大変わかりやすいです。

まずは以下の getHandlerFileName 関数を用意し
import * as path from "path"; export function getHandlerFileName(dirName: string, filename: string): string { const basename = path.basename(filename, ".ts"); return `${path.join(dirName, basename)}.handler.ts`; }
あとは tasks.LambdaInvoke を継承したクラス内部で呼べばOKです。
check-artifact.ts
export class CheckArtifactTask extends tasks.LambdaInvoke { constructor(scope: Construct, id: string) { ... const lambdaFunction = new DeployLambdaFunction(scope, `CheckArtifactFunction`, { entry: getHandlerFileName(__dirname, __filename), initialPolicy: [ new iam.PolicyStatement({ actions: [ "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:DescribeImages", ], resources: [...], }), ], }); super(scope, id, { lambdaFunction: lambdaFunction, resultPath: "$.results.checkArtifact", }); } }
ラムダのハンドラは↓ のようになります。
check-artifact.handler.ts
interface CheckArtifactEvent { readonly commitId: string; } interface CheckArtifactResult { readonly commitId: string; readonly existsArtifact: boolean; } export async function handler(event: CheckArtifactEvent): Promise<CheckArtifactResult> { const existsArtifact = await new existsImage(...), return { commitId: event.commitId, existsArtifact: existsArtifact, }; } async existsImage(ecr :Ecr, repositoryName: string, imageTag: string): Promise<boolean> { try { const response = await ecr .batchGetImage({ repositoryName: repositoryName, imageIds: [{ imageTag: imageTag }], }) .promise(); console.log(response); if (response.images?.length === 1) { return true; } else { console.log(`ECR:${repositoryName}にImageTag:${imageTag}が存在しません。`); return false; } } catch (e) { console.log(e); throw e; } }
こうすると「check-artifact.ts がStepFunctionのTaskで、check-artifact.handler.ts がそこで実行するラムダ関数なのね」と一目で分かります。
可読性が高く保守しやすいのでおすすめです。
余談
今回 lambda は typescript を使用しましたが、python 等別の言語でも同じフォルダ配置にしてあげると良さそうに思えました。
ただし認知負荷が低いうえ CDK と透過的に扱えるので、CDK を使うのであれば lambda も typescript を採用するのが個人的に良いと考えています。
3. StepFunction としてフローを組み立てる

このようなフォルダ構成で、各Taskをそれぞれクラスとして export しています。
1.ECRに成果物があるかを確認するTask
chack-artifact.ts
export class CheckArtifactTask extends tasks.LambdaInvoke { constructor(scope: Construct, id: string) { ... } }
2.ECSを作成 or 更新するTask
create-or-update-service-task.ts
export class CreateOrUpdateServiceTask extends tasks.LambdaInvoke { ... }
3.slackへ成功通知するTask
notify-success-slack.ts
export class NotifySuccessSlack extends tasks.LambdaInvoke { ... }
4.slackへ失敗通知するTask
notify-fail-slack.ts
export class NotifyFailSlack extends tasks.LambdaInvoke { ... }
5.これらをStepFunctionとしてまとめたstack
deploy-stack.ts
export class DeployStack extends cdk.Stack { constructor(scope: Construct, id: string) { super(scope, id, props); const notifySuccessTask = new NotifySuccessSlack(stack, `NotifySuccess`, { .... }).next(new sfn.Succeed(stack, "Success")); const notifyFailTask = new NotifyFailSlack(stack, `NotifyFail`, { .... }).next(new sfn.Fail(stack, "Fail")); const checkArtifactTask = new CheckArtifactTask(stack, "CheckArtifactTask", { ..., }); // .nextや.addCatchで、成功時に遷移するTaskやエラー時に遷移するTaskを記述できる const createOrUpdateServiceTask = new CreateOrUpdateServiceTask(stack, "CreateOrUpdateServiceTask", { ..., }).addCatch(notifyFailTask, { resultPath: "$.results.createOrUpdateService" }) .next(notifySuccessTask); checkArtifactTask .addCatch(notifyFailTask, { resultPath: "$.results.checkArtifact", }) .next( new sfn.Choice(stack, "Verify Artifact Existance") .when( sfn.Condition.booleanEquals("$.results.checkArtifact.Payload.existsArtifact", false), notifyFailTask ) .when( sfn.Condition.booleanEquals("$.results.checkArtifact.Payload.existsArtifact", true), createOrUpdateServiceTask ) .otherwise(notifyFailTask) ); new sfn.StateMachine(this, "DeploymentStateMachine", { stateMachineName: `deployment-flow`, definition: defineCheckArtifactParams, }); }
さいごに
各Taskを class としてまとめておくと見通しよく StepFunction のフローを構築できますし、task.LambdaInvoke と同じ階層にハンドラがあるのでコードも追いやすいです。
CDK で見通しよく管理できると気持ち良いですし、StepFunction の実行フローをAWS上で眺めると「よしよし」とテンション上がるので、皆さんもぜひチャレンジしてみて下さい。