🚀

Lambda@EdgeへのデプロイをGitHub Actionsで自動化する

2022/06/30に公開

マネジメントコンソールからは簡単に実施できる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

update-lambda-function.yml
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 }}
deploy-lambda-fuction-to-lambda@edge.yml
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の完了を待機して利用可能枠を浪費することを避けるためです。以下を参考に、念のために設定しました。
https://dev.classmethod.jp/articles/must-set-timeout-on-actions-for-save-cost/

IAM

GitHub Actionsが引き受けるIAM Roleに割り当てる最小権限のIAM Policyは以下になりました。

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の信頼関係には以下を記載しました。

trust relationship
{
    "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ポリシーを書くのもやっぱり少し手間でした。🦭

GitHubで編集を提案

Discussion