0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【CICD入門】AWSでシンプルなCICDを作る(第2回:構築編)

0
Posted at

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の認証を完了できないため、ここだけコンソールで操作します。

  1. CodePipelineのコンソールで「設定」→「接続」を開く
  2. 作成された接続を選び「保留中の接続を更新」
  3. GitHubの認証画面で、対象のリポジトリへアクセスを許可する
  4. 状態が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)と、失敗に気付くための監視を扱います。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?