🐡

LambdaのCI/CDをAWS CDKで構築する

2024/06/02に公開

はじめに

Lambda、使ってますか? Lambda、デプロイしてますか? デプロイ、自動化してますか?
LambdaはSAM CLIでもさくっとデプロイできますが、ブランチをPushしたらそのままデプロイが素敵ですよね。

今回はCodeBuildとCodePipelineを使用した、DockerイメージのAWS LambdaをデプロイするCI/CDパイプラインを、AWS CDKで実装したいと思います。

対象読者

  • AWS CDKを使ったことがある方
  • AWS CDK Toolkitがインストール済みの方
  • CloudFormationを使ったことがある方
  • LambdaのCI/CD構築に興味がある方

技術スタック

  • AWS CDK(TypeScript)
  • AWS CodeBuild
  • AWS CodePipeline
  • AWS Lambda(Python)
  • Amazon ECR
  • Amazon S3
  • Docker

概要

CodePipelineとCodeBuildを利用したことがない方向けに簡単に説明を記載します。
詳しい説明は本家のリンクを記載してますのでそちらでご確認いただけたら幸いです。

CodePipelineについて

CodePipelineはソースコード取得、ビルド、デプロイまでの一連の流れを自動化してくれるサービスです。
上記の一連の流れをそれぞれステージという単位に区切ってつなげているイメージです。
ソースコードの取得はAWS CodeCommitやGithub、Gitlabなど様々な外部サービスと連携できます。

CodeBuildについて

CodeBuildは、CodePipelineで説明したビルド部分を担当する部分で、LinuxやWindowsなど豊富なDockerイメージからビルドするサーバー環境を簡単に作成することができます。
この仮想サーバー上でコマンドを駆使して柔軟なビルドを実装することができます。

CI/CD全体の流れ

  • Githubにmainブランチをpush
  • CodeBuildでLambdaプロジェクトに配置したbuildspec.ymlをもとにビルド
    • 単体テスト実行
    • DockerビルドしてイメージをECRにpush
    • Lambdaプロジェクトに配置したtemplate.ymlをビルド
  • CloudformationでLambdaをデプロイ

事前準備

Githubでトークンの生成

今回ソースコードの取得はGithubから行います。
CodePipelineとの連携のためにGithub APIを利用するための認証トークンの生成しましょう。
Personal access tokens(classic)の右上にあるGenerate new tokenをクリックして、以下のアクセス権にチェックして、保存をしてください。

生成したトークンを控えておきましょう。

コード解説

今回使用するAWS CDKのコードはGithubに置いてるので動かしたい方はCloneしてみてください。
https://github.com/Tomoaki-Moriya/aws-cicd-receipe

また、デプロイするサンプルのLambdaは手前味噌で恐縮ですが、こちらの記事で紹介しているAWS Lambdaのプロジェクトを使用します。

こちらのコードもGithubに置いてありますので動かしたい方はCloneしてみてください。
https://github.com/Tomoaki-Moriya/html-to-pdf-lambda

AWS CDK

今回は複数のStackに分けています。

  • ArtifactBucketStack
    • CodePipelineの成果物を格納するためのS3バケットを作成
    • シンプルかつCodePipelineは自動で作成してもくれるので解説はスキップ
  • SecretsManagerStack
    • AWS Secrets Managerのシークレットを作成
    • Githubで取得した認証トークンを保存しておくために使用
  • LambdaCodePipelineStack
    • 以下を使用したCI/CDを作成
      • CodeBuildの作成
      • CodePipelineの作成

SecretsMangerStack

Githubで生成したトークンは機密情報なので、安全に取り扱う必要があります。
そのため、今回はSecretsMangerに登録しておいて、取得はSecretsManagerを通して行います。

lib/secrets-manager-stack.ts
// 下記2つはパイプライン作成時に参照するためexportしておく
export const SECRET_NAME = "AwsCicdRecipe";
export const GITHUB_OAUTH_TOKEN_KEY = "GITHUB_OAUTH_TOKEN";

export class SecretsManagerStack extends cdk.Stack {
  public readonly secret: secretsmanager.Secret;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const githubAuthToken = this.node.tryGetContext(
      "GITHUB_OAUTH_TOKEN"
    ) as string;

    new secretsmanager.Secret(this, `${SECRET_NAME}Id`, {
      secretName: SECRET_NAME,
      secretObjectValue: {
        [GITHUB_OAUTH_TOKEN_KEY]:
          cdk.SecretValue.unsafePlainText(githubAuthToken),
      },
    });
  }
}

GITHUB_AUTH_TOKENという名前で生成したトークンをコンテキストから取得し、シークレット作成と同時にトークンを登録しています。
exportしている変数はこの後のLambdaCodePipelineStackで使用します。

LambdaCodePipelineStack

今回のメインです。
Githubのソースやブランチの設定は今回ハードコーディングしていますが、実際の運用ではコンテキストから取得したほうが汎用性が高いと思います。

lib/lambda-code-pipeline-stack.ts
const STACK_NAME = "HtmlToPdfLambdaStack";

export class LambdaCodePipelineStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const artifactBucket = aws_s3.Bucket.fromBucketName(
      this,
      "LambdaCodePipelineArtifactBucket",
      ARTIFACT_BUCKET_NAME
    );

    const codePipeline = this.createCodePipeline(artifactBucket);

    const project = this.createCodeBuild();

    const sourceOutput = new aws_codepipeline.Artifact();
    const buildOutput = new aws_codepipeline.Artifact();

    // Githubソースコード連携のステージ追加
    codePipeline.addStage({
      stageName: "Source",
      actions: [
        new aws_codepipeline_actions.GitHubSourceAction({
          actionName: "Source",
          owner: "Tomoaki-Moriya",
          repo: "html-to-pdf-lambda",
          branch: "main",
          // GitHubの認証トークンをSecretsManagerから取得し、設定
          oauthToken: cdk.SecretValue.secretsManager(SECRET_NAME, {
            jsonField: GITHUB_OAUTH_TOKEN_KEY,
          }),
          output: sourceOutput, // ソースコードを成果物としてビルドステージへ渡す
          runOrder: 1,
        }),
      ],
    });

    // ビルドステージを追加し、作成したCodeBuildを指定
    codePipeline.addStage({
      stageName: "Build",
      actions: [
        new aws_codepipeline_actions.CodeBuildAction({
          actionName: "Build",
          project,
          input: sourceOutput,
          // build.ymlとparam.jsonを成果物としてデプロイステージへ渡す
          outputs: [buildOutput],
          runOrder: 2,
        }),
      ],
    });

    // デプロイステージを追加
    codePipeline.addStage({
      stageName: "Deploy",
      actions: [
        // CloudFormationでLambda作成スタックを作成
        new aws_codepipeline_actions.CloudFormationCreateReplaceChangeSetAction(
          {
            actionName: "CreateChangeSet",
            stackName: STACK_NAME,
            changeSetName: `${STACK_NAME}ChangeSet`,
            runOrder: 3,
            // ビルドステージでtemplate.ymlから作成したbuild.ymlを参照
            templatePath: buildOutput.atPath("build.yml"),
            // ビルドステージで作成したテンプレートパラメータファイルを参照
            templateConfiguration: buildOutput.atPath("param.json"),
            // trueに設定することでCloudFormationに対する全権限が付与されたIamが作成される
            // 最小限にしたい場合はfalseに設定して任意のIamを作成してアタッチする
            // ただしその場合CodePipelineのIamにAssumeRoleできる権限が必要なので注意
            adminPermissions: true,
          }
        ),
        // 作成したスタックを適用するためのアクションを設定
        // このアクションを設定しないとスタックが実行されない
        new aws_codepipeline_actions.CloudFormationExecuteChangeSetAction({
          actionName: "ExecuteChangeSet",
          stackName: STACK_NAME,
          changeSetName: `${STACK_NAME}ChangeSet`,
          runOrder: 4,
        }),
      ],
    });
  }

  private createCodeBuild() {
    // CodeBuildのIAMロールを作成
    // ECRへのアクセス権限を付与
    const codeBuildRole = new aws_iam.Role(
      this,
      "LambdaCodePipelineCodeBuildRoleId",
      {
        assumedBy: new aws_iam.ServicePrincipal("codebuild.amazonaws.com"),
        inlinePolicies: {
          CodeBuildRolePolicy: new aws_iam.PolicyDocument({
            statements: [
              new aws_iam.PolicyStatement({
                actions: [
                  "ecr:DescribeRepositories",
                  "ecr:GetAuthorizationToken",
                  "ecr:BatchCheckLayerAvailability",
                  "ecr:GetDownloadUrlForLayer",
                  "ecr:GetRepositoryPolicy",
                  "ecr:ListImages",
                  "ecr:BatchGetImage",
                  "ecr:InitiateLayerUpload",
                  "ecr:UploadLayerPart",
                  "ecr:CompleteLayerUpload",
                  "ecr:PutImage",
                ],
                resources: ["*"],
              }),
            ],
          }),
        },
      }
    );

    // Iam RoleをアタッチしてCodeBuildを作成
    // buildspecで参照する環境変数を設定
    return new aws_codebuild.PipelineProject(this, "Project", {
      projectName: "LambdaProject",
      environment: {
        privileged: true, // CodeBuildでDockerを使うために必要(Docker in Docker)
        buildImage: aws_codebuild.LinuxBuildImage.AMAZON_LINUX_2_5,
      },
      environmentVariables: {
        AWS_ACCOUNT_ID: {
          value: this.account,
          type: aws_codebuild.BuildEnvironmentVariableType.PLAINTEXT,
        },
        ARTIFACT_S3_BUCKET_NAME: {
          value: ARTIFACT_BUCKET_NAME,
          type: aws_codebuild.BuildEnvironmentVariableType.PLAINTEXT,
        },
      },
      role: codeBuildRole,
    });
  }

  private createCodePipeline(artifactBucket: IBucket) {
    // CodePipelineのIAMロールを作成しています
    // 以下の権限を付与
    // - S3(ArtifactBucket)への成果物の取り出しができるように
    // - CloudFormationでLambdaを作成する際に必要な権限
    // - Lambda関数の作成できるように
    // - Lambda関数にIamRoleを作成してアタッチできるように
    const codePipelineRole = new aws_iam.Role(
      this,
      "LambdaCodePipelineRoleId",
      {
        assumedBy: new aws_iam.ServicePrincipal("codepipeline.amazonaws.com"),
        inlinePolicies: {
          LambdaCodePipelinePolicy: new aws_iam.PolicyDocument({
            statements: [
              new aws_iam.PolicyStatement({
                actions: ["s3:GetBucket*", "s3:GetObject*", "s3:List*"],
                resources: [
                  `arn:aws:s3:::${ARTIFACT_BUCKET_NAME}/*`,
                  `arn:aws:s3:::${ARTIFACT_BUCKET_NAME}`,
                ],
                effect: aws_iam.Effect.ALLOW,
              }),
              new aws_iam.PolicyStatement({
                effect: aws_iam.Effect.ALLOW,
                actions: [
                  "iam:GetRole",
                  "iam:CreateRole",
                  "iam:TagRole",
                  "iam:AttachRolePolicy",
                  "iam:PassRole",
                ],
                resources: ["*"],
              }),
              new aws_iam.PolicyStatement({
                effect: aws_iam.Effect.ALLOW,
                actions: [
                  "lambda:GetFunction",
                  "lambda:GetFunctionUrlConfig",
                  "lambda:CreateFunction",
                  "lambda:CreateFunctionUrlConfig",
                  "lambda:AddPermission",
                  "lambda:TagResource",
                ],
                resources: ["*"],
              }),
            ],
          }),
        },
      }
    );

    // Iam RoleをアタッチしてCodePipelineを作成
    return new aws_codepipeline.Pipeline(this, "LambdaCodePipelineId", {
      pipelineName: "LambdaCodePipeline",
      role: codePipelineRole,
      artifactBucket, // 成果物を保存するS3バケット。指定しなければ作成される
    });
  }
}

Lambda

以下の解説を行います。

  • template.yml
    • Lambda作成するCloudFormationテンプレート
  • buildspec.yml
    • CodeBuildで行う処理をコマンドで実装
    • プロジェクトルートに配置することでCodeBuildが自動的に実行する

CloudFormation

ECRに登録してあるイメージからAWS Lambdaを作成するだけのシンプルなテンプレートです。
簡易的なAPIとして使用するので関数URLも同時に作成しています。

template.yml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31

Parameters:
  ImageTag: # ECRに登録したイメージタグの指定。ビルドステージから受け取る。
    Type: String
    Default: "latest"

Resources:
  Function:
    Type: AWS::Serverless::Function
    Properties:
      PackageType: Image
      ImageUri: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/html-to-pdf-lambda:${ImageTag}"
      MemorySize: 512
      Timeout: 30
      Policies:
        - AWSLambdaBasicExecutionRole
    Metadata:
      Dockerfile: Dockerfile
      DockerTag: html-to-pdf-lambda
      DockerContext: ./
  FunctionLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /aws/lambda/Function

  Permission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref Function
      FunctionUrlAuthType: "NONE"
      Action: lambda:InvokeFunctionUrl
      Principal: "*"

  FunctionUrl:
    Type: AWS::Lambda::Url
    Properties:
      AuthType: NONE
      TargetFunctionArn: !GetAtt Function.Arn

Outputs:
  FunctionUrl:
    Value: !GetAtt FunctionUrl.FunctionUrl
    Export:
      Name: FunctionUrl

buildspec

単体テストやDockerビルドしています。
またイメージの登録も行うため、ECRに登録するためのリポジトリを検索してなければ作成する処理を入れています。

buildspec.yml
version: 0.2

env:
  variables:
    IMAGE_REPOSITORY_NAME: "html-to-pdf-lambda"

phases:
  install:
    on-failure: ABORT
    runtime-versions:
      python: 3.11
    commands:
      - python -m venv venv
      - source venv/bin/activate
  pre_build:
    on-failure: ABORT
    commands:
      - pip install -r src/requirements.txt
      - pip install -r tests/requirements.txt
      # ECRがなければ作成する
      - aws ecr describe-repositories --repository-names ${IMAGE_REPOSITORY_NAME} > /dev/null 2>&1 || aws ecr create-repository --repository-name ${IMAGE_REPOSITORY_NAME}
  build:
    on-failure: ABORT
    commands:
      - PYTHONPATH=src pytest
      - docker build -t ${IMAGE_REPOSITORY_NAME} .
      # コミットハッシュの頭7桁をイメージタグとして設定
      - IMAGE_TAG=$(echo "$CODEBUILD_RESOLVED_SOURCE_VERSION" | head -c 7)
      - IMAGE_URI="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPOSITORY_NAME}:${IMAGE_TAG}"
      - docker tag ${IMAGE_REPOSITORY_NAME} ${IMAGE_URI}
      - aws ecr get-login-password --region ${AWS_DEFAULT_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com
      # ビルドしたイメージをECRにpush
      - docker push ${IMAGE_URI}
      # イメージURIを指定してCloudFormationテンプレートをビルド
      - sam package
        - --template-file template.yml
        - --output-template-file build.yml
        - --s3-bucket "${ARTIFACT_S3_BUCKET_NAME}"
        - --image-repository "${IMAGE_URI}"
      # templateにパラメータを流し込むためのjsonを作成
      - echo '{"Parameters":{"ImageTag":"'"${IMAGE_TAG}"'"}}' > param.json
# デプロイステージへビルドしたテンプレートとパラメータ設定を渡す
artifacts:
  files:
    - "build.yml"
    - "param.json"

デプロイしてみる

さっそくCDKをデプロイしてCI/CDを動かしてみます。

成果物格納用のS3作成

cdk deploy ArtifactBucketStack

Githubの認証トークン管理用のSecrets Mangager作成

cdk deploy SecretsManagerStack -c GITHUB_OAUTH_TOKEN=取得した認証トークン

CI/CD作成

cdk deploy LambdaCodePipelineStack

動作確認

デプロイすると初回は自動的にCI/CDが走ります。
コンソールで確認してみましょう。

CodePipeline

問題なく全てのステージが成功してますね。

Lambda

Lambdaもちゃんと作成されてます!

まとめ

いかがだったでしょうか。
今回はDockerイメージのAWS LambdaのCI/CDを作成しましたが、ECRの処理をbuildspec.ymlから除けば普通のAWS Lambdaも同じくデプロイされるはずです。

チームならなおさらですが、CI/CDがあると開発効率がとてもいいです。
CodePipeline、CodeBuild共に永久無料枠が少しあるので、個人利用でも作っているプロダクトがある場合はおすすめです!

素敵な自動デプロイ生活を送りましょう。

Discussion