えっ、まだローカルでzipにしてデプロイしてるの?GitHub ActionsとTerraformを使ってLambdaをB/Gデプロイ!
私と一緒にLambdaをB/Gデプロイして幸せになりませんか???
やったこと
- TerraformでCodePipeline(CodeBuild, CodeDeploy)と空のLambdaを作成
- GitHub ActionsでLambdaのコードとbuildspec.ymlをS3にアップロード
- CodeBuildでAWS CLIを使い、新バージョンのLambdaをデプロイ
- CodeDeployでLambdaの新旧バージョンの切り替え
構成図
何をやってるのかわからねーと思うが、おれも何をやっているのかわからなかった…
1. TerraformでCodePipelineを作る
とりあえず普通のところから見ていこうね。
構成図
パイプラインの枠だけ作るんだぜ 内側の話はまたあとでだぜ
Terraform
ウワーッ!!長いよ〜〜〜ッ!!!!
これは比較的普通なので、特に解説はしません。気になったら声かけてください。
// 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作れるの私も初めて知りました。
ローカルに空のLambdaのnull.zip
が残ってしまうので、それだけはgitignoreしてください。許して……
// 空の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にアップする
どうせzipにするんだけどローカルでzipにするより幸せじゃん?
GitHub Actions
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に接続するのめちゃ便利なので全人類やろうな。
- 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はバージョニング必須です。
// 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でデプロイなど!
ここがこのアーキテクチャのサビですからね よく聞いて
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.yml
とfunction.zip
を出力します。このファイルはLambdaが入ってるS3にしれ〜っと出力されます。なんか言って〜!
artifacts:
files:
- appspec.yml
- function.zip
discard-paths: yes
とりあえず動いたよ〜〜〜ッ!!!!!
直したい。どんどんつっこんでほしい。
ウエ〜ってところ
- CodeBuildでデプロイするところ
- CodeDeployがデプロイしろ!
- エイリアスが
$LATEST
のままだと動かないところ- なんかエイリアスを毎回付け替えている。いやだ。
- CodeBuildでappspec.ymlを書くところ
- とにかくCodeBuildの仕事が重いんだよな。かわいそすぎる。
- おれがCodeBuildなら退職してるね。
memo
特定のディレクトリに変更があったときのみGitHubActionsをうごかします(さもないとstagingにpushしたら毎回デプロイされることになって嬉しくない)
他のことでも使えそう!使っていこ!!!! 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
# 以下省略
Discussion