AWS CDKを使ってStep FunctionsでFargateなECSタスクを実行するステートマシンを作成。例外処理も。

AWS CDKでStepFunctionsのリソースを作成してみました!TypeScriptでの開発体験が非常によく、かなり楽しんで書くことができました!なおコードの出来。
2022.11.28

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。AWS事業本部コンサルティング部に所属している今泉(@bun76235104)です。

私はこれまでIaC(Infrastructure as Code)ツールとしてTerraformを使うことが多かったのですが、今回CDKを利用する機会に恵まれました。

そこで、皆大好き AWS Step FunctionsをCDKで組んでみようということで、ECSのタスク定義を動かしてみました。

フローについては後述しますが、以下のようなフローのステートマシンを作成します。

20221128_stepfunc_with_fargate_by_cdk_graph

なお、以降の検証は以下環境で実施していますのでご留意ください。

Key Value
OS macOS Monterey
Node Version 18.12.1
CDK Version 2.37.1
言語 TypeScript

下準備 VPCなどのリソース作成

今回ECSのRun Taskを実行させるため、以下のような構成を作成しました。

20221128_stepfunc_with_fargate_by_cdk_architecture2

StepFunctionsからECS on Fargateでタスクを実行する形です。

今回はStepFunctionsの作成がメインとなるためVPCなどのリソースのコードについては割愛しますが、コード全文はこちらのリポジトリで閲覧できます。

なお今回はプライベートサブネットからFargateでコンテナを起動できるように、以下VPCエンドポイントを追加しています。

Fargate利用時に必要なVPCエンドポイントについてはこちらのブログをご確認ください。

CDKでStep Functions を定義する

作るものの確認

StepFunctionsを作成するためのクラスを定義していきます。

再掲となりますが、今回作成するStep Functionsのステートマシンは以下のとおりです。

20221128_stepfunc_with_fargate_by_cdk_graph

Step1 OKRun では以下のような定義のDockerコンテナを動かします。

FROM alpine:latest
ENTRYPOINT [ "ash", "-c", "echo shuld end with code0 && true"]

true コマンドにより、終了コード0で正常にコンテナが終了するイメージです。

はたまた、Step2 NGRun では以下のような定義のDockerコンテナを動かします。

FROM alpine:latest
ENTRYPOINT [ "ash", "-c", "echo shuld end with code1 && false"]

こちらは対照的に false コマンドにより終了コード1でコンテナが異常終了するイメージです。

Step1 FailStep2 Fail はそれぞれのステップで失敗した場合の例外処理となっています。

まとめると以下のようになります。

  • Step1 OKRun ではコンテナ(Fargate Task)が正常終了するはず
    • 正常終了するので Step1 Fail には進まないでほしい
  • Step2 NGRun ではコンテナ(Fargate Task)が異常終了する(終了コード1のため)
    • 異常終了するので Step2 Fail に進んでほしい

進行してほしいルートはこうです。

20221128_stepfunc_with_fargate_by_cdk_expected

やりたいことを確認できたので、CDKのコードをみていきます。

FargateタスクをStepFunctionsで実行するためのコード

CDKから必要なコードを抜粋した部分は以下のとおりです。

まず、StepFunctionsでFargateのタスクを実行するにあたり、VPCなどの情報が必要であるため、必要なパラメータを以下interfaceにまとめています。

lib/resources/interfaces/step_functions_param.ts

import { Construct } from 'constructs';
import { Cluster } from 'aws-cdk-lib/aws-ecs';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import { FargateTaskDefinition } from 'aws-cdk-lib/aws-ecs';

export interface StepFunctionsParam {
  scope: Construct;
  vpc: Vpc;
  okTaskDef: FargateTaskDefinition;
  ngTaskDef: FargateTaskDefinition;
  cluster: Cluster;
}
  • okTaskDefStep1 OKRun で実行させたいコンテナ実行情報を持つタスク定義
  • ngTaskDefStep2 NGRun で実行させたいコンテナ実行情報を持つタスク定義

となっています。

また、ECSクラスタの情報も cluster パラメータで渡しています。

次にこれらパラメータを受け取って、StepFunctionsのステートマシンを作成するクラスがこちらです。

lib/resources/step_functions.ts

import { Construct } from 'constructs';
import { aws_stepfunctions_tasks } from 'aws-cdk-lib';
import {
  Errors,
  IntegrationPattern,
  Pass,
  TaskStateBase,
  Fail,
  StateMachine,
} from 'aws-cdk-lib/aws-stepfunctions';
import { EcsFargateLaunchTarget } from 'aws-cdk-lib/aws-stepfunctions-tasks';
import { StepFunctionInvokeAction } from 'aws-cdk-lib/aws-codepipeline-actions';
import { SecurityGroup } from 'aws-cdk-lib/aws-ec2';
import { TaskDefinition } from 'aws-cdk-lib/aws-ecs';
import { StepFunctionsParam } from './interfaces/step_functions_param';
import { Resource } from './abstract/resource';

export class StepFunc extends Resource {
  readonly params: StepFunctionsParam;

  constructor(params: StepFunctionsParam) {
    super();
    this.params = params;
  }

  public createResources() {
    const sg = this.createSG();
    // Step1
    const okTask = this.getRunTaskParam('Step1 OKRun', this.params.okTaskDef, [
      sg,
    ]);
    const failStep1 = new Fail(this.params.scope, 'Step1Fail');    okTask.addCatch(failStep1);
    // Step2
    const ngTask = this.getRunTaskParam('Step2 NGRun', this.params.ngTaskDef, [
      sg,
    ]);
    const failStep2 = new Fail(this.params.scope, 'Step2Fail');
    ngTask.addCatch(failStep2);
    const definition = okTask.next(ngTask);
    // createSteate
    new StateMachine(this.params.scope, 'ExampleStepStateMachine', {
      definition,
    });
  }

  private createSG(): SecurityGroup {
    // RunTaskの際に利用するSG
    const sg = new SecurityGroup(this.params.scope, 'RunTaskSG', {
      vpc: this.params.vpc,
      securityGroupName: 'stepExampleRuntaskSG',
      allowAllOutbound: true,
    });
    return sg;
  }

  private getRunTaskParam(
    id: string,
    taskdef: TaskDefinition,
    sg: [SecurityGroup]
  ) {
    const runTask = new aws_stepfunctions_tasks.EcsRunTask(
      this.params.scope,
      id,
      {
        integrationPattern: IntegrationPattern.RUN_JOB,
        cluster: this.params.cluster,
        taskDefinition: taskdef,
        assignPublicIp: false,
        launchTarget: new EcsFargateLaunchTarget(),
        subnets: {
          subnets: this.params.vpc.isolatedSubnets,
        },
        securityGroups: sg,
      }
    );
    return runTask;
  }
}

createResourcesの部分が主要ロジックです。

  public createResources() {
    // ECS RunTaskで利用するセキュリティグループを作成
    const sg = this.createSG();
    // Step1でタスク定義から実行するためのジョブを追加
    const okTask = this.getRunTaskParam('Step1 OKRun', this.params.okTaskDef, [
      sg,
    ]);
    // Step1の例外処理を追加
    const failStep1 = new Fail(this.params.scope, 'Step1Fail');
    okTask.addCatch(failStep1);
    // Step2でタスク定義をもとに実行するためのジョブを追加
    const ngTask = this.getRunTaskParam('Step2 NGRun', this.params.ngTaskDef, [
      sg,
    ]);
    // Step2の例外処理を追加
    const failStep2 = new Fail(this.params.scope, 'Step2Fail');
    ngTask.addCatch(failStep2);
    const definition = okTask.next(ngTask);
    // createSteate
    new StateMachine(this.params.scope, 'ExampleStepStateMachine', {
      definition,
    });
  }

呼び出す側のクラスを抜粋すると以下のようになっています。

lib/step_example-stack.ts

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Ecr } from './resources/ecr';
import { Network } from './resources/network';
import { Ecs } from './resources/ecs';
import { StepFunc } from './resources/step_functions';

export class StepExampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    // ↑ここより上でVPCなどのリソースを作成しているが省略
    // StepFunc
    const sf = new StepFunc({
      scope: this,
      okTaskDef: ecs.okTaskDef,
      ngTaskDef: ecs.ngTaskDef,
      cluster: ecs.cluster,
      vpc: network.vpc,
    });
    sf.createResources();
  }
}

呼び出し部分を省略せずに記載すると以下のようになっています。

VPCやECRなどの必要なリソースを先に作成して、StepFunctionsのリソース作成に利用しています。

lib/step_example-stack.ts

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Ecr } from './resources/ecr';
import { Network } from './resources/network';
import { Ecs } from './resources/ecs';
import { StepFunc } from './resources/step_functions';

export class StepExampleStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    // ECR
    const ecr = new Ecr(this);
    ecr.createResources();
    // Network
    const network = new Network(this);
    network.createResources();
    // ECS taskdef
    const ecs = new Ecs({
      scope: this,
      okImage: ecr.getOKImage(),
      ngImage: ecr.getNGImage(),
      vpc: network.vpc,
    });
    ecs.createResources();
    // StepFunc
    const sf = new StepFunc({
      scope: this,
      okTaskDef: ecs.okTaskDef,
      ngTaskDef: ecs.ngTaskDef,
      cluster: ecs.cluster,
      vpc: network.vpc,
    });
    sf.createResources();
  }
}

以上がStepFunctionsに関連する部分の抜粋となります。

繰り返しとなりますがコード全文を確認したい場合以下リポジトリをご確認ください。

CDKでリソース作成 & StepFunctionsのステートマシンを実行してみる

それではCDKを実行してみます。

cdk synth

これによりCloudFormationの定義を確認し、問題なさそうですので以下コマンドでデプロイします。

cdk deploy

無事リソースが作成されました。

20221128_stepfunc_with_fargate_by_cdk_created

さっそく実行してみます!

始まりました...

20221128_stepfunc_with_fargate_by_cdk_started

想定どおり、Step2 NGRun でコンテナが異常終了したため、Step2 Fail で例外をキャッチしています!

20221128_stepfunc_with_fargate_by_cdk_end_with_fail

なんとか意図どおりの挙動となりました。

まとめ

  • CDKでFargateのタスクを実行するStepFunctionsのステートマシンを作ってみた
  • TypeScript+CDK をVSCodeで開発する体験が良すぎる
    • 型補完がやばい(語彙力)

今回サンプルのコードは今時点の私が書いたコードであるため、美しない点が多々あると思いますので、是非もっと良い構成を考えて教えてください!