Zenn
🌏

えっ、まだローカルでzipにしてデプロイしてるの?GitHub ActionsとTerraformを使ってLambdaをB/Gデプロイ!

2025/02/05に公開
1

私と一緒にLambdaをB/Gデプロイして幸せになりませんか???

やったこと

  • TerraformでCodePipeline(CodeBuild, CodeDeploy)と空のLambdaを作成
  • GitHub ActionsでLambdaのコードとbuildspec.ymlをS3にアップロード
  • CodeBuildでAWS CLIを使い、新バージョンのLambdaをデプロイ
  • CodeDeployでLambdaの新旧バージョンの切り替え

構成図

構成図
何をやってるのかわからねーと思うが、おれも何をやっているのかわからなかった…

1. TerraformでCodePipelineを作る

とりあえず普通のところから見ていこうね。

構成図
Terraformで作成した部分
パイプラインの枠だけ作るんだぜ 内側の話はまたあとでだぜ

Terraform

ウワーッ!!長いよ〜〜〜ッ!!!!
これは比較的普通なので、特に解説はしません。気になったら声かけてください。

main.tf
// CodePipelineのIAMロール
////////////////////////////////////////////////////////////////
resource "aws_iam_role" "pipeline_role" {
  name = "role-pipeline-${var.common.app_name}-${var.common.env}"
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Action = "sts:AssumeRole",
        Effect = "Allow",
        Principal = {
          Service = "codepipeline.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "pipeline_policy" {
  name = "policy-pipeline-${var.common.app_name}-${var.common.env}"
  role = aws_iam_role.pipeline_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:GetObjectVersion",
          "s3:GetBucketVersioning",
          "s3:PutObject"
        ]
        Resource = [
          "${var.s3.bucket.arn}",
          "${var.s3.bucket.arn}/*"
        ]
      },
      {
        Effect = "Allow"
        Action = [
          "codebuild:BatchGetBuilds",
          "codebuild:StartBuild"
        ]
        Resource = "*"
      },
      {
        Effect = "Allow"
        Action = [
          "codedeploy:CreateDeployment",
          "codedeploy:GetDeployment",
          "codedeploy:GetApplication",
          "codedeploy:GetApplicationRevision",
          "codedeploy:RegisterApplicationRevision",
          "codedeploy:GetDeploymentConfig"
        ]
        Resource = "*"
      }
    ]
  })
}

// CodeBuildのIAMロール
////////////////////////////////////////////////////////////////
resource "aws_iam_role" "codebuild_role" {
  name = "role-lambda-codebuild-${var.common.app_name}-${var.common.env}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "codebuild.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy" "codebuild_policy" {
  name = "policy-lambda-codebuild-${var.common.app_name}-${var.common.env}"
  role = aws_iam_role.codebuild_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "*"
      },
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject"
        ]
        Resource = [
          "${var.s3.bucket.arn}",
          "${var.s3.bucket.arn}/*"
        ]
      },
      {
        Effect = "Allow"
        Action = [
          "lambda:GetAlias",
          "lambda:PublishVersion",
          "lambda:UpdateFunctionCode",
          "lambda:UpdateAlias",
          "lambda:GetFunction"
        ]
        Resource = ["*"]
      }
    ]
  })
}

// CodeDeployのIAMロール
////////////////////////////////////////////////////////////////
resource "aws_iam_role" "codedeploy_role" {
  name = "lambda-deploy-role-${var.common.app_name}-${var.common.env}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "codedeploy.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_policy" "s3_access_policy" {
  name = "s3-access-policy-${var.common.app_name}-${var.common.env}"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:*"
        ]
        Resource = [
          "*"
        ]
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "codedeploy_policy" {
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSCodeDeployRoleForLambda"
  role       = aws_iam_role.codedeploy_role.name
}

resource "aws_iam_role_policy_attachment" "s3_policy_attachment" {
  policy_arn = aws_iam_policy.s3_access_policy.arn
  role       = aws_iam_role.codedeploy_role.name
}


// CodeBuild
////////////////////////////////////////////////////////////////
resource "aws_codebuild_project" "lambda_build" {
  name          = "lambda-build-project-${var.common.app_name}-${var.common.env}"
  service_role  = aws_iam_role.codebuild_role.arn
  build_timeout = "5"

  artifacts {
    type = "CODEPIPELINE"
  }

  environment {
    compute_type = "BUILD_GENERAL1_SMALL"
    image        = "aws/codebuild/amazonlinux2-x86_64-standard:3.0"
    type         = "LINUX_CONTAINER"

    environment_variable {
      name  = "FUNCTION_NAME"
      value = aws_lambda_function.lambda.function_name
    }

    environment_variable {
      name  = "ENV"
      value = var.common.env
    }
  }

  source {
    type      = "CODEPIPELINE"
    buildspec = "buildspec.yml"
  }
}

# CodeDeploy
////////////////////////////////////////////////////////////////
resource "aws_codedeploy_app" "lambda_app" {
  compute_platform = "Lambda"
  name             = "lambda-deployment-app-${var.common.app_name}-${var.common.env}"
}

# CodeDeployのデプロイメントグループ
////////////////////////////////////////////////////////////////
resource "aws_codedeploy_deployment_group" "lambda_deployment_group" {
  app_name              = aws_codedeploy_app.lambda_app.name
  deployment_group_name = "lambda-deployment-group-${var.common.app_name}-${var.common.env}"
  service_role_arn      = aws_iam_role.codedeploy_role.arn
  # 一回で全部切り替える
  deployment_config_name = "CodeDeployDefault.LambdaAllAtOnce"

  deployment_style {
    deployment_option = "WITH_TRAFFIC_CONTROL"
    deployment_type   = "BLUE_GREEN"
  }

  auto_rollback_configuration {
    enabled = true
    events  = ["DEPLOYMENT_FAILURE"]
  }
}

// CodePipeline
////////////////////////////////////////////////////////////////
resource "aws_codepipeline" "lambda_pipeline" {
  name     = "lambda-pipeline-${var.common.app_name}-${var.common.env}"
  role_arn = aws_iam_role.pipeline_role.arn

  artifact_store {
    location = var.s3.bucket.bucket
    type     = "S3"
  }

  stage {
    name = "Source"

    action {
      name             = "Source"
      category         = "Source"
      owner            = "AWS"
      provider         = "S3"
      version          = "1"
      output_artifacts = ["source_output"]

      configuration = {
        S3Bucket             = var.s3.bucket.bucket
        S3ObjectKey          = "lambda.zip"
        PollForSourceChanges = true
      }
    }
  }

  stage {
    name = "Build"

    action {
      name             = "Build"
      category         = "Build"
      owner            = "AWS"
      provider         = "CodeBuild"
      input_artifacts  = ["source_output"]
      version          = "1"
      output_artifacts = ["build_output"]

      configuration = {
        ProjectName = aws_codebuild_project.lambda_build.name
      }
    }
  }

  stage {
    name = "Deploy"

    action {
      name            = "Deploy"
      category        = "Deploy"
      owner           = "AWS"
      provider        = "CodeDeploy"
      input_artifacts = ["build_output"]
      version         = "1"

      configuration = {
        ApplicationName     = aws_codedeploy_app.lambda_app.name
        DeploymentGroupName = aws_codedeploy_deployment_group.lambda_deployment_group.deployment_group_name
      }
    }
  }
}

空のLambdaを作る

空のLambda作れるの私も初めて知りました。
https://techblog.szksh.cloud/create-empty-lambda-by-terraform/

ローカルに空のLambdaのnull.zipが残ってしまうので、それだけはgitignoreしてください。許して……

main.tf
// 空のLambda関数
////////////////////////////////////////////////////////////////
// ダミーのZIPファイルを作成
data "archive_file" "null" {
  type        = "zip"
  output_path = "${path.module}/null.zip"
  source {
    content  = "null"
    filename = "bootstrap"
  }
}

resource "null_resource" "this" {}

# Lambda用のIAMロール
resource "aws_iam_role" "null_role" {
  name = "null-role-${var.common.app_name}-${var.common.env}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "null_policy" {
  role       = aws_iam_role.null_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_lambda_function" "lambda" {
  function_name = "stream-lambda-${var.common.app_name}-${var.common.env}"
  role          = aws_iam_role.null_role.arn
  handler       = "app.lambda_handler"
  runtime       = "ruby3.3"
  filename      = data.archive_file.null.output_path
}

resource "aws_lambda_alias" "staging" {
  name             = var.common.env
  function_name    = aws_lambda_function.lambda.function_name
  function_version = aws_lambda_function.lambda.version
}

どうして空のLambdaを作る必要があるのか

Lambdaの新しいバージョンを作るときに、存在しないLambdaに新しいバージョンを作れなかったから。受け皿として必要でした。

2. GitHub ActionsでLambdaのコードをS3にアップする

GitHubActions
どうせzipにするんだけどローカルでzipにするより幸せじゃん?

GitHub Actions

.github/workflows/ci.yml
name: CI/CD
on: [push]

jobs:
  # zipにしてS3にアップロード
  upload-lambda-to-s3:
    if: github.ref_name == 'staging' || github.ref_name == 'production'
    runs-on: ubuntu-latest
    environment: ${{ github.ref_name }}
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ vars.NAME_OF_LAMBDA_GITHUB_ACTION_IAM_ROLE }}
          role-session-name: GitHubActionsSession
          aws-region: ap-northeast-1
      - name: Zip source code
        run: |
          cd <lambdaのあるディレクトリ>
          zip -r lambda.zip src buildspec.yml
      - name: Upload Lambda to S3
        run: aws s3 cp ./lambda.zip s3://${{ vars.LAMBDA_S3_BUCKET_NAME }}/lambda.zip

細かく見ていこうな

ブランチがstagingとproductionのときしか動かないようにしました。環境変数にブランチ名を入れて、そのあとの環境変数を使い分けています。

    if: github.ref_name == 'staging' || github.ref_name == 'production'
    runs-on: ubuntu-latest
    environment: ${{ github.ref_name }}

ワークフロー内で OpenID Connect (OIDC) トークンを取得するための権限をつけます。

    permissions:
      id-token: write
      contents: read

いつもの

    steps:
      - name: Checkout
        uses: actions/checkout@v4

AWSのIAMロールを指定します。
OIDCでGitHubActionsからAWSに接続するのめちゃ便利なので全人類やろうな。
https://zenn.dev/kou_pg_0131/articles/gh-actions-oidc-aws

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ vars.NAME_OF_LAMBDA_GITHUB_ACTION_IAM_ROLE }}
          role-session-name: GitHubActionsSession
          aws-region: ap-northeast-1

必要なファイルをzipにします。

      - name: Zip source code
        run: |
          cd <lambdaのあるディレクトリ>
          zip -r lambda.zip src buildspec.yml
zipの中のディレクトリ構成

zipファイルのrootに buildspec.yml がある必要があります。こんな感じ。

lambda
├── buildspec.yml
└── src
    └── app.rb

2 directories, 2 files

S3にアップロードだ!そーれドーン!!

      - name: Upload Lambda to S3
        run: aws s3 cp ./lambda.zip s3://${{ vars.LAMBDA_S3_BUCKET_NAME }}/lambda.zip

Terraform

GitHubActionsで使うためのIAMロールと、zipを入れるためのS3を作ります。S3はバージョニング必須です。

main.tf
// IAM openid connect provider用role
////////////////////////////////////////////////////////////////
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

resource "aws_iam_role" "github_actions_role" {
  name = "github-actions-role-${var.common.app_name}-${var.common.env}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/token.actions.githubusercontent.com"
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "${var.github_actions.iam_openid_connect_provider.github_repo}:*"
          }
        }
      }
    ]
  })
}

resource "aws_iam_policy" "github_actions_policy" {
  name = "github-actions-policy-${var.common.app_name}-${var.common.env}"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow",
        Action = [
          "ecr:GetAuthorizationToken"
        ],
        Resource = [
          "*"
        ]
      },
      {
        Effect = "Allow",
        Action = ["s3:*"],
        Resource = [
          // 操作したいS3
          aws_s3_bucket.lambda.arn,
          "${aws_s3_bucket.lambda.arn}/*"
        ]
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "github_actions_policy_attachment" {
  role       = aws_iam_role.github_actions_role.name
  policy_arn = aws_iam_policy.github_actions_policy.arn
}

// Lambdaを入れる用のS3
////////////////////////////////////////////////////////////////
resource "aws_s3_bucket" "lambda" {
  bucket = "s3-lambda-${var.common.app_name}-${var.common.env}"
}

resource "aws_s3_bucket_versioning" "lambda" {
  bucket = aws_s3_bucket.lambda.bucket
  versioning_configuration {
    status = "Enabled"
  }
}

// 変数
////////////////////////////////////////////////////////////////
variable "github_actions" {
  type = map(any)
  default = {
    iam_openid_connect_provider = {
      # https://gist.github.com/sakopov/a66ef55f9713649e7d7b9b4a91d64be2
      github_thumbprint = "1b511abead59c6ce207077c0bf0e0043b1382612"
      github_repo       = "repo:org/repo"
    }
  }
}

3. CodePipelineでデプロイなど!

codepipeline
ここがこのアーキテクチャのサビですからね よく聞いて

buildspec.yml
version: 0.2

phases:
  post_build:
    commands:
      # 新しいバージョンを発行するための関数コードのzip作成
      - zip -r function.zip src/

      # Lambdaの更新
      - aws lambda update-function-code --function-name ${FUNCTION_NAME} --zip-file fileb://function.zip

      # 更新が完了するまで待機
      - |
        while true; do
          STATUS=$(aws lambda get-function --function-name ${FUNCTION_NAME} --query 'Configuration.LastUpdateStatus' --output text)
          if [ "$STATUS" = "Successful" ]; then
            echo "Function update completed successfully."
            break
          elif [ "$STATUS" = "Failed" ]; then
            echo "Function update failed."
            exit 1
          else
            echo "Function update in progress... (status: $STATUS)"
            sleep 5
          fi
        done

      # 新しいバージョンを発行
      - aws lambda publish-version --function-name ${FUNCTION_NAME} > version.json

      # 発行したバージョン番号を取得
      - VERSION=$(cat version.json | jq -r '.Version')

      # 現在のバージョンを発行したバージョンの一つ前に更新
      - aws lambda update-alias --function-name ${FUNCTION_NAME} --name ${ENV} --function-version $(($VERSION - 1))

      # 現在のバージョンを取得
      - CURRENT_VERSION=$(aws lambda get-alias --function-name ${FUNCTION_NAME} --name ${ENV} --query 'FunctionVersion' --output text)

      # appspec.ymlを動的に作る
      - |
        cat << EOF > appspec.yml
        version: 0.0
        Resources:
          - ${FUNCTION_NAME}:
              Type: AWS::Lambda::Function
              Properties:
                Name: "${FUNCTION_NAME}"
                Alias: "${ENV}"
                CurrentVersion: "${CURRENT_VERSION}"
                TargetVersion: "${VERSION}"
        EOF

artifacts:
  files:
    - appspec.yml
    - function.zip
  discard-paths: yes

buildspec内でAWS CLI使ってLambdaのデプロイ、CodeDeployで使うためのappspec.ymlの書き出しまでやっています。
buildspec.ymlに背負わせるものとしては重すぎるよね………ごめんね…………

細かく見ていこうな

Lambdaのコードをzipにします(え〜っ!zip解凍したのにまたzipにするの!?っておもうよね?私も思ってるのでなんかいい感じのコメントくれる人いたら嬉しい!)

      # 新しいバージョンを発行するための関数コードのzip作成
      - zip -r function.zip src/

AWS CLI を使って、Lambda 関数のコードを上で作成した function.zip で更新します。

      # Lambdaの更新
      - aws lambda update-function-code --function-name ${FUNCTION_NAME} --zip-file fileb://function.zip

Lambdaの更新が終わるまで待機……(先走ると「そんなものねぇ!」と怒られるので)

      # 更新が完了するまで待機
      - |
        while true; do
          STATUS=$(aws lambda get-function --function-name ${FUNCTION_NAME} --query 'Configuration.LastUpdateStatus' --output text)
          if [ "$STATUS" = "Successful" ]; then
            echo "Function update completed successfully."
            break
          elif [ "$STATUS" = "Failed" ]; then
            echo "Function update failed."
            exit 1
          else
            echo "Function update in progress... (status: $STATUS)"
            sleep 5
          fi
        done

新しいLambdaを新しいバージョンとして発行し、発行したバージョンを変数 VERSIONに追加します。新しいバージョンはまだエイリアスをつけていないので使われません。

      # 新しいバージョンを発行
      - aws lambda publish-version --function-name ${FUNCTION_NAME} > version.json

      # 発行したバージョン番号を取得
      - VERSION=$(cat version.json | jq -r '.Version')

一個前のLambdaにエイリアスを貼り直します。デフォルトはエイリアスが $LATESTになっており、$LATESTのままだとCodeDeployでエイリアスの切り替えができないからです。

      # 現在のバージョンを発行したバージョンの一つ前に更新
      - aws lambda update-alias --function-name ${FUNCTION_NAME} --name ${ENV} --function-version $(($VERSION - 1))

      # 現在のバージョンを取得
      - CURRENT_VERSION=$(aws lambda get-alias --function-name ${FUNCTION_NAME} --name ${ENV} --query 'FunctionVersion' --output text)

appspec.ymlを動的に作成します。切り替えるバージョンを動的に入れ替えたかったので。

      # appspec.ymlを動的に作る
      - |
        cat << EOF > appspec.yml
        version: 0.0
        Resources:
          - ${FUNCTION_NAME}:
              Type: AWS::Lambda::Function
              Properties:
                Name: "${FUNCTION_NAME}"
                Alias: "${ENV}"
                CurrentVersion: "${CURRENT_VERSION}"
                TargetVersion: "${VERSION}"
        EOF

Buildで生成したappspec.ymlfunction.zipを出力します。このファイルはLambdaが入ってるS3にしれ〜っと出力されます。なんか言って〜!

artifacts:
  files:
    - appspec.yml
    - function.zip
  discard-paths: yes

とりあえず動いたよ〜〜〜ッ!!!!!

直したい。どんどんつっこんでほしい。

ウエ〜ってところ

  • CodeBuildでデプロイするところ
    • CodeDeployがデプロイしろ!
  • エイリアスが$LATESTのままだと動かないところ
    • なんかエイリアスを毎回付け替えている。いやだ。
  • CodeBuildでappspec.ymlを書くところ
    • とにかくCodeBuildの仕事が重いんだよな。かわいそすぎる。
    • おれがCodeBuildなら退職してるね。

memo

特定のディレクトリに変更があったときのみGitHubActionsをうごかします(さもないとstagingにpushしたら毎回デプロイされることになって嬉しくない)
https://github.com/dorny/paths-filter
https://qiita.com/yokawasa/items/6c8ab43200ed4ff09060
他のことでも使えそう!使っていこ!!!!

.github/workflows/ci.yml
  check-lambda-changes:
    runs-on: ubuntu-latest
    outputs:
      # lambda_changedという変数に結果が入る
      lambda_changed: ${{ steps.filter.outputs.lambda }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
      - name: lambda filter
        id: filter
        uses: dorny/paths-filter@v3
        # lambdaディレクトリ以下にdevブランチから変更があったらtrue
        # (commit単位の変更ではない!!!!)
        with:
          filters: |
            lambda:
              - 'server/terraform/modules/stream/lambda/**'
              
  upload-lambda-to-s3:
    needs:
      [
        vitest,
        format-ts-files,
        format-erb-files,
        check-lambda-changes, <- # これ!!!!!
      ]
    # Lambda変わってますか?がtrueだったら動く
    if: ${{ needs.check-lambda-changes.outputs.lambda_changed == 'true' &&(github.ref_name == 'staging' || github.ref_name == 'production') }}
    runs-on: ubuntu-latest
    # 以下省略
1
Fusic 技術ブログ

Discussion

ログインするとコメントできます