私が考えるLambda周りのIaC及びCI/CDのベタープラクティス

8 min read読了の目安(約7700字

概要

以前、私が考えるLambda開発環境のベストプラクティスという記事を投稿したが、それの続編としてIaC(Infrastructure as Code)及びCI/CDのベタープラクティスについて記事にしてみた。

ベストプラクティスではなくベタープラクティスにしている理由は単純で、IaC及びCI/CDの組み合わせは数多あり、各アプリケーション特有の仕様がある中で、ベストというものは存在しないと思っており、どう転んでも改善レベルだから。

この記事は、次に述べる前提を満たす場合の良さげなプラクティスとして参考にしてほしい。

前提

  • 一つの関数として動くLambdaを対象とする
    • アプリケーションサーバーとしての役割ではなく、バッチ処理などを役割とするLambdaをイメージ
  • コンテナで動くLambdaを対象とする
  • Lambda Layersは考慮しない
  • Lambda数は最大でも数十を想定

Lambdaをシンプルに保つアーキテクチャを採用しているケースや、開発初期フェーズが当てはまるのではないかと思う。

また達成したいこととして、シンプルさと開発スピードの向上にピンを止める。

結論

  • ディレクトリで各Lambdaを管理する
  • 各Lambdaを並列でビルド及びデプロイする

利用する技術・リソース

次の表の技術を用いるが、CI/CD基盤がCircle CIやJenkins、GitHub Actionsでも、IaCにCloudFormationやTerraformを使っていても問題ない。

ただ筆者の経験上、権限問題や管理コスト、開発スピードを考慮すると下記が最も良い構成ではあると思う。この技術構成にしたというだけでも1記事書けてしまいそうなので詳しくは割愛する。

領域 技術・リソース
CI/CD基盤 CodeBuild
イメージレジストリ ECR
IaC AWS CDK

IaC及びCI/CDのイメージ
LambdaのIaC及びCI/CD

ディレクトリで各Lambdaを管理する

例えば、lambdaディレクトリ内を下記のような構成にする。

mac_terminal
$ tree -a
.
├── create-thumbnail
│   ├── .env
│   ├── .env.local
│   ├── Dockerfile
│   ├── app.py
│   ├── docker-compose.yaml
│   └── requirements.txt
└── export-annotations
    ├── .env
    ├── .env.local
    ├── Dockerfile
    ├── app.py
    ├── docker-compose.yaml
    └── requirements.txt

例では、lambdaディレクトリ内にcreate-thumbnailexport-annotationsという2つのLambdaが管理されているという至極当たり前の構成になっている。

「それだけか?」という感じだがそれだけで、大事なのはこのシンプルな構成を守ることだ。Lambda開発者全員に周知する必要があるし、この構成を保てなくなったら今回紹介する方法とは別の方法に変えなくてはならない。

各Lambdaを並列でビルド及びデプロイする

CI/CD基盤にCodeBuildを使っている前提で話を進める。

イメージビルド

これらの処理を行うスクリプトを用意する。

  1. ECRにログイン
  2. ECRリポジトリを作成(ディレクトリ名を用いる)
  3. Dockerfileからイメージをビルドし、「2」で作成したリポジトリにプッシュ

ポイントは、ディレクトリごとに並列で処理することだ。直列にすると線形にビルド時間がのびてしまう。イメージビルドには時間がかかるので注意しなればならない。

メリット
ディレクトリ名を利用しているため、新しいLambdaを追加した際にスクリプトを変更する必要がないこと。触りたくない場所を触らないで済むという割と大きなメリットではないかと思う。

デメリット
ディレクトリでLambdaを管理するようにルールが必要になる。

実際のスクリプトを記載しておく。

lambda-build.sh
#!/bin/bash
set -euxo pipefail

create_docker_image() {
  AWS_ACCOUNT_ID=$1
  LAMBDA_DIR_NAME=$2
  IMAGE_TAG=$3

  IMAGE_REPO_NAME=lambda-$LAMBDA_DIR_NAME-repository
  IMAGE_REPO_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME

  # 2. ECRリポジトリを作成(ディレクトリ名を用いる)
  if [ -z "$(aws ecr describe-repositories --repository-names $IMAGE_REPO_NAME)" ]; then
    aws ecr create-repository --repository-name $IMAGE_REPO_NAME
  fi

  # 3. Dockerfileからイメージをビルドし、「2」で作成したリポジトリにプッシュ
  docker build -t $IMAGE_REPO_URI:latest lambda/$LAMBDA_DIR_NAME/
  docker tag $IMAGE_REPO_URI:latest $IMAGE_REPO_URI:$IMAGE_TAG
  docker push $IMAGE_REPO_URI:latest
  docker push $IMAGE_REPO_URI:$IMAGE_TAG
}

AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --output text)
COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
IMAGE_TAG=${COMMIT_HASH:=latest}

# 1. ECRにログイン
aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com

# ディレクトリ名を取得
LAMBDA_DIR_NAMES=`cd lambda && ls -l | awk '$1 ~ /d/ {print $9}'`
for LAMBDA_DIR_NAME in $LAMBDA_DIR_NAMES
do
  # 並列に実行する
  create_docker_image $AWS_ACCOUNT_ID $LAMBDA_DIR_NAME $IMAGE_TAG &
done

wait

echo All image created on `date`

# Deploy lambdas by cdk
## ...省略...

上記のスクリプトを実行するCodeBuildのbuildspec.yamlはこんな感じ。

lambda-buildspec.yaml
version: 0.2

phases:
  install:
    commands:
      - yarn global add aws-cdk@1.83.0
  build:
    commands:
      - ./deploy/env/buildspec/lambda-build.sh

デプロイ

AWS CDKを用いて、DockerイメージをくるんだLambdaを並列でデプロイする。本記事で紹介するCDKでは、1つのStackで全Lambdaの情報を記述する。

THUMBNAILS_BUCKET_NAMEなどの環境変数は、CodeBuildに持たせている。

lambda-stack.ts
import * as cdk from "@aws-cdk/core";
import * as lambda from "@aws-cdk/aws-lambda";
import * as ecr from "@aws-cdk/aws-ecr";
import * as iam from "@aws-cdk/aws-iam";

interface StackProps extends cdk.StackProps {
  readonly imageTag: string;
}

export class LambdaStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props: StackProps) {
    super(scope, id, props);

    // create-thumbnailの定義
    const createThumbnailDirName = "create-thumbnail";
    const createThumbnailBaseName = `lambda-${createThumbnailDirName}`;
    const createThumbnailRepository = ecr.Repository.fromRepositoryName(
      this,
      `${createThumbnailBaseName}-repository`,
      `${createThumbnailBaseName}-repository`,
    );
    new lambda.DockerImageFunction(this, `${createThumbnailBaseName}-function`, {
      functionName: `${createThumbnailBaseName}-function`,
      code: lambda.DockerImageCode.fromEcr(createThumbnailRepository, {
        tag: props.imageTag,
      }),
      memorySize: 512,
      timeout: cdk.Duration.seconds(180),
      environment: {
        THUMBNAILS_BUCKET_NAME: process.env.THUMBNAILS_BUCKET_NAME ?? "",
      },
      role: new iam.Role(this, `${createThumbnailBaseName}-role`, {
        roleName: `${createThumbnailBaseName}-role`,
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
          iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonS3FullAccess"),
        ],
      }),
    });

    // export-annotationsの定義
    const exportAnnotationsDirName = "export-annotations";
    const exportAnnotationsBaseName = `lambda-${exportAnnotationsDirName}`;
    const exportAnnotationsRepository = ecr.Repository.fromRepositoryName(
      this,
      `${exportAnnotationsBaseName}-repository`,
      exportAnnotationsBaseName
    );
    new lambda.DockerImageFunction(this, `${exportAnnotationsBaseName}-function`, {
      functionName: `${exportAnnotationsBaseName}-function`,
      code: lambda.DockerImageCode.fromEcr(exportAnnotationsRepository, {
        tag: props.imageTag,
      }),
      memorySize: 256,
      timeout: cdk.Duration.minutes(15),
      role: new iam.Role(this, `${exportAnnotationsBaseName}-role`, {
        roleName: `${exportAnnotationsBaseName}-role`,
        assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
      }),
    });
  }
}

変数名が長いと思う場合は、Lambdaごとにファイルを分ければスッキリする。親ファイルは下記。

lambda.ts
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "@aws-cdk/core";
import { LambdaStack } from "../lib/lambda-stack";

const app = new cdk.App();

const imageTag = (app.node.tryGetContext("imageTag") ?? "latest") as string;

new LambdaStack(app, "lambda-stack", {
  stackName: "lambda",
  imageTag,
});

デプロイスクリプトは先ほどのイメージビルドの後にcdk deployをすればOK。CloudFormationの動きと同じなので、互いに依存関係のない各Lambdaが並列にデプロイされる。

lambda-build.sh
# build
## ...省略...

# Deploy lambdas by cdk
cd deploy/stage/
yarn install
cdk deploy -c imageTag=$IMAGE_TAG --require-approval never

開発者がやるべきこと

ここまで準備すると、Lambdaを新規で開発・適用するステップが簡潔になる。

  1. 開発するLambda名でディレクトリをきり、そこに各種ファイルを置く
  2. 開発するLambdaの定義をlambda-stack.tsに書く

既に述べたが、ビルド・デプロイのためのスクリプトを追加する必要がないので非常にストレスフリーになる。

注意点

本記事では、複数のLambdaに対してCodeBuild1つ、かつStack1つで管理しているが、これらに制約があるわけではない。

各LambdaでCodeBuildを分ける
CodeBuildが1つの場合、全Lambdaの環境変数が一緒くたにされていたが、それを分けることができる。ただし、ディレクトリを分ける意味がなくなるためにシンプルなルールからはみ出しやすくなったり、Lambdaを新規開発する度にCodeBuildを増やさなければならないのであまりおすすめできない。

各LambdaでStackを分ける
これは1つの場合と比較して、Stackを分けたいかくらいしか差分がない。個人的にはStackが大量になるのは避けたいので1つで管理したい。

以上の理由から本記事では複数のLambdaに対してCodeBuild1つ、かつStack1つの構成で紹介させてもらっている。好みに応じて変えていいと思う。

最後に

ディレクトリで各Lambdaが管理されることでシンプルさを保ち、開発者がLambdaを新規開発する際に考えることを最小限にする、を実現したIaC及びCI/CD設計になっているのではないかと思う。

正直色々な方法がありすぎるのでこれがベストとは言い難いが、実際に運用して楽な方法なので参考になれば嬉しい。

ルールを設けるやり方なので、簡潔なREADMEを用意するのを忘れないように。