はじめに

こんにちは、そしてこんばんは!
4月から所属が変わりまして、サービスプラットフォーム事業部の大嵩です。

今回は、自身でも検証や個人的な開発をしている際に困っていたLambdaのCI/CDパイプラインについて、どうするのが適切か、またどうやって実装していくのか考えてみましたので紹介します!

Lambdaへのデプロイ・自動化で困っている方必見です!

Lambdaのデプロイが抱える問題

みなさん、Lambdaのコードってどうやってデプロイされていますか・・?
CI/CDがない前提だと、コードをZip化してアップロードなりする方法になってしまうかと思います。
また、デプロイしたあとのロールバックなども再度前のバージョンをアップロードしたり、手動で戻すなどしないといけないと思います。
これはどう考えても運用上よろしくないものです。

本記事のゴール

そんな問題を解決すべく、AWS完結で本番でも使えるLambdaへのCI/CDパイプライン構築をご紹介します!

今回使うもの

下記のRecognitionによる芸能人判定サンプルを使用します!

イメージ内の有名人の認識 - Amazon Rekognition
イメージに含まれる有名人を認識し、認識した有名人に関する追加情報を取得するときは、非ストレージ型の API オペレーション RecognizeCelebrities を使用します。たとえば、情報収集がタイムクリティカルなソーシャルメディア、ニュース、エンターテインメント業界では、 RecognizeCelebrities オペレーションを使用してイメージ内の最大 64 名の有名人を識別し、有名人のウェブページ (ある場合) へのリンクを返します。どのイメージから有名人を検出したかは、Amazon Rekognition に記憶されません。この情報は、アプリケーション...

構成

前提知識の整理

Lambda バージョンとエイリアス

Lambda 関数をデプロイするとき、コードは常に $LATEST と呼ばれる「未発行バージョン」に書き込まれます。$LATESTはデプロイのたびに上書きされる可変の状態で、本番トラフィックをここに直接向けるのは危険です。デプロイの瞬間に全リクエストが新コードに切り替わるためです。

バージョンとは

$LATEST の状態を保存したい場合、バージョンを発行 します。発行されたバージョンはコード・ランタイム・メモリ・タイムアウトなどの設定がイミュータブル(不変) になり、以後変更できません。各バージョンには増番号が振られ、削除・再作成してもその番号は再利用されません。

arn:aws:lambda:ap-northeast-1:123456789012:function:my-function:42(バージョン番号)

バージョン番号を含む ARN を 修飾ARN と呼びます。これに対してバージョン番号を含まない ARN は 非修飾ARN と呼ばれ、暗黙的に $LATESTを指します。

エイリアスとは

バージョン番号はデプロイのたびに変わるため、呼び出し元がバージョン番号を直接参照すると更新のたびに設定変更が必要になります。これを解決するのが エイリアス です。

エイリアスは特定のバージョンへの名前付きポインタです。たとえば liveというエイリアスをバージョン 5 に向けておけば、呼び出し元はエイリアス ARN を参照したまま、裏側のバージョンだけを切り替えられます。

エイリアス ARN:
arn:aws:lambda:ap-northeast-1:123456789012:function:my-function:live(エイリアス名)

カナリアリリースとの関係

エイリアスには重み付きルーティングの機能があります。たとえば「バージョン 5 に 90%、バージョン 6 に 10%」というルーティングを設定でき、これがカナリアリリースの仕組みです。

本記事で使う SAM の AutoPublishAlias: live は、デプロイのたびに以下を自動で行います。

  1. $LATEST のコードから新しいバージョンを発行
  2. live エイリアスを新バージョンへ向ける(または重みを分割する)

CodeDeploy はこのエイリアスの重みを時間をかけて操作することでトラフィックシフトを実現しています。

参考:Manage Lambda function versions / Create an alias for a Lambda function

CodeDeploy の Lambda デプロイメントとは

CodeDeploy は Lambda のバージョンとエイリアスを使ってトラフィックを制御します。

Lambda エイリアスはバージョンへの重み付きルーティングをサポートしており、CodeDeploy はこれを利用して段階的にトラフィックを新バージョンへ移行します。

エイリアス “live”
├── v3 (旧バージョン): 90% ← Canary開始直後
└── v4 (新バージョン): 10%
↓ 5分後(問題なければ)
└── v4 (新バージョン): 100%

SAM の AutoPublishAlias: live は「コード変更のたびに新バージョンを発行し、エイリアス live を更新する」処理を自動化しています。

AppSpecファイル

CodeDeploy は AppSpec ファイルによって「どのバージョンからどのバージョンへ切り替えるか」を把握します。SAMが自動生成するため通常は意識しませんが、内容はこうなっています。

  version: 0.0
  Resources:
    - CelebrityRecognitionFn:
        Type: AWS::Lambda::Function
        Properties:
          Name: "celebrity-rekognition-prod"
          Alias: "live"
          CurrentVersion: "3"   # 現行バージョン
          TargetVersion: "4"    # 新バージョン
  Hooks:
    - BeforeAllowTraffic: "PreTrafficHookFn"

AppSpec File example for an AWS Lambda deployment

デプロイメント設定

設定名 挙動 用途
LambdaCanary10Percent5Minutes 最初に 10% シフト → 5分後に残り 90% 本番(今回の prod)
LambdaLinear10PercentEvery1Minute 1分ごとに 10% ずつ増加 より慎重な本番
LambdaAllAtOnce 一度に 100% シフト 開発環境(今回の dev)

Canary は 5分/10分/15分/30分待機のバリエーションがあり、Linear も 2分/3分/10分間隔が用意されています。

Deployment configurations on an AWS Lambda compute platform

ライフサイクルフック

デプロイ中のフックは2種類あります。

Start
  ↓
[BeforeAllowTraffic]  スモークテストを実行(SAM では PreTraffic)
  ↓
AllowTraffic          エイリアスの重みを変更(トラフィックシフト実行)
  ↓
[AfterAllowTraffic]   シフト完了後の検証(SAM では PostTraffic)
  ↓
End

今回の実装では BeforeAllowTrafficPreTrafficHookFn)で新バージョンを直接 Invokeしてスモークテストを実施します。失敗した場合はトラフィックシフトを開始する前にロールバックされます。

AppSpec ‘hooks’ section for an AWS Lambda deployment

自動ロールバック

CloudWatch Alarm と連携することで、トラフィックシフト中にエラー率が閾値を超えた場合に自動ロールバックが実行されます。今回の ErrorRateAlarm(エラー数 ≥ 1 が 2回連続)がこれに該当します。
ロールバック時は CodeDeploy がエイリアスの重みを旧バージョン 100% に戻します。
新バージョンへの切り替えは完全にキャンセルされます。

CodeCommit セットアップ

まずは、単純に空のリポジトリを作ります。
CodeCommit利用時はAWSの認証情報が必要となりますので、取り扱いにはご注意ください。

SAM テンプレート設計

基本構成

メインの Lambda 関数には DeploymentPreference を設定して、CodeDeploy によるトラフィックシフトを有効化します!dev/prod で異なるシフト戦略を使い分けるために、Mappings で切り替えるようにしています。

Mappings:
  DeploymentConfig:
    dev:
      ShiftType: AllAtOnce
    prod:
      ShiftType: Canary10Percent5Minutes

ここで重要なのが FunctionName を固定することです!後述する CloudWatch Alarm との循環依存を防ぐために必要になります。

CelebrityRecognitionFn:
  Type: AWS::Serverless::Function
  Properties:
    FunctionName: !Sub "celebrity-rekognition-${Env}"
    CodeUri: src/
    Handler: handler.lambda_handler
    AutoPublishAlias: live
    DeploymentPreference:
      Type: !FindInMap [DeploymentConfig, !Ref Env, ShiftType]
      Role: !GetAtt CodeDeployServiceRole.Arn
      Alarms:
        - !Ref ErrorRateAlarm
      Hooks:
        PreTraffic: !Ref PreTrafficHookFn

また、SAM の Events: Type: S3 を使って同一テンプレート内のバケットを指定すると循環依存になってしまいます。そのため S3 トリガーは NotificationConfigurationAWS::Lambda::Permission を直接定義するようにしています。

S3InvokeLambdaPermission:
  Type: AWS::Lambda::Permission
  Properties:
    FunctionName: !GetAtt CelebrityRecognitionFn.Arn
    Action: lambda:InvokeFunction
    Principal: s3.amazonaws.com
    # !GetAtt ImageBucket.Arn を使うと循環依存になるため文字列で指定
    SourceArn: !Sub "arn:aws:s3:::celebrity-rekognition-${Env}-${AWS::AccountId}"

ImageBucket:
  Type: AWS::S3::Bucket
  DependsOn: S3InvokeLambdaPermission
  Properties:
    BucketName: !Sub "celebrity-rekognition-${Env}-${AWS::AccountId}"
    NotificationConfiguration:
      LambdaConfigurations:
        - Event: s3:ObjectCreated:*
          Function: !GetAtt CelebrityRecognitionFn.Arn

PreTraffic Hook の実装

PreTraffic Hook は BeforeAllowTraffic フックに対応するもので、トラフィックシフト開始前に新バージョンのスモークテストを実行します!

テスト対象のバージョン ARN は 環境変数では渡せません
!Ref CelebrityRecognitionFn.Version を環境変数に設定しようとすると、Lambda と Hook の間で循環依存が発生してしまうためです。

代わりに、CodeDeploy の GetDeployment API から AppSpec 内の TargetVersion を取得するようにしています。

def _get_new_version_arn(deployment_id):
    deployment = codedeploy.get_deployment(deploymentId=deployment_id)
    content = deployment["deploymentInfo"]["revision"]["appSpecContent"]["content"]
    appspec = json.loads(content)
    for resource in appspec.get("Resources", []):
        for _, props in resource.items():
            if props.get("Type") == "AWS::Lambda::Function":
                return props["Properties"]["TargetVersion"]

取得したバージョン ARN を直接 Invoke して、テスト結果を PutLifecycleEventHookExecutionStatus で報告します。

def lambda_handler(event, context):
    deployment_id = event["DeploymentId"]
    lifecycle_event_hook_execution_id = event["LifecycleEventHookExecutionId"]
    status = "Succeeded"
    try:
        new_version_arn = _get_new_version_arn(deployment_id)
        response = lambda_client.invoke(
            FunctionName=new_version_arn,
            InvocationType="RequestResponse",
            Payload=json.dumps(_smoke_test_event()),
        )
        if response.get("FunctionError"):
            status = "Failed"
    except Exception:
        status = "Failed"
    codedeploy.put_lifecycle_event_hook_execution_status(
        deploymentId=deployment_id,
        lifecycleEventHookExecutionId=lifecycle_event_hook_execution_id,
        status=status,
    )

def _smoke_test_event():
    return {
        "Records": [
            {
                "s3": {
                    "bucket": {"name": "smoke-test-bucket"},
                    "object": {"key": "smoke-test.jpg"},
                }
            }
        ]
    }

CloudWatch アラーム設計

CloudWatch Alarm を CodeDeploy の Alarms に紐付けることで、トラフィックシフト中にエラー率が閾値を超えると自動ロールバックが実行されます!

ここでも循環依存に注意が必要です!DimensionsValue!Ref CelebrityRecognitionFn を使うと、DeploymentPreference.Alarms と Alarm が互いを参照して循環依存になってしまいます。FunctionName を固定したことで !Sub による文字列参照で回避できます。

ErrorRateAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    Namespace: AWS/Lambda
    MetricName: Errors
    Dimensions:
      - Name: FunctionName
        Value: !Sub "celebrity-rekognition-${Env}"   # !Ref ではなく文字列で参照
      - Name: Resource
        Value: !Sub "celebrity-rekognition-${Env}:live"
    Period: 60
    EvaluationPeriods: 2
    Threshold: 1
    TreatMissingData: notBreaching

TreatMissingData: notBreaching はデータがない期間(Lambda が呼ばれていない状態)をアラームと見なさない設定です。これがないとデプロイ直後にアラームが鳴ってロールバックされてしまうので、忘れずに設定しましょう!

CodeBuild 設定

buildspec.yml

version: 0.2
phases:
  install:
    runtime-versions:
      python: "3.12"
    commands:
      - pip install --upgrade pip
      - pip install aws-sam-cli
      - pip install -r requirements-test.txt

  pre_build:
    commands:
      - python -m pytest tests/ -v --tb=short -m "not integration"

  build:
    commands:
      - sam build
      - sam package
          --s3-bucket $ARTIFACT_BUCKET
          --s3-prefix $CODEBUILD_BUILD_ID
          --output-template-file packaged.yaml

artifacts:
  files:
    - packaged.yaml
  discard-paths: yes

3 つのフェーズで構成しています。

  • install: Python 3.12 と SAM CLI、テスト用ライブラリをインストールします
  • pre_build: pytest を実行します。-m "not integration" で integration マークのテストをスキップして、実際の AWS リソースへのアクセスが必要なテストを CI で除外しています
  • build: sam build でソースをビルドして、sam package でコードを S3 にアップロードして packaged.yaml を生成します。$ARTIFACT_BUCKET は CodeBuild プロジェクトの環境変数として設定されます

アーティファクトは packaged.yaml のみで、これを次の Deploy ステージへ渡します!

CodeBuild サービスロール設計

CodeBuild が動作するために必要な最小権限は以下の 3 種類です。

  • CloudWatch Logs: ビルドログの書き込み
  • S3(アーティファクトバケット): ソースの取得と packaged.yaml のアップロード
  • CodeCommit: ソースコードの取得
CodeBuildServiceRole:
  Policies:
    - PolicyDocument:
        Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogGroup
              - logs:CreateLogStream
              - logs:PutLogEvents
            Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/*"
          - Effect: Allow
            Action: [s3:GetObject, s3:PutObject, s3:GetObjectVersion]
            Resource: !Sub "${ArtifactBucket.Arn}/*"
          - Effect: Allow
            Action: [codecommit:GitPull]
            Resource: !Sub "arn:aws:codecommit:${AWS::Region}:${AWS::AccountId}:${RepositoryName}"

SAM CLI がビルド中に S3 へのアクセスや Lambda の操作を行わないため、権限は最小限に抑えられます。デプロイに必要な権限は CloudFormationDeployRole が担います。

CodePipeline 構成

Source ステージ

Source ステージでは PollForSourceChanges: "false" を明示的に設定します。

- Name: SourceAction
  ActionTypeId:
    Category: Source
    Provider: CodeCommit
  Configuration:
    RepositoryName: !Ref RepositoryName
    BranchName: !Ref BranchName
    PollForSourceChanges: "false"

デフォルトの PollForSourceChanges: true では CodePipeline が定期的にリポジトリをポーリングしてパイプラインを起動してしまいます。今回は EventBridge ルールでトリガーするため、必ずペアで false にする必要があります!両方が有効だとパイプラインが二重起動してしまいます・・。

EventBridge ルールは main ブランチへのプッシュを検知して CodePipeline を起動します。

PipelineTriggerRule:
  Type: AWS::Events::Rule
  Properties:
    EventPattern:
      source: [aws.codecommit]
      detail-type: [CodeCommit Repository State Change]
      detail:
        event: [referenceUpdated, referenceCreated]
        referenceType: [branch]
        referenceName: [!Ref BranchName]

Manual Approval ステージ

Approve ステージでは SNS トピックを通じてメールで承認を求めます。dev 環境のデプロイを確認してから本番デプロイを実行する関門となります!

- Name: ManualApproval
  ActionTypeId:
    Category: Approval
    Provider: Manual
  Configuration:
    NotificationArn: !Ref ApprovalTopic
    CustomData: "dev環境のデプロイを確認し、本番デプロイを承認してください。"

Deploy ステージ(CloudFormation + CodeDeploy)

Deploy ステージでは CloudFormation の CREATE_UPDATE アクションを使います。

Configuration:
  ActionMode: CREATE_UPDATE
  StackName: celebrity-rekognition-prod
  TemplatePath: "BuildOutput::packaged.yaml"
  Capabilities: CAPABILITY_IAM,CAPABILITY_AUTO_EXPAND
  ParameterOverrides: '{"Env": "prod"}'

CHANGE_SET_REPLACE + CHANGE_SET_EXECUTE の 2 アクション構成にすると、初回デプロイ時(スタック未存在)に「Stack [null] does not exist」エラーが発生してしまいます。CREATE_UPDATE なら初回は作成、2 回目以降は更新と自動で切り替わります!

CAPABILITY_AUTO_EXPAND は SAM テンプレートを CloudFormation に変換するために必要です。

ParameterOverridesEnv パラメータを渡して、dev/prod で同一の packaged.yaml を使いながら DeploymentPreference.Type(AllAtOnce / Canary10Percent5Minutes)とリソース名を切り替えます!

CloudFormation のデプロイが完了すると、SAM が生成した CodeDeploy のデプロイメントグループが実際のトラフィックシフトを開始します。このときライフサイクルフックと CloudWatch Alarm による自動ロールバックが有効になります!

デプロイしたLambdaをテストしてみる

ということで、芸能人判定をテストしてみましょう。
テスト用の人物画像(フリー素材)を3バケットにアップロードして、Lambdaを発火します!

すると、CloudWatch Logs に以下のjsonが出力され、動作が確認できました!!
(阿部寛さんに似てたようですねw)

これで、無事にCodeCommitへのプッシュからデプロイまで自動で行えました!

{
    "bucket": "celebrity-rekognition-prod-866779885836",
    "key": "test.jpg",
    "celebrity_count": 1,
    "celebrities": [
        {
            "name": "Hiroshi Abe",
            "id": "41ws0zb",
            "gender": "Male",
            "confidence": 100,
            "urls": [
                "www.wikidata.org/wiki/Q940531",
                "www.imdb.com/name/nm0008346"
            ]
        }
    ]
}

ハマりポイント集

SAM + CodeDeploy の組み合わせでは、リソース間の参照で循環依存が発生しやすいです。
今回ハマったパターンをまとめます!

① DeploymentPreference.Alarms と CloudWatch Alarm

ErrorRateAlarm を Alarms に指定しつつ、その Alarm の Dimensions で Lambda 関数を参照すると循環依存になります。
FunctionName を明示固定して、Alarm 側は !Sub で文字列参照することで回避できます。

# NG: !Ref を使うと循環
Dimensions:
  - Name: FunctionName
    Value: !Ref CelebrityRecognitionFn

# OK: 文字列で参照
Dimensions:
  - Name: FunctionName
    Value: !Sub "celebrity-rekognition-${Env}"

② SAM の S3 イベントと同一テンプレート内のバケット

SAM が S3 通知と Lambda::Permission を自動生成する際に、バケットと Lambda が互いを参照して循環依存になります。
Events: Type: S3 を使わず、NotificationConfiguration と Lambda::Permission を直接定義して DependsOn で順序を制御しましょう!

③ PreTraffic Hook に新バージョン ARN を渡せない

Lambda のバージョン ARN を Hook の環境変数に設定しようとすると、Lambda と Hook が互いを参照して循環依存になります。
CodeDeploy の GetDeployment API から AppSpec 内の TargetVersion を取得することで回避できます!

④ CHANGE_SET_REPLACE は初回デプロイで失敗する

CodePipeline の CloudFormation アクションに CHANGE_SET_REPLACE + CHANGE_SET_EXECUTE の 2 アクション構成を使うと、スタックが存在しない初回デプロイで「Stack [null] does not exist」エラーが発生してしまいます・・・
CREATE_UPDATE の 1 アクションにするだけで、初回作成も以降の更新もまとめて対応できます!

⑤ CodeDeploy サービスロールに権限が付かない

SAM の DeploymentPreference が自動生成する CodeDeploy ロールに AWSCodeDeployRoleForLambda が付かないケースがあります。
IAM ロールを明示的に定義して DeploymentPreference の Role に渡すのが確実です!

⑥ RekognitionDetectOnlyPolicy では RecognizeCelebrities が呼べない

SAM ポリシーテンプレートの RekognitionDetectOnlyPolicy は DetectFaces / DetectLabels 系のみが対象で、RecognizeCelebrities は含まれていません・・・
カスタムポリシーで明示的に許可が必要です。

Policies:
  - Version: "2012-10-17"
    Statement:
      - Effect: Allow
        Action:
          - rekognition:RecognizeCelebrities
        Resource: "*"

⑦ スタック削除が DELETE_FAILED になる

CodeDeploy の DeploymentGroup が Lambda エイリアスを保持しているため、スタックを削除しようとすると DELETE_FAILED になることがあります。
先に CodeDeploy アプリケーションを削除してからスタックを削除しましょう!

aws deploy delete-application --application-name <アプリケーション名>

まとめ

今回は AWS 完結で Lambda の CI/CD パイプラインを構築してみました!

CodeCommit へのプッシュをトリガーに、CodeBuild でテスト・ビルドして、CodeDeploy がカナリアリリースでデプロイするところまで自動化できました。
特に気に入っているのが、SAM の DeploymentPreference を数行書くだけで Canary リリースと自動ロールバックが手に入るところです。CloudWatch Alarm との連携も含めてかなりの安心感があります!
一方で、SAM と CodeDeploy は循環依存の罠があるので、実装の際はハマりポイント集を参考にしてみてください。

GitHub や GitLab ではなく AWS 完結にこだわることで、IAM による細かなアクセス制御や VPC 内でのプライベートなパイプライン構築も可能になります。コンプライアンス要件が厳しい環境でも使いやすい構成かと思いますので、ぜひ試してみてください!