Bootstrap / CI Stackを作ってPipelineを通す(構築編)
🐈🐈🐈 作成した内容は👆こちら 🐈🐈🐈
1. はじめに
前回の記事では、AWS標準機能だけで組んだLambdaのCI/CDテンプレートについて、全体像と設計の方針を書きました。Stackを3つに分けること、持たない仕組みを先に決めること、名前をStack名から決めることの3つです。
今回はその続きとして、実際にAWSへ土台を作ります。GitHubへpushするとLambdaが更新される状態まで進めます。
やりたいこと
- Pipelineを動かすための土台を、環境ごとに1回だけ作る
- Pipelineに強すぎる権限を持たせない
- 構築そのものにCI/CDを使わない(Pipelineが壊れたときにPipelineで直す状況を避ける)
この構成のポイント
CI/CDで最初につまずきやすいのはIAMです。「どのRoleに何を許可すればよいのか分からない」「とりあえず広めに許可して動かした」という状態になりがちです。
このテンプレートでは、権限を2つのRoleに分けて整理しました。今回はその考え方を中心に書きます。
2. 今回作るもの
今回の構成
管理者がAWS CloudShellから2つのStackを作ります。Lambda本体は作りません。
[管理者がCloudShellから作る]
Bootstrap Stack
CloudFormation Execution Role Lambdaなどを実際に作るRole
Deployment Bucket SAMの資材置き場(S3)
│ Role ARNとBucket名を渡す(Export)
▼
CI Stack
GitHub Connection GitHubとの接続
CodePipeline Source → Test → Build → Deploy
CodeBuild × 3 Test / Build / Deploy
SNS Topic 通知先
[このあとPipelineが作る]
Runtime Stack
Lambda、実行Role、Log Group、Alarm、Scheduler
作業の流れ
| 手順 | 内容 | AWSへの変更 |
|---|---|---|
| 1 | リポジトリをZIPにしてCloudShellへ転送する | なし |
| 2 | Project名、環境、Regionを確認する | なし |
| 3 | 接続先とTemplateを確認する | なし |
| 4 | Bootstrap Stackを作成する | あり |
| 5 | CI Stackを作成する | あり |
| 6 | 出力を確認する | なし |
| 7 | GitHub Connectionを認証する(コンソール) | あり |
| 8 | 通知の受け取り先を登録する | あり |
| 9 | Pipelineを動かす前に確認する | なし |
CloudShellを使うのは、開発端末にAWS CLIの設定を持たせないためです。ブラウザだけで作業が完結します。
3. なぜStackを2つに分けるのか
Bootstrap StackとCI Stackは、どちらも管理者が作ります。1つにまとめてもよさそうですが、分けています。
| Stack | 持つもの | 変更の頻度 |
|---|---|---|
| Bootstrap | 権限(Execution Role)と、資材置き場 | ほぼ変えない |
| CI | 仕組み(Pipeline、CodeBuild、接続、通知) | CI構成を変えるときに触る |
分ける理由は、変更の理由が違うからです。「Pipelineの構成を変えたい」ときに権限の定義まで一緒に更新されると、変更の影響範囲が読みにくくなります。
もう1つ実利があります。Bootstrap StackはCI Stackを削除しても残るため、CI Stackだけを作り直せます。Pipelineの定義を大きく変えたいとき、権限とS3の資材を保ったままCI Stackを削除して作り直せます。
4. 権限の二段構え
ここが今回の中心です。
「Pipelineに全部やらせる」をやめる
素直に作ると、こうなります。
CodeBuild(Deploy)が、Lambda・IAM Role・Alarmを直接作る
この場合、CodeBuildのRoleに「Lambdaを作る権限」「IAM Roleを作る権限」を持たせることになります。IAM Roleを作れる権限は実質的に何でもできる権限です。ビルド用のコンテナがそれを持つのは避けたいところです。
このテンプレートでは、間にCloudFormationを挟みます。
CodeBuild(Deploy Role)
│ sam deploy --role-arn <Execution RoleのARN>
▼
CloudFormation
│ 渡されたRoleを引き受けて実行
▼
CloudFormation Execution Role
│
▼
Lambda / IAM Role / Log Group / Alarm を作成
役割はこう分かれます。
| Role | 置き場所 | できること | できないこと |
|---|---|---|---|
| Deploy Role | CI Stack | Change Setを作って実行する。S3へ資材を置く | Lambdaを直接作る、IAM Roleを直接作る |
| Execution Role | Bootstrap Stack | Lambda、IAM Role、Alarm等を作る | 自分から動くことはできない(CloudFormationしか引き受けられない) |
Deploy Roleは「デプロイを指示する役」、Execution Roleは「実際に作る役」です。Execution Roleは、CloudFormationだけが引き受けられます。
ExecutionRole:
Type: AWS::IAM::Role
Properties:
Path: !Sub '/${ProjectName}/${Environment}/cloudformation/'
# このRoleを引き受けられるのはCloudFormationだけ
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: cloudformation.amazonaws.com
Action: sts:AssumeRole
渡せるRoleを限定する
Deploy Roleが「別のもっと強いRole」をCloudFormationへ渡せてしまうと、この分離は意味を失います。そこで、渡せるRoleを条件で固定します。
- Sid: CreateRuntimeChangeSets
Effect: Allow
Action: cloudformation:CreateChangeSet
Resource: !Sub 'arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${ProjectName}-${Environment}-*/*'
Condition:
# この条件により、Execution Role以外を渡したChange Setは作れない
ArnEquals:
cloudformation:RoleArn:
Fn::ImportValue: !Sub '${ProjectName}-${Environment}-bootstrap-RuntimeExecutionRoleArn'
Fn::ImportValueは、別のStackが公開(Export)した値を取り込む書き方です。Execution RoleのARNを人が転記する必要はありません。ARNをファイルに書かずに済むので、Account IDがGitに残りません。
作れるものを名前で絞る
Execution Role側も、何でも作れるわけではありません。名前で範囲を絞っています。
- Sid: ManageRuntimeLambda
Effect: Allow
Action:
- lambda:CreateFunction
- lambda:UpdateFunctionCode
# ...
# example-project-dev- で始まる名前のLambdaだけ
Resource: !Sub 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:${ProjectName}-${Environment}-*'
前回「名前をStack名から決める」と書いたのは、この許可の書き方をするためです。名前が規則的だから、範囲を名前で表現できます。
IAM Roleだけは名前ではなくPathで絞っています。
| 対象 | 絞り方 | 範囲 |
|---|---|---|
| Lambda、Alarm、Schedule等 | 名前の前方一致 | example-project-dev-* |
| IAM Role | Path |
/example-project/dev/runtime/ 配下 |
Pathは、IAM Roleを分類するためのディレクトリのような文字列です。Runtime StackのRoleは全部このPathに置き、Bootstrap自身のRole(/cloudformation/)やCI StackのRole(/ci/)とは別の場所にしています。
これにより、Runtime TemplateからCI側のRoleの権限を書き換えることはできません。 名前だけで絞っていると、うっかり同じ名前を付けたときに範囲が重なります。Pathならその心配がありません。
5. Bootstrap Stackを作る
CloudShellで、リポジトリを展開したディレクトリから実行します。
aws cloudformation deploy \
--template-file infrastructure/bootstrap/template.yaml \
--stack-name "example-project-dev-bootstrap" \
--capabilities CAPABILITY_IAM \
--parameter-overrides "file://infrastructure/env/dev/setup-parameters.json" \
--tags Project=example-project Environment=dev ManagedBy=CloudFormation \
--no-fail-on-empty-changeset \
--no-cli-pager
数分で完了します。
Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - example-project-dev-bootstrap
--capabilities CAPABILITY_IAMは「このTemplateはIAM Roleを作ります」という明示的な同意です。付けないとCloudFormationが拒否します。
--tagsはStackにタグを付けます。CloudFormationが各Resourceへ自動で伝播させるため、Templateの中にタグを書きません。書く場所が1か所になり、付け忘れがなくなります。
Bootstrap Stackが作るのは、Execution RoleとS3 Bucketの2つだけです。この2つは他のStackから使われるため、Outputsで公開しておきます。
Outputs:
RuntimeExecutionRoleArn:
Value: !GetAtt ExecutionRole.Arn
Export:
Name: !Sub '${ProjectName}-${Environment}-bootstrap-RuntimeExecutionRoleArn'
6. CI Stackを作る
コマンドの形は同じです。Templateだけが変わります。
aws cloudformation deploy \
--template-file infrastructure/ci/template.yaml \
--stack-name "example-project-dev-ci" \
--capabilities CAPABILITY_IAM \
--parameter-overrides "file://infrastructure/env/dev/setup-parameters.json" \
--tags Project=example-project Environment=dev ManagedBy=CloudFormation \
--no-fail-on-empty-changeset \
--no-cli-pager
渡している設定値は4つだけです。
[
{ "ParameterKey": "ProjectName", "ParameterValue": "example-project" },
{ "ParameterKey": "Environment", "ParameterValue": "dev" },
{ "ParameterKey": "GitHubRepositoryId", "ParameterValue": "example-owner/example-repository" },
{ "ParameterKey": "BranchName", "ParameterValue": "develop" }
]
Account IDもExecution RoleのARNもここにありません。前者はCloudFormationがAWS::AccountIdで取得し、後者はBootstrap StackのExportから取り込むためです。
Pipelineの定義
Stageは4つです。
Pipeline:
Type: AWS::CodePipeline::Pipeline
Properties:
Name: !Sub '${ProjectName}-${Environment}-pipeline'
# 実行中に新しいpushが来ても前の実行を止めず、順番待ちにする
PipelineType: V2
ExecutionMode: QUEUED
# CI Stackを更新しただけでPipelineが動き出さないようにする
RestartExecutionOnUpdate: false
ExecutionMode: QUEUEDは地味ですが重要です。既定の設定では、連続してpushしたときに後の実行が先の実行を追い越すことがあります。デプロイが並行して走ると、同じStackを同時に更新して失敗します。QUEUEDなら順番待ちになります。
| Stage | 何をするか | 失敗したら |
|---|---|---|
| Source | GitHubからコードを取得 | 以降は動かない |
| Test | pytestを実行 | AWSは何も変わらない |
| Build | SAMでパッケージを作る | AWSは何も変わらない |
| Deploy |
sam deployでStackを更新 |
途中まで配備された状態になり得る |
TestとBuildを通ってからでないとDeployへ進まないため、壊れたコードがAWSへ届きません。
GitHub Connectionの認証
CI StackはGitHubとの接続(CodeConnections)も作ります。ただし作成直後はPENDING(未認証)の状態です。
CloudFormationからはGitHubの認証を完了できないため、ここだけコンソールで操作します。
- CodePipelineのコンソールで「設定」→「接続」を開く
- 作成された接続を選び「保留中の接続を更新」
- GitHubの認証画面で、対象のリポジトリへアクセスを許可する
- 状態が
AVAILABLEになることを確認する
GitHubのTokenをParameterで渡す方式にしていないのは、秘密情報をGitやCloudFormationのParameterに置かないためです。認証情報はCodeConnectionsの中に保持されます。
7. 初回デプロイ
ここまでで土台はできました。あとは対象branchへpushするだけです。
push
↓
Source GitHubから取得
↓
Test pytest 251件 成功
↓
Build sam build(Runtimeの数だけ実行)
↓
Deploy sam deploy
└ CloudFormationがExecution Roleを引き受けてResourceを作成
↓
Runtime Stack が作られる
Deployが成功すると、CloudFormationのコンソールにexample-project-dev-daily-reportのようなStackが増えています。中を見ると、Lambda、実行Role、Log Group、Alarm、Schedulerが入っています。
Lambdaを1つ追加したいときは、application/functions/とinfrastructure/runtimes/にディレクトリを1つずつ足してpushするだけです。Pipelineの設定は変えません。
8. つまずいたところ
構築は一度で通りませんでした。実際に出たエラーを3つ載せます。どれもIAM周りで、CI/CDを自作すると必ず通る道だと思います。
AccessDenied:条件を付けすぎて拒否される
Deployで次のエラーが出ました。
User: arn:aws:sts::123456789012:assumed-role/example-project-dev-ci-DeployRole-XXXX/AWSCodeBuild
is not authorized to perform: cloudformation:DescribeChangeSet
DescribeChangeSetは許可していたので、最初は意味が分かりませんでした。原因は条件(Condition)の書き方です。
# 当初の書き方(Change Set名で絞っていた)
Action:
- cloudformation:DescribeChangeSet
Condition:
StringLike:
cloudformation:ChangeSetName: samcli-deploy*
SAM CLIは、Change Setを作るときは名前で指定しますが、その後の参照はChange SetのARNで行います。ARNで呼ばれるとcloudformation:ChangeSetNameの条件が評価されず、条件に一致しないため拒否されます。
対処として、参照・実行・削除からは名前の条件を外し、代わりに対象Stackで範囲を絞りました。そのうえで、Bootstrap StackとCI Stack自身への操作は明示的に拒否しています。
- Sid: DenySetupStackChangeSets
Effect: Deny
Action:
- cloudformation:CreateChangeSet
- cloudformation:ExecuteChangeSet
Resource:
- !Sub '...:stack/${ProjectName}-${Environment}-bootstrap/*'
- !Sub '...:stack/${ProjectName}-${Environment}-ci/*'
許可を広げたときは、広がった範囲を明示的に塞ぐ、という形にしました。
AccessDenied:作る前の存在確認で止まる
次はExecution Role側です。
User: .../AWSCloudFormation is not authorized to perform: iam:GetRole
on resource: arn:aws:iam::123456789012:role/example-project-dev-daily-report-execution
iam:GetRoleは許可していました。ただしPathで絞っていました。
Resource: arn:aws:iam::123456789012:role/example-project/dev/runtime/*
CloudFormationは、名前を指定したIAM Roleを作る前に「同じ名前のRoleが既にないか」を確認します。このとき、まだ存在しないRoleにはPathがありません。そのため確認はPathを含まないARNに対して行われ、上の許可に一致せず拒否されました。
対処は、名前の範囲に限って読み取りだけを許可するStatementを足すことです。
- Sid: CheckRuntimeRoleNames
Effect: Allow
Action:
- iam:GetRole
- iam:GetRolePolicy
# Pathを含まない形。作成前の存在確認はこちらで評価される
Resource: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${ProjectName}-${Environment}-*'
読み取りだけなので、権限が広がりすぎることはありません。
ROLLBACK_COMPLETEから進めなくなる
権限を直して再実行したところ、今度はこう言われました。
Stack:example-project-dev-daily-report is in ROLLBACK_COMPLETE state and can not be updated.
ROLLBACK_COMPLETEは、初回作成に失敗して、作りかけのResourceを片付け終えた状態です。空の器だけが残っていて、この状態のStackは更新できません。
削除してから作り直します。
aws cloudformation delete-stack --stack-name "example-project-dev-daily-report"
aws cloudformation wait stack-delete-complete --stack-name "example-project-dev-daily-report"
紛らわしいのはUPDATE_ROLLBACK_COMPLETE(更新に失敗して前の状態へ戻った)です。こちらはそのまま更新できます。似た名前ですが対処が違います。
| 状態 | 意味 | 対処 |
|---|---|---|
ROLLBACK_COMPLETE |
初回作成に失敗した | 削除してから作り直す |
UPDATE_ROLLBACK_COMPLETE |
更新に失敗して前の状態に戻った | 原因を直して再実行 |
エラーの読み方
IAMのエラーは長くて読みにくいのですが、見る場所は決まっています。
User: arn:aws:sts::123456789012:assumed-role/<Role名>/<セッション名>
is not authorized to perform: <Action>
on resource: <Resource ARN>
| 見る場所 | 分かること |
|---|---|
assumed-role/の直後 |
どのRoleが足りないか(Deploy RoleかExecution Roleか) |
to perform: |
追加すべきAction |
on resource: |
許可のResourceに一致しているか |
Roleの名前を見れば、CI Stack側とBootstrap Stack側のどちらを直すかが分かります。Bootstrap Stackの権限を変えたときは、管理者が手動でBootstrap Stackを更新する必要があります(Pipelineは更新しません)。この更新忘れで同じエラーが続く、というのもよくある足踏みです。
9. おわりに
今回は、Pipelineを動かす土台の作り方と、権限の分け方を書きました。
まとめると、権限の設計でやったことは3つです。
- 作る役(Execution Role)と指示する役(Deploy Role)を分ける
- 渡せるRoleを条件で固定する
- 作れるResourceを名前とPathで絞る
「とりあえず広めに許可する」を避けると、最初は今回のようなAccessDeniedを何度か踏みます。ただしそのたびにどのRoleに何が必要かが1件ずつはっきりします。エラーメッセージがそのまま設計の答えになるので、悪くない進め方だと思います。
次回は、pushしたコードが実際に切り替わる仕組み(VersionとAlias)と、失敗に気付くための監視を扱います。