Lambda@EdgeへのデプロイをGitHub Actionsで自動化する
マネジメントコンソールからは簡単に実施できるLambda@Edgeのデプロイですが、CLIで自動化するのはやや面倒です。本記事ではGitHub ActionsからLambda@EdgeへデプロイするWorkflowを紹介します。ご参考になれば幸いです。
背景
今回紹介するWorkflowは以下の背景から作成しました。
- IaCで作成・管理されていないLambda関数がある。このLambda関数を更新して、Lambda@Edgeへデプロイする運用作業を省力化したい。
- このLambda関数はインフラを担当するチーム(SRE)だけでなく、サービス開発チームが更新することがある。更新作業はSREが引き受けるのではなく、開発チームのタイミングで実施できるようにして、リードタイムが長くなることを避けたい。
- 開発チームにはAWSに詳しくないメンバーもいる。そのため、開発者にIAM権限を移譲してAWSのマネジメントコンソールやAPIからリソースを操作してもらうのではなく、スクリプトを実行してもらう方法で作業を実施してもらいたい。
こういった背景から、GitHub Actionsを通してLambda@Edgeへデプロイしてもらうことにしました。この方法には次のメリットがありました。
- 開発者がAWS CLIの実行環境を準備する必要がない。
- 開発者用IAMユーザーの新規作成、既存IAMユーザーへの権限追加を行う必要がない。GitHub Actions用のIAMロールを準備すればよく、セキュリティの観点で好ましい。
イメージ
設計
今回紹介するWorkflowの設計は次のとおりです。
- Workflowは二つに分ける。
- Lambda関数を更新するWorkflow
- Lambda関数をLambda@EdgeへデプロイするWorkflow(CloudFront distributionを更新するWorkflow)
- Workflowは手動で実行してもらう。
Workflowを二つに分ける理由
安全性を考慮して分けることにしました。別々にしておくと次のメリットがあります。
- 別々に切り戻せること
- Lambda関数の更新直後にコードの問題が発覚したとき、Workflowを分けておけば、問題のあるコードがそのままLambda@Edgeへデプロイされることを防げること
Workflowを手動で実行してもらう理由
workflow_dispatch
限定のWorkflowにしました。大したメリットでもありませんが、ブランチをチェックアウト(スイッチ)する手間が省けます。
Workflow
name: Update lambda function
on:
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
type: choice
options:
- develop
- staging
- production
jobs:
deploy-lambda:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
steps:
- uses: actions/checkout@v3
- name: Set constants for develop
if: ${{ inputs.environment == 'develop' }}
run: |
echo "AWS_ROLE_ARN=aws:iam::your-aws-account-number:role/your-iam-role-arn-name" >> $GITHUB_ENV
echo "AWS_LAMBDA_FUNCTION_ARN=arn:aws:lambda:us-east-1:your-aws-account-number:function:your-lambda-function-name" >> $GITHUB_ENV
- name: Set constants for staging
if: ${{ inputs.environment == 'staging' }}
run: |
echo "AWS_ROLE_ARN=aws:iam::your-aws-account-number:role/your-iam-role-arn-name" >> $GITHUB_ENV
echo "AWS_LAMBDA_FUNCTION_ARN=arn:aws:lambda:us-east-1:your-aws-account-number:function:your-lambda-function-name" >> $GITHUB_ENV
- name: Set constants for production
if: ${{ inputs.environment == 'production' }}
run: |
echo "AWS_ROLE_ARN=aws:iam::your-aws-account-number:role/your-iam-role-arn-name" >> $GITHUB_ENV
echo "AWS_LAMBDA_FUNCTION_ARN=arn:aws:lambda:us-east-1:your-aws-account-number:function:your-lambda-function-name" >> $GITHUB_ENV
- name: Configure aws credentials
uses: aws-actions/configure-aws-credentials@v1
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Zip function code
run: zip your-function-code.zip your-function-code
- name: Update function code
run: aws lambda update-function-code --function-name ${{ env.AWS_LAMBDA_FUNCTION_ARN }} --zip-file fileb://your-function-code.zip
- name: Wait for update-function-code completion
timeout-minutes: 10
run: |
while :; \
do \
LAMBDA_UPDATE_STATUS=$(aws lambda get-function --function-name ${{ env.AWS_LAMBDA_FUNCTION_ARN }} | jq -r .Configuration.LastUpdateStatus); \
LAMBDA_STATE=$(aws lambda get-function --function-name ${{ env.AWS_LAMBDA_FUNCTION_ARN }} | jq -r .Configuration.State); \
if [ "$LAMBDA_UPDATE_STATUS" = "Successful" ] && [ "$LAMBDA_STATE" = "Active" ]; \
then \
echo "update completion"; \
break; \
elif [ "$LAMBDA_UPDATE_STATUS" = "InProgress" ] || [ "$LAMBDA_STATE" = "Pending" ]; \
then \
echo "update inprogress"; \
sleep 5; \
else \
echo "update failed"; \
exit 1; \
fi; \
done
- name: Publish version
run: aws lambda publish-version --function-name ${{ env.AWS_LAMBDA_FUNCTION_ARN }}
name: Deploy lambda function to Lambda@Edge
on:
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
type: choice
options:
- develop
- staging
- production
jobs:
update-distribution:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
steps:
- uses: actions/checkout@v3
- name: Set constants for develop
if: ${{ inputs.environment == 'develop' }}
run: |
echo "AWS_ROLE_ARN=aws:iam::your-aws-account-number:role/your-iam-role-arn-name" >> $GITHUB_ENV
echo "AWS_LAMBDA_FUNCTION_ARN=arn:aws:lambda:us-east-1:your-aws-account-number:function:your-lambda-function-name" >> $GITHUB_ENV
echo "AWS_CLOUDFRONT_DISTRIBUTION_ID=your-cloudfront-distribution-id" >> $GITHUB_ENV
- name: Set constants for staging
if: ${{ inputs.environment == 'staging' }}
run: |
echo "AWS_ROLE_ARN=aws:iam::your-aws-account-number:role/your-iam-role-arn-name" >> $GITHUB_ENV
echo "AWS_LAMBDA_FUNCTION_ARN=arn:aws:lambda:us-east-1:your-aws-account-number:function:your-lambda-function-name" >> $GITHUB_ENV
echo "AWS_CLOUDFRONT_DISTRIBUTION_ID=your-cloudfront-distribution-id" >> $GITHUB_ENV
- name: Set constants for production
if: ${{ inputs.environment == 'production' }}
run: |
echo "AWS_ROLE_ARN=aws:iam::your-aws-account-number:role/your-iam-role-arn-name" >> $GITHUB_ENV
echo "AWS_LAMBDA_FUNCTION_ARN=arn:aws:lambda:us-east-1:your-aws-account-number:function:your-lambda-function-name" >> $GITHUB_ENV
echo "AWS_CLOUDFRONT_DISTRIBUTION_ID=your-cloudfront-distribution-id" >> $GITHUB_ENV
- name: Configure aws credentials
uses: aws-actions/configure-aws-credentials@v1
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Set variables
run: |
echo CURRENT_CONFIG=$(aws cloudfront get-distribution-config --id ${{ env.AWS_CLOUDFRONT_DISTRIBUTION_ID }} | jq '.DistributionConfig') >> $GITHUB_ENV
echo ETAG=$(aws cloudfront get-distribution-config --id ${{ env.AWS_CLOUDFRONT_DISTRIBUTION_ID }} | jq -r '.ETag') >> $GITHUB_ENV
echo VERSION=$(aws lambda list-versions-by-function --function-name ${{ env.AWS_LAMBDA_FUNCTION_ARN }} | jq -r '.Versions[-1].Version') >> $GITHUB_ENV
- name: Make config for updating
run: echo '${{ env.CURRENT_CONFIG }}' | jq "(.DefaultCacheBehavior.LambdaFunctionAssociations.Items[] | select(.EventType == \"origin-request\") | .LambdaFunctionARN) |= \"${{ env.AWS_LAMBDA_FUNCTION_ARN }}:${{ env.VERSION }}\"" > config.json
- name: Update distribution
run: aws cloudfront update-distribution --id ${{ env.AWS_CLOUDFRONT_DISTRIBUTION_ID }} --distribution-config file://config.json --if-match ${{ env.ETAG }}
- name: Wait for update-distribution completion
timeout-minutes: 20
run: |
while :; \
do STATUS=$(aws cloudfront get-distribution --id ${{ env.AWS_CLOUDFRONT_DISTRIBUTION_ID }} | jq -r .Distribution.Status); \
if [ "$STATUS" = "InProgress" ]; \
then \
echo "update inprogress"; \
sleep 10; \
else \
echo "update completion"; \
break; \
fi; \
done
デプロイ先の環境を切り替える
Workflow実行する前に、デプロイ先の環境をパラメーターとして受け取るようにしています。
workflow_dispatch
>inputs
>environment
の箇所が該当します。
inputs
についてはドキュメントをご参照ください。
inputs
で受け取ったパラメーターは${{ inputs.environment }}
という形式で参照します。
パラメーターの値に応じてGitHub Actionsが引き受けるIAMロール、更新するLambda関数、デプロイするLambda@Edge(CloudFront distribution)の三つを切り替えます。三つの値は環境変数$GITHUB_ENV
に書き込んでおき、後続のstepにおいてecho ${{ env.key }}
で参照します。
stepを跨いで変数を引き継ぐ
stepを跨いで結果を共有するには、本記事のように環境変数$GITHUB_ENV
に書き込むか、set-outputを使用して別stepから参照できるようにする必要があります。
stepごとにシェルが与えられるためkey=value
のようにコマンドを実行してシェル変数を定義しても、次のステップでそのシェル変数は参照できません。
変数に代入する、参照する
通常、代入はecho "key=value" >> $GITHUB_ENV
で行います。valueが改行を含まない場合はこの書き方で問題ありません。
改行を含む場合、改行ありの値を参照するには複数行の文字列を扱う書き方に従う必要があります。
本記事では、以下の箇所では意図的にecho key=value >> $GITHUB_ENV
と書いています。
- name: Set variables
run: |
echo CURRENT_CONFIG=$(aws cloudfront get-distribution-config --id ${{ env.AWS_CLOUDFRONT_DISTRIBUTION_ID }} | jq '.DistributionConfig') >> $GITHUB_ENV
echo ETAG=$(aws cloudfront get-distribution-config --id ${{ env.AWS_CLOUDFRONT_DISTRIBUTION_ID }} | jq -r '.ETag') >> $GITHUB_ENV
echo VERSION=$(aws lambda list-versions-by-function --function-name ${{ env.AWS_LAMBDA_FUNCTION_ARN }} | jq -r '.Versions[-1].Version') >> $GITHUB_ENV
CURRENT_CONFIGの値はCloudFront distributionの設定ですが、echo "key=value" >> $GITHUB_ENV
と書くと改行を含むJSONを代入しようとします。すると、以下のエラーが発生します。
Error: Unable to process file command 'env' successfully.
Error: Invalid environment variable format ' "CallerReference": "",'
改行を含む場合は前述の複数行の文字列を扱う書き方に従わないといけません。しかし、CloudFrontのupdate-distributionコマンドのオプションに渡すJSONファイルは改行がなくても問題ありませんので、ダブルクォーテーションで囲わずにあえてecho key=value >> $GITHUB_ENV
と書いています。そのためCURRENT_CONFIGには一行の長いJSONが入っています。
ETAGとVERSIONの環境変数をセットする行は"key=value"
と書いても、key=value
と書いてもWorkflowの動作に影響しないため、CURRENT_CONFIGに書き方を合わせています。(後になってCURRENT_CONFIGの行にダブルクォーテーションを書き忘れていると思い違いをしないようにするため)
CURRENT_CONFIGの値を参照するときに、echo '${{ env.CURRENT_CONFIG }}'
とシングルクォートで囲っているのには理由があり、シングルクォートで囲わないとJSONからダブルクォーテーションが消えてしまい、パイプで繋いだjqコマンドがパースエラーを返します。
更新完了を待機する
Lambda関数の更新完了を待機するWait for update-function-code completionは必須ステップです。Lambda関数には状態の概念があり、関数のバージョン発行が可能になるまで待機する必要があります。
関数の状態
AWS Lambda関数の状態と追跡
CloudFront distributionの更新完了を待機するWait for update-distribution completionは必須ではありませんが、あった方がよいでしょう。CloudFront distributionの更新には5分弱はかかります。開発者には更新が正常に完了したか知ってもらう必要がありますが、マネジメントコンソールやAPIからCloudFront distributionの状態を確認させたくないので、distributionの更新が正常に完了したことをもってWorkflowを正常終了させるようにしました。
なお、更新のstepにはタイムアウトを設定しています。この設定は、AWS側で何らかの問題が発生して状態が異常であるにも関わらず、Actionsがstepの完了を待機して利用可能枠を浪費することを避けるためです。以下を参考に、念のために設定しました。
IAM
GitHub Actionsが引き受けるIAM Roleに割り当てる最小権限のIAM Policyは以下になりました。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DeployLambda",
"Effect": "Allow",
"Action": [
"lambda:UpdateFunctionCode",
"lambda:PublishVersion",
"lambda:ListVersionsByFunction",
"lambda:GetFunction",
"lambda:GetFunctionConfiguration",
"lambda:EnableReplication*"
],
"Resource": [
"arn:aws:lambda:us-east-1:your-aws-account-number:function:your-lambda-function-arn",
"arn:aws:lambda:us-east-1:your-aws-account-number:function:your-lambda-function-arn:*"
]
},
{
"Sid": "UpdateDistribution",
"Effect": "Allow",
"Action": [
"cloudfront:GetDistribution",
"cloudfront:GetDistributionConfig",
"cloudFront:UpdateDistribution"
],
"Resource": "arn:aws:cloudfront::your-aws-account:distribution/your-cloudfront-distribution-id"
}
]
}
IAM Roleの信頼関係には以下を記載しました。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::your-aws-account-numberber:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:your-organization-name/your-repository-name:*"
}
}
}
]
}
あとがき
今回、はじめてGitHub ActionsのWorkflowを自作しましたが、割とすんなり作成できました。update-distrbution に渡すJSONの作成でハマりました。最小権限のIAMポリシーを書くのもやっぱり少し手間でした。🦭
Discussion