まとめ
CodePipeline のアクションとして AWS Lambda を実行すれば、いろいろなことができる
たとえばRails のdb:migrateをデプロイ時に自動設定したり、ECSスケジュールタスクを登録したり
Lambdaのタスクロールを適切に指定すればAWS アクセスキーなしにAWS SDK が使えて便利
CDKを使えばLambdaファンクションのソースコード のバージョン管理も捗る
CodePipeline実行の様子
事の起こり
弊社受託案件のうちいくつかは Rails + AWS CodePipeline + ECS による継続的デリバリ環境で稼働しています。
AWS CodePipeline は処理内容と、それどういった順番で実行するかを定義することで、柔軟なリリース自動化が可能です。
処理内容はたとえば、
Gitからソースコード をチェックアウトする
CodeBuildを使ってDockerイメージのビルドをしてECRにpushする
ECSのローリングアップデートを行う
などです。
今回このパイプラインのなかで、
Rails の db:migrate したい
ECS のスケジュールタスクを更新したい
することにしました。
作業メモ
ECSのコンテナを使って db:migrate するには?
ECSのRunTask を使います。
Rails の動くアプリ用コンテナを用意する
ワンオフ 実行用のタスク定義を作る
たとえばアプリサーバー用タスクであればコマンドを bundle exec rails -s
するところ、ワンオフ 実行用のタスクでは bundle exec rails -v
するだけにする、Nginxなどのサイドカー はつけない、など。
ワンオフ 実行用のタスク定義を使って、 RunTask実行。
コマンドのオーバーライドを使って実行するコマンドを指定し、 bundle exec rails -v
を上書きする。
タスクロールを、実行する操作に必要な権限を持ったIAMロールで上書きする
たとえば「ECS のスケジュールタスクを更新」するなら、ECSの設定を変更する権限が必要
CodePipeline にアクションの成功/失敗を伝える必要がある
CodePipelineのアクションとしてLambaファンクションを指定した場合、実行するLambdaファンクションでは、CodePipelineに対してアクションの実行が成功したか?それとも失敗したか?を伝える必要があります。
これは、Lambdaファンクションの入力イベントにCodePipelineのジョブIDを含むので、それを使って CodePipeline の PutJobSuccessResult か PutJobFailureResult を実行します。
Lambdaファンクション
関数ハンドラ
たとえば、次のようなコードになります。
クラスタ 名やタスク定義名はLambdaファンクションの環境変数 で渡しています。CodePipelineのアクションを作成する際に、実行するLambdaファンクションと、そこに渡す環境変数 を指定できます。
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
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
]
}
]
});