CloudFormation で構築した CodePipeline を使って AWS SAM プロジェクトを自動デプロイする

TIL
January 1, 2023

いまさらだが、CloudFormation で構築した CodePipeline を使って AWS SAM プロジェクトを自動デプロイする流れを追った。
どんな登場人物がいて、それぞれが果たす役割を理解したかったためである。

現在は、自分で CloudFormation のスタックを作って CodePipeline を構築しなくても、SAM Pipeline を使うと AWS SAM CLI のコマンドを数回実行するだけで CodePipeline を作ることができる。

全体の構成

次の図は、CodePipeline の構成と Pipeline におけるビルドとデプロイの流れを示している。
図:CodePipeline の構成と処理の流れを示している

動作環境

  • AWS CLI v2.9.11
  • AWS SAM CLI v1.67.0

STEP1:SAM プロジェクトの作成

SAM プロジェクトの作成

AWS SAM CLI を使って、SAM プロジェクトを作成する。
sam init を実行すると、Lambda 関数のテンプレートと SAM の設定ファイルが作成される。これらのファイル群を GitHub リポジトリで管理する。
CodePipeline を構築した後は、GitHub への push を検知すると、パイプライン上でビルドやデプロイが自動で行なわれるようになる。

$ sam init --runtime nodejs18.x --name hello-world

...略...
Cloning from https://github.com/aws/aws-sam-cli-app-templates (process may take a moment)

    -----------------------
    Generating application:
    -----------------------
    Name: hello-world
    Runtime: nodejs18.x
    Architectures: x86_64
    Dependency Manager: npm
    Application Template: hello-world
    Output Directory: .

    Next steps can be found in the README file at ./hello-world/README.md

...略...

コマンド実行後に作成されたファイルは次のとおり。

hello-world
├── README.md
├── events
│   └── event.json
├── hello-world # Lambda 関数の本体
│   ├── app.mjs
│   ├── package.json
│   └── tests
│       └── unit
│           └── test-handler.mjs
└── template.yaml # AWS SAM テンプレート

今回は、この初期テンプレートの内容をそのまま利用する。
GET リクエストを送ると hello world を返す REST API である。

buildspec.yml の作成

プロジェクトのディレクトリ下に、次の内容の buildspec.yml を作成する。
パイプラインにおける Build Stage では、このファイルに記載された内容に従って処理が行なわれる。
今回は、aws cloudformation package が実行されるため、AWS SAM テンプレート(template.yaml)を利用して、次のファイルが S3 にアップロードされる。

  • Lambda 関数のソースコード
  • template.yaml を元に生成した SAM デプロイテンプレート(deploy_template.yml

環境変数の $BUCKET_NAME は、この後の手順で構築する CodePipeline に設定する。

buildspec.yml
version: 0.2

phases:
  build:
    commands:
      - aws cloudformation package --template-file template.yml \
        --s3-bucket $BUCKET_NAME --output-template-file deploy_template.yml

artifacts:
  files:
    - deploy_template.yml

STEP2:CloudFormation テンプレートファイルの作成

次の 4 つの CloudFormation テンプレートファイルを作成する。

  • github-connection.yml
    GitHub と連携する CodeStarConnections を作成するスタック
  • artifactstore.yml
    Source Stage や Build Stage の成果物を置く S3 を作成するスタック
  • role.yml
    CodePipeline のための IAM ロールを作成するスタック
  • pipeline.yml
    CodePipeline を作成するスタック

github-connection.yml

github-connection.yml では、GitHub と連携する CodeStarConnections を作成する。
参考:AWS::CodeStarConnections::Connection
ProviderType で「GitHub」を設定すると、GitHub と連携できるようになる。
なお、GitHub アカウント名やリポジトリ名は、pipeline.yml で設定する。

github-connection.yml
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  SystemName:
    Type: String
    Default: example # 任意の値に変更する

Resources:
  SourceConnection:
    Type: "AWS::CodeStarConnections::Connection"
    Properties:
      ConnectionName: !Sub "${SystemName}-connection"
      ProviderType: GitHub

Outputs:
  SourceConnection:
    Value: !Ref SourceConnection
    Export:
      Name: !Sub "${SystemName}-connection-arn"

artifactstore.yml

artifactstore.yml では、Source Stage や Build Stage の成果物を置く S3 バケットを作成する。
参考:AWS::S3::Bucket

artifactstore.yml
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  SystemName:
    Type: String
    Default: example # 任意の値に変更する

Resources:
  ArtifactStore:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: !Sub "${AWS::Region}-${AWS::AccountId}-${SystemName}"
      AccessControl: Private
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        IgnorePublicAcls: True
        BlockPublicPolicy: True
        RestrictPublicBuckets: True
      LifecycleConfiguration:
        Rules:
          - Id: !Sub "${SystemName}-artifact-lifecycle"
            Status: Enabled
            ExpirationInDays: 1
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 1
            NoncurrentVersionExpiration:
              NewerNoncurrentVersions: 1
              NoncurrentDays: 1

Outputs:
  ArtifactStore:
    Value: !Ref ArtifactStore
    Export:
      Name: !Sub "${SystemName}-articactstore"
  ArtifactStoreARN:
    Value: !GetAtt ArtifactStore.Arn
    Export:
      Name: !Sub "${SystemName}-articactstore-arn"

role.yml

role.yml では、CodePipeline のための IAM ロールを作成する。
参考:AWS::IAM::Role

  • CodePipeLineRole を構築する IAM ロール
  • CodeBuild を実行する IAM ロール
  • CodeDeploy を実行する IAM ロール

いくつかの Role で FullAccess なポリシーを適用しているため、本当は必要最低限なポリシーに絞ったほうが良い。

role.yml
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  SystemName:
    Type: String
    Default: example # 任意の値に変更する

Resources:
  CodePipeLineRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub "${SystemName}-codepipeline-role"
      Path: "/service-role/"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
        - Effect: Allow
          Action:
            - sts:AssumeRole
          Principal:
            Service:
            - codepipeline.amazonaws.com
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AWSCloudFormationFullAccess"
      Policies:
        - PolicyName: !Sub "${SystemName}-codepipeline-policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "codestar-connections:UseConnection"
                Resource:
                  - {"Fn::ImportValue": !Sub "${SystemName}-connection-arn"}
                  - !Join
                    - "/"
                    - - {"Fn::ImportValue": !Sub "${SystemName}-connection-arn"}
                      - "*"
              - Effect: Allow
                Action:
                  - "s3:*"
                Resource:
                  - {"Fn::ImportValue": !Sub "${SystemName}-articactstore-arn"}
                  - !Join
                    - "/"
                    - - {"Fn::ImportValue": !Sub "${SystemName}-articactstore-arn"}
                      - "*"
              - Effect: Allow
                Action:
                  - "codebuild:StartBuild"
                  - "codebuild:BatchGetBuilds"
                Resource:
                  - !Sub "arn:aws:codebuild:${AWS::Region}:${AWS::AccountId}:project/${SystemName}-build"
              - Effect: Allow
                Action:
                  - "iam:PassRole"
                Resource: "*"
  CodeBuildRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub "${SystemName}-codebuild-role"
      Path: "/service-role/"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
        - Effect: Allow
          Action:
            - sts:AssumeRole
          Principal:
            Service:
            - codebuild.amazonaws.com
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AmazonS3FullAccess"
      Policies:
        - PolicyName: !Sub "${SystemName}-codebuild-policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "logs:CreateLogGroup"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource:
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/*"
                  - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/*:*"
              - Effect: Allow
                Action:
                  - "cloudformation:ValidateTemplate"
                Resource: "*"
  CodeDeployRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub "${SystemName}-deploy-role"
      Path: "/service-role/"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
        - Effect: Allow
          Action:
            - sts:AssumeRole
          Principal:
            Service:
            - cloudformation.amazonaws.com
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AWSLambda_FullAccess"
      Policies:
        - PolicyName: !Sub "${SystemName}-codedeploy-policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - iam:CreateRole
                  - iam:DeleteRole
                  - iam:GetRole
                  - iam:PassRole
                  - iam:DeleteRolePolicy
                  - iam:AttachRolePolicy
                  - iam:DetachRolePolicy
                  - iam:PutRolePolicy
                  - iam:GetRolePolicy
                Resource:
                  - !Sub "arn:aws:iam::${AWS::AccountId}:role/*"
              - Effect: Allow
                Action:
                  - "cloudformation:CreateChangeSet"
                  - "cloudformation:ExecuteChangeSet"
                Resource:
                  - !Sub arn:aws:cloudformation:${AWS::Region}:aws:transform/Serverless-2016-10-31
              - Effect: Allow
                Action:
                  - "s3:GetObject"
                Resource:
                  - {"Fn::ImportValue": !Sub "${SystemName}-articactstore-arn"}
                  - !Join
                    - "/"
                    - - {"Fn::ImportValue": !Sub "${SystemName}-articactstore-arn"}
                      - "*"
              - Effect: Allow
                Action:
                  - "apigateway:GET"
                  - "apigateway:PUT"
                  - "apigateway:POST"
                  - "apigateway:PATCH"
                  - "apigateway:DELETE"
                Resource:
                  - !Sub "arn:aws:apigateway:${AWS::Region}::/restapis"
                  - !Sub "arn:aws:apigateway:${AWS::Region}::/restapis/*"

pipeline.yml

pipeline.yml では、CodePipeline を作成する。 具体的には、次の 2 つを行なっている。

ビルドプロジェクトの作成では、Source Stage の成果物を置いた S3 バケットの指定や、ビルドを実行するコンテナの定義や環境変数を定義する。

パイプラインの作成では、次の 3 つのステージで何を行うかを設定する。

  • Source Stage
    CodeStarConnections で接続する GitHub の情報を設定する
  • Build Stage
    同じファイル内で定義したビルドプロジェクトを呼び出す
  • Deploy Stage
    SAM デプロイテンプレートを使って構築した CloudFormation で、Lambda 関数をデプロイする
pipeline.yml
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  SystemName:
    Type: String
    Default: example # 任意の値に変更する
  RepositoryOwner:
    Type: String
    Default: chick-p # SAM プロジェクトを置いた GitHub アカウント名
  RepositoryName:
    Type: String
    Default: practice-sam # SAM プロジェクトを置いた GitHub リポジトリ名
  BranchName:
    Type: String
    Default: main # デプロイするブランチ

Resources:
  CodeBuild:
    Type: "AWS::CodeBuild::Project"
    Properties:
      Name: !Sub "${SystemName}-build"
      BadgeEnabled: false
      Source:
        Type: "S3"
        InsecureSsl: false
        Location: !Join
          - "/"
          - - {"Fn::ImportValue": !Sub "${SystemName}-articactstore"}
            - "index.zip"
      Artifacts:
        Type: "S3"
        Location: {"Fn::ImportValue": !Sub "${SystemName}-articactstore"}
        NamespaceType: "NONE"
        OverrideArtifactName: false
        Packaging: "ZIP"
        Path: ""
      Cache:
        Type: "NO_CACHE"
      Environment:
        Type: "LINUX_CONTAINER"
        ComputeType: "BUILD_GENERAL1_SMALL"
        Image: "aws/codebuild/standard:3.0"
        PrivilegedMode: false
        EnvironmentVariables:
          - Name: "BUCKET_NAME"
            Type: "PLAINTEXT"
            Value: {"Fn::ImportValue": !Sub "${SystemName}-articactstore"}
      ServiceRole: !Sub "arn:aws:iam::${AWS::AccountId}:role/service-role/${SystemName}-codebuild-role"
      TimeoutInMinutes: 5
      QueuedTimeoutInMinutes: 60
      LogsConfig:
        CloudWatchLogs:
          Status: "DISABLED"
        S3Logs:
          Status: "DISABLED"
  CodePipeline:
    Type: "AWS::CodePipeline::Pipeline"
    Properties:
      Name: !Sub ${SystemName}-pipeline
      RoleArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/service-role/${SystemName}-codepipeline-role"
      ArtifactStore:
        Type: "S3"
        Location: {"Fn::ImportValue": !Sub "${SystemName}-articactstore"}
      Stages:
        - Name: "Source"
          Actions:
            - Name: SourceAction
              ActionTypeId:
                Category: Source
                Owner: AWS
                Provider: "CodeStarSourceConnection"
                Version: 1
              Configuration:
                ConnectionArn: {'Fn::ImportValue': !Sub '${SystemName}-connection-arn'}
                FullRepositoryId: !Sub "${RepositoryOwner}/${RepositoryName}"
                BranchName: !Sub "${BranchName}"
                OutputArtifactFormat: "CODE_ZIP"
              OutputArtifacts:
                - Name: "SourceArtifact"
              Region: !Ref AWS::Region
              Namespace: "SourceVariables"
              RunOrder: 1
        - Name: "Build"
          Actions:
            - Name: BuildAction
              ActionTypeId:
                Category: "Build"
                Owner: "AWS"
                Provider: "CodeBuild"
                Version: 1
              Configuration:
                ProjectName: !Ref CodeBuild
              InputArtifacts:
                - Name: "SourceArtifact"
              OutputArtifacts:
                - Name: "BuildArtifact"
              Region: !Ref AWS::Region
              Namespace: "BuildVariables"
              RunOrder: 2
        - Name: "Deploy"
          Actions:
            - Name: DeployAction
              ActionTypeId:
                Category: "Deploy"
                Owner: "AWS"
                Provider: "CloudFormation"
                Version: 1
              Configuration:
                ActionMode: "CREATE_UPDATE"
                Capabilities: "CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND"
                RoleArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/service-role/${SystemName}-deploy-role"
                StackName: !Sub "${SystemName}-deploy"
                TemplatePath: "BuildArtifact::deploy_template.yml"
              InputArtifacts:
                - Name: BuildArtifact
              Region: !Ref AWS::Region
              Namespace: "DeployVariables"
              RunOrder: 1

STEP3:CloudFormation のスタックの作成

STEP2 で作成した CloudFormation テンプレートを使って、CloudFormation のスタックを 4 つ作成する。
これらのスタックをネストさせて束ねる方法もあるが、テンプレートファイルを S3 に置く必要があるため、今回は別々にスタックを作成する。

github-connection.yml のスタック

# スタックを作成する
$ aws cloudformation create-stack \
  --stack-name example-github-connection --template-body file://github-connection.yml

{
    "StackId": "arn:aws:cloudformation:ap-northeast-1:123456789012:stack/sam-example/12345678-1234-1234-1234-123434567890"
}

# CodestarConnections が作成されたことを確認する
$ aws codestar-connections list-connections | jq '.Connections[].ConnectionName'

"example-connection"

artifactstore.yml のスタック

# スタックを作成する
$ aws cloudformation create-stack \
  --stack-name example-artifactstore --template-body file://artifactstore.yml

{
    "StackId": "arn:aws:cloudformation:ap-northeast-1:123456789012:stack/sam-example/12345678-1234-1234-1234-123434567890"
}

# バケットが作成されたことを確認する
$  aws s3 ls

2023-01-01 16:13:18 ap-northeast-1-123456789012-example

role.yml のスタック

# スタックを作成する
$ aws cloudformation create-stack \
  --stack-name example-role --template-body file://role.yml --capabilities CAPABILITY_NAMED_IAM

{
    "StackId": "arn:aws:cloudformation:ap-northeast-1:123456789012:stack/sam-example/12345678-1234-1234-1234-123434567890"
}

# IAM ロールが作成されたことを確認する
$ aws iam list-roles --path-prefix /service-role/ | jq '.Roles[].RoleName'

"example-codebuild-role"
"example-codepipeline-role"
"example-deploy-role"

pipeline.yml のスタック

# スタックを作成する
$ aws cloudformation create-stack \
  --stack-name example-pipeline --template-body file://pipeline.yml

{
    "StackId": "arn:aws:cloudformation:ap-northeast-1:123456789012:stack/sam-example/12345678-1234-1234-1234-123434567890"
}

# Pipeline が作成されたことを確認する
$ aws codepipeline list-pipelines | jq '.pipelines[].name'

"example-pipeline"

STEP4:CodeStar Connection の有効化

AWS Console でパイプラインを確認しても、この時点ではパイプラインが失敗している。
これは、GitHub との接続が有効になっていないためである。
スクリーンショット:SourceStage のパイプラインが失敗している

GitHub リポジトリに AWS Connecter for GitHub をインストールし、GitHub の連携を有効化する。
この操作は、AWS CLI では操作できないため、AWS Console を使う。

  1. デベロッパー用ツール | 接続 にアクセスする
  2. 作成した CodeStar Connection を選択し、[保留中の接続を更新]をクリックする
  3. [新しいアプリをインストールする]をクリックする
  4. 接続する GitHub アカウントを選択する
  5. 「Repository access」セクションで、接続するリポジトリを選択し、[Save]をクリックする
  6. [接続]をクリックする
  7. 作成した CodeStar Connection(example-connection)のステータスが「利用可能」になったことを確認する

STEP5:失敗したパイプラインの再実行

AWS CLI で再実行するための Execution ID を、次のコマンドで取得する。

# Execution ID を取得する
$ aws codepipeline get-pipeline-state \
  --name example-pipeline | jq '.stageStates[].latestExecution'

{
  "pipelineExecutionId": "12345678-1234-1234-1234-123456789012",
  "status": "Failed"
}
null
null

aws codepipeline retry-stage-execution で再実行する。

# パイプラインを再実行する
$ aws codepipeline retry-stage-execution \
  --pipeline-name example-pipeline --stage-name Source \
  --pipeline-execution-id 12345678-1234-1234-1234-123456789012 \
  --retry-mode FAILED_ACTIONS

{
  "pipelineExecutionId": "12345678-1234-1234-1234-123456789012"
}

# すべてのパイプラインステージが成功したことを確認する
$ aws codepipeline get-pipeline-state \
  --name example-pipeline | jq '.stageStates[].latestExecution'

{
  "pipelineExecutionId": "12345678-1234-1234-1234-123456789012",
  "status": "Succeeded"
}
{
  "pipelineExecutionId": "12345678-1234-1234-1234-123456789012",
  "status": "Succeeded"
}
{
  "pipelineExecutionId": "12345678-1234-1234-1234-123456789012",
  "status": "Succeeded"
}

STEP6:動作確認

次の 2 点を確認する。

  • SAM プロジェクトのリポジトリにコミットを追加すると Pipeline が実行されること
  • Lambda 関数が作成されたこと

コミットを追加すると Pipeline が実行されること

SAM プロジェクトの Lambda 関数を適当に変更し、コミットを push する。

app.mjs
            'body': JSON.stringify({
-                message: 'hello world',
+                message: 'hello world!!',
            })

ステージのステータスが変わり、パイプラインが再実行されていることを確認できる。

$ aws codepipeline get-pipeline-state \
  --name example-pipeline | jq '.stageStates[].latestExecution'
{
  "pipelineExecutionId": "cd2cc850-cd34-45b9-807a-5aa9ecba0d25",
  "status": "Succeeded"
}
{
  "pipelineExecutionId": "cd2cc850-cd34-45b9-807a-5aa9ecba0d25",
  "status": "InProgress"
}
{
  "pipelineExecutionId": "e16aed25-2325-493f-b82f-24186ad2cdcf",
  "status": "Succeeded"
}

Lambda 関数が作成されたこと

Lambda 関数が作成されたことを確認する。
API のエンドポイント URL には、API Gateway の ID が使われる。
次のコマンドで API Gateway の ID を確認する。

$ aws apigateway get-rest-apis | jq '.items[] | select (.name == "example-deploy") | .id'

"randomvalueid"

API のエンドポイント URL に GET リクエストを送信する。
「hello world!!」が返却されることを確認する。

$ curl https://randomvalueid.execute-api.ap-northeast-1.amazonaws.com/Prod/hello
{"message":"hello world!!"}%