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

Today I Learned

いまさらだが、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!!"}%