タケユー・ウェブ日報

Webシステム受託会社の業務の中での気づきや調べごとのメモ。

CodePipeline のアクションで AWS Lambda を実行する

まとめ

  • CodePipeline のアクションとして AWS Lambda を実行すれば、いろいろなことができる
    • たとえばRailsのdb:migrateをデプロイ時に自動設定したり、ECSスケジュールタスクを登録したり
  • Lambdaのタスクロールを適切に指定すればAWSアクセスキーなしにAWS SDKが使えて便利
  • CDKを使えばLambdaファンクションのソースコードのバージョン管理も捗る

f:id:uzuki05:20200331232500p:plain
CodePipeline実行の様子

事の起こり

弊社受託案件のうちいくつかは Rails + AWS CodePipeline + ECS による継続的デリバリ環境で稼働しています。

AWS CodePipeline は処理内容と、それどういった順番で実行するかを定義することで、柔軟なリリース自動化が可能です。

処理内容はたとえば、

  • Gitからソースコードをチェックアウトする
  • CodeBuildを使ってDockerイメージのビルドをしてECRにpushする
  • ECSのローリングアップデートを行う

などです。

今回このパイプラインのなかで、

  • Rails の db:migrate したい
  • ECS のスケジュールタスクを更新したい

することにしました。

作業メモ

ECSのコンテナを使って db:migrate するには?

ECSのRunTaskを使います。

  1. Railsの動くアプリ用コンテナを用意する
  2. ワンオフ実行用のタスク定義を作る
  3. たとえばアプリサーバー用タスクであればコマンドを bundle exec rails -s するところ、ワンオフ実行用のタスクでは bundle exec rails -v するだけにする、Nginxなどのサイドカーはつけない、など。
  4. ワンオフ実行用のタスク定義を使って、 RunTask実行。
  5. コマンドのオーバーライドを使って実行するコマンドを指定し、 bundle exec rails -v を上書きする。
  6. タスクロールを、実行する操作に必要な権限を持ったIAMロールで上書きする
    • たとえば「ECS のスケジュールタスクを更新」するなら、ECSの設定を変更する権限が必要

CodePipeline にアクションの成功/失敗を伝える必要がある

CodePipelineのアクションとしてLambaファンクションを指定した場合、実行するLambdaファンクションでは、CodePipelineに対してアクションの実行が成功したか?それとも失敗したか?を伝える必要があります。

これは、Lambdaファンクションの入力イベントにCodePipelineのジョブIDを含むので、それを使って CodePipeline の PutJobSuccessResultPutJobFailureResult を実行します。

Lambdaファンクション

関数ハンドラ

たとえば、次のようなコードになります。

クラスタ名やタスク定義名はLambdaファンクションの環境変数で渡しています。CodePipelineのアクションを作成する際に、実行するLambdaファンクションと、そこに渡す環境変数を指定できます。

# functions/migrate_database/handler.rb
require "json"
require "aws-sdk-codepipeline"
require "aws-sdk-ecs"

def handler(event:, context:)
  puts "event: #{event.inspect}"
  puts "context: #{context.inspect}"

  codepipeline = Aws::CodePipeline::Client.new
  ecs = Aws::ECS::Client.new
  task_params = {
    cluster: ENV['CLUSTER_NAME'],
    count: 1,
    task_definition: ENV['TASK_DEFINITION'],
    launch_type: "FARGATE",
    network_configuration: {
      awsvpc_configuration: {
        subnets: [ENV['VPC_SUBNET_ID']],
        security_groups: [ENV['VPC_SG']],
        assign_public_ip: "DISABLED",
      },
    },
    overrides: {
      task_role_arn: ENV['TASK_ROLE_ARN'],
      container_overrides: [
        {
          name: 'rails',
          command: [
            "bundle",
            "exec",
            "rails",
            "db:create",
            "db:migrate",
            "db:version"
          ],
        },
      ]
    }
  }
  puts "task_params: #{task_params.inspect}"
  resp = ecs.run_task(task_params)
  puts "resp: #{resp.inspect}"
  ecs.wait_until(:tasks_running, cluster: ENV['CLUSTER_NAME'], tasks: [resp.tasks[0].task_arn])
  puts "complete"
  codepipeline.put_job_success_result({job_id: event["CodePipeline.job"]['id']})

  { statusCode: 200, body: JSON.dump(resp.tasks[0]) }
rescue => e
  message = "#{e.class.name} (#{e.message})"
  puts message
  codepipeline.put_job_failure_result({
    job_id: event["CodePipeline.job"]['id'],
    failure_details: {
      type: "JobFailed",
      message: message,
    }
  })

  { statusCode: 501, body: JSON.dump({error: message}) }
end
# functions/migrate_database/Gemfile

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem 'aws-sdk'

タスクロール

上記コードの task_role_arn: ENV['TASK_ROLE_ARN'], で与えているIAMロールで、LambdaファンクションからAWS SDKを通じて実行した ECSタスクに割り当てられるロールです。 Lambdaファンクションの実行ロールとは別です。

タスクロールはもともとのワンオフ実行用タスク定義で指定しているものだが、実行するECSタスクが何らかのAWSリソースに対する操作を行う場合、それに必要な権限を割り当てたIAMロールで上書きします。

ECSタスクに割り当てるロールなので、 ecs-tasks.amazonaws.com

    const deployElasticWheneverTaskRole = new iam.Role(
      this,
      "deployElasticWheneverTaskRole",
      {
        assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),  // ECSタスクで引き受けるので
        managedPolicies: [
          // たとえばECSタスク中で自身のクラスタを含むECSのフルアクセスを与えるならこんな
          iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonECS_FullAccess")
        ]
      }
    );
    deployElasticWheneverTaskRole.attachInlinePolicy(
      new iam.Policy(this, "deployElasticWheneverTaskRolePolicy", {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: [
              "iam:AttachRolePolicy",
              "iam:CreateRole",
              "iam:GetRole",
              "iam:PassRole",
              "events:ListRules"
            ],
            resources: ["*"]
          })
        ]
      })
    );

   // 省略
          lambda: new lambda.Function(this, "deployElasticWheneverFunction", {
          runtime: lambda.Runtime.RUBY_2_5,
          handler: "handler.handler",
          code: new lambda.AssetCode("./functions/deploy_elastic_whenever"),
          role: deployElasticWheneverFunctionRole,
          environment: {
            CLUSTER_NAME: ecsCluster.clusterName,
            TASK_DEFINITION: oneoffTaskDefinition.family,
            TASK_ROLE_ARN: deployElasticWheneverTaskRole.roleArn,  // ここで環境変数に渡している
            CONTAINER_NAME: "app",
            VPC_SUBNET_ID: vpc.privateSubnets[0].subnetId,
            VPC_SG: vpc.vpcDefaultSecurityGroup,
            REGION: cdk.Aws.REGION
          },
          timeout: cdk.Duration.seconds(900)
        })

ファンクションロール

Lambdaファンクションの実行に割り当てられるロールです。 紛らわしいですが、タスクロールは「ファンクションから実行したECSタスク」に割り当てられるロールであり、こちらは「ECSタスクを実行するファンクション」に割り当てるロールです。 今回は、ECSタスクを実行したいので ecs:DescribeTasks ecs:RunTask を許可してやる必要があります。

例えば次のような感じになります。

    const deployElasticWheneverFunctionRole = new iam.Role(
      this,
      "deployElasticWheneverFunctionRole",
      {
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),  // lambdaファンクションで引き受けるので
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambdaExecute")
        ]
      }
    );
    deployElasticWheneverFunctionRole.attachInlinePolicy(
      new iam.Policy(this, "deployElasticWheneverFunctionRolePolicy", {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: ["iam:PassRole", "ecs:DescribeTasks", "ecs:RunTask"], // ECSタスクの操作
            resources: ["*"]
          })
        ]
      })
    );

CodePipeline

抜粋します。

    // 入力は別途イメージビルド用のパイプラインでCodeCommitにpushした imagedefinitions.json
    // CodeCommit への push をトリガーにパイプラインを開始
    const deploySourceOutput = new codepipeline.Artifact();
    const deploySourceAction = new codepipeline_actions.CodeCommitSourceAction({
      actionName: "CodeCommit",
      repository: imageDefinitionsRepository,
      output: deploySourceOutput
    });

    // 各ECSサービスを更新するアクションを定義
    // まずアクションを定義して、最後にどんな順序で実行するか?を指定する。
    const deployOneoffAction = new codepipeline_actions.EcsDeployAction({
      actionName: "OneoffECS",
      service: oneoffEcsService,
      imageFile: new codepipeline.ArtifactPath(
        deploySourceOutput,
        "imagedefinitions.oneoff.json"  // ビルド用のパイプラインでCodeCommitにpushしたものを使う
      )
    });
    const deployAppAction = new codepipeline_actions.EcsDeployAction({
      actionName: "AppECS",
      service: service.service,
      imageFile: new codepipeline.ArtifactPath(
        deploySourceOutput,
        "imagedefinitions.app.json"
      ),
      runOrder: 1
    });
    const deployShoryukenAction = new codepipeline_actions.EcsDeployAction({
      actionName: "ShoryukenECS",
      service: shoryukenEcsService,
      imageFile: new codepipeline.ArtifactPath(
        deploySourceOutput,
        "imagedefinitions.shoryuken.json"
      ),
      runOrder: 1
    });

    const migrateDatabaseFunctionRole = new iam.Role(
      this,
      "migrateDatabaseFunctionRole",
      {
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambdaExecute")
        ]
      }
    );
    migrateDatabaseFunctionRole.attachInlinePolicy(
      new iam.Policy(this, "migrateDatabaseFunctionRolePolicy", {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: ["iam:PassRole", "ecs:DescribeTasks", "ecs:RunTask"],
            resources: ["*"]
          })
        ]
      })
    );
    const migrateAction = new codepipeline_actions.LambdaInvokeAction({
      actionName: "migrateDatabase",
      userParameters: {},
      lambda: new lambda.Function(this, "migrateDatabaseFunction", {
        runtime: lambda.Runtime.RUBY_2_5,
        handler: "handler.handler",
        code: new lambda.AssetCode("./functions/migrate_database"),
        role: migrateDatabaseFunctionRole,
        environment: {
          CLUSTER_NAME: ecsCluster.clusterName,
          TASK_DEFINITION: oneoffTaskDefinition.family,
          VPC_SUBNET_ID: vpc.privateSubnets[0].subnetId,
          VPC_SG: vpc.vpcDefaultSecurityGroup
        },
        timeout: cdk.Duration.seconds(900)
      })
    });

    const deployElasticWheneverFunctionRole = new iam.Role(
      this,
      "deployElasticWheneverFunctionRole",
      {
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambdaExecute")
        ]
      }
    );
    deployElasticWheneverFunctionRole.attachInlinePolicy(
      new iam.Policy(this, "deployElasticWheneverFunctionRolePolicy", {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: ["iam:PassRole", "ecs:DescribeTasks", "ecs:RunTask"],
            resources: ["*"]
          })
        ]
      })
    );
    const deployElasticWheneverTaskRole = new iam.Role(
      this,
      "deployElasticWheneverTaskRole",
      {
        assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com")
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonECS_FullAccess")
        ]
      }
    );
    deployElasticWheneverTaskRole.attachInlinePolicy(
      new iam.Policy(this, "deployElasticWheneverTaskRolePolicy", {
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            actions: [
              "iam:AttachRolePolicy",
              "iam:CreateRole",
              "iam:GetRole",
              "iam:PassRole",
              "events:ListRules"
            ],
            resources: ["*"]
          })
        ]
      })
    );
    const deployElasticWheneverAction = new codepipeline_actions.LambdaInvokeAction(
      {
        actionName: "deployElasticWhenever",
        userParameters: {},
        lambda: new lambda.Function(this, "deployElasticWheneverFunction", {
          runtime: lambda.Runtime.RUBY_2_5,
          handler: "handler.handler",
          code: new lambda.AssetCode("./functions/deploy_elastic_whenever"),
          role: deployElasticWheneverFunctionRole,
          environment: {
            CLUSTER_NAME: ecsCluster.clusterName,
            TASK_DEFINITION: oneoffTaskDefinition.family,
            TASK_ROLE_ARN: deployElasticWheneverTaskRole.roleArn,
            CONTAINER_NAME: "app",
            VPC_SUBNET_ID: vpc.privateSubnets[0].subnetId,
            VPC_SG: vpc.vpcDefaultSecurityGroup,
            REGION: cdk.Aws.REGION
          },
          timeout: cdk.Duration.seconds(900)
        })
      }
    );

    // 最後にここまでに作ったアクションを組み合わせてパイプラインを構成
    const deployPipeline = new codepipeline.Pipeline(this, "deployPipeline", {
      pipelineName: `DeployPipeline-${railsEnv}`,
      stages: [
        {
          stageName: "Source",
          actions: [deploySourceAction]
        },
        {
          stageName: "BeforeMigration",
          actions: [deployOneoffAction]
        },
        {
          stageName: "Migration",
          actions: [migrateAction]
        },
        {
          stageName: "Deploy",
          actions: [
            deployAppAction,
            deployShoryukenAction,
            deployElasticWheneverAction
          ]
        }
      ]
    });