はじめに
昨年末にGAされたLambdaのコンテナイメージ機能。
プラットフォームとしてのデプロイ方法が増えて、ますますLambdaの使い勝手が良くなってきている。
今回は、通常のZipによるデプロイではなくて、コンテナイメージでのデプロイをTerraformで自動化してみよう。
前提知識としては以下だ。
ソースコード
今回は動けば何でもよいので、AWSリソースとも接続しない、テキトーにJSONを返すだけのコードにしよう。
クエリからidを受け取り、それに応じた名前を返すような機能(getemployee)とする。
package main
import (
"context"
"encoding/json"
"log"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)
type responseBody struct {
ID string `json:"id"`
Name string `json:"name"`
}
func main() {
lambda.Start(handler)
}
func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
employees := map[string]string{
"00001": "Taro",
"00002": "Jiro",
"00003": "Saburo",
"00004": "Shiro",
"00005": "Goro",
}
if len(request.QueryStringParameters) == 0 {
log.Println("QueryStringParameters is not specified.")
return events.APIGatewayProxyResponse{
StatusCode: 400,
IsBase64Encoded: false,
}, nil
}
id, idIsNotNull := request.QueryStringParameters["id"]
if !idIsNotNull {
log.Println("[QueryStringParameters]id is not specified")
return events.APIGatewayProxyResponse{
StatusCode: 400,
IsBase64Encoded: false,
}, nil
}
name, nameIsNotNull := employees[id]
if !nameIsNotNull {
log.Println("[QueryStringParameters]id is not specified")
return events.APIGatewayProxyResponse{
StatusCode: 404,
IsBase64Encoded: false,
}, nil
}
responseBody := responseBody{
ID: id,
Name: name,
}
jsonBytes, _ := json.Marshal(responseBody)
return events.APIGatewayProxyResponse{
StatusCode: 200,
IsBase64Encoded: false,
Body: string(jsonBytes),
}, nil
}
Dockerfile
Dockerfileは、公式の開発者ガイドに従って作ろう。
中身はほぼそのままである。RIE(後述)の部分だけ少し変更している。
FROM public.ecr.aws/lambda/provided:al2 as build
# install compiler
RUN yum install -y golang
RUN go env -w GOPROXY=direct
# cache dependencies
ADD src/go.mod src/go.sum ./
RUN go mod download
# build
ADD src .
RUN go build -o /main
# copy artifacts to a clean image
FROM public.ecr.aws/lambda/provided:al2
COPY --from=build /main /main
# (Optional) Add Lambda Runtime Interface Emulator and use a script in the ENTRYPOINT for simpler local runs
ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/latest/download/aws-lambda-rie /usr/bin/aws-lambda-rie
RUN chmod 755 /usr/bin/aws-lambda-rie
COPY ./script/entry.sh /
RUN chmod 755 /entry.sh
ENTRYPOINT [ "/entry.sh" ]
CMD [ "/main" ]
entry.shは以下のようにする。
#!/bin/sh
if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
exec /usr/bin/aws-lambda-rie "$@"
else
exec "$@"
fi
AWS_LAMBDA_RUNTIME_APIが、Lambdaのマネージドサービスから起動された場合は設定されるので、設定されていない場合はローカル起動としてRIE経由での起動とする。
Terraform
さて、これを以下のように構築する。
ECR
コンテナイメージでのデプロイなので、まずはECRにPUSHしよう。
################################################################################
# ECR #
################################################################################
resource "aws_ecr_repository" "getemployee" {
name = local.ecr_getemployee_repository_name
}
data "aws_ecr_authorization_token" "token" {}
resource "null_resource" "image_push" {
provisioner "local-exec" {
command = <<-EOF
docker build ../ -t ${aws_ecr_repository.getemployee.repository_url}:latest; \
docker login -u AWS -p ${data.aws_ecr_authorization_token.token.password} ${data.aws_ecr_authorization_token.token.proxy_endpoint}; \
docker push ${aws_ecr_repository.getemployee.repository_url}:latest
EOF
}
}
Lambda
Lambdaは以下のように作る。
################################################################################
# Lambda #
################################################################################
resource "aws_lambda_function" "getemployee" {
depends_on = [
aws_cloudwatch_log_group.lambda_getemployee,
null_resource.image_push,
]
function_name = local.lambda_getemployee_function_name
package_type = "Image"
image_uri = "${aws_ecr_repository.getemployee.repository_url}:latest"
role = aws_iam_role.lambda_getemployee.arn
publish = true
memory_size = 128
timeout = 28
lifecycle {
ignore_changes = [
image_uri, last_modified
]
}
}
################################################################################
# IAM Role for Lambda #
################################################################################
resource "aws_iam_role" "lambda_getemployee" {
name = local.lambda_getemployee_iam_role_name
assume_role_policy = data.aws_iam_policy_document.lambda_getemployee_assume.json
}
data "aws_iam_policy_document" "lambda_getemployee_assume" {
statement {
effect = "Allow"
actions = [
"sts:AssumeRole",
]
principals {
type = "Service"
identifiers = [
"lambda.amazonaws.com",
]
}
}
}
resource "aws_iam_role_policy_attachment" "lambda_getemployee" {
role = aws_iam_role.lambda_getemployee.name
policy_arn = aws_iam_policy.lambda_getemployee_custom.arn
}
resource "aws_iam_policy" "lambda_getemployee_custom" {
name = local.lambda_getemployee_iam_policy_name
policy = data.aws_iam_policy_document.lambda_getemployee_custom.json
}
data "aws_iam_policy_document" "lambda_getemployee_custom" {
statement {
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = [
"*",
]
}
}
################################################################################
# CloudWatch Logs #
################################################################################
resource "aws_cloudwatch_log_group" "lambda_getemployee" {
name = "/aws/lambda/${local.lambda_getemployee_function_name}"
retention_in_days = 3
}
################################################################################
# Lambda Alias #
################################################################################
resource "aws_lambda_alias" "getemployee_prod" {
name = "Prod"
function_name = aws_lambda_function.getemployee.arn
function_version = aws_lambda_function.getemployee.version
lifecycle {
ignore_changes = [function_version]
}
}
################################################################################
# Lambda Permission #
################################################################################
resource "aws_lambda_permission" "allow_apigateway" {
statement_id = "AllowExecutionFromCloudWatch"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.getemployee.function_name
principal = "apigateway.amazonaws.com"
qualifier = aws_lambda_alias.getemployee_prod.name
}
前述の通り、超簡易な機能でAWSへのリソースアクセスはないので、IAMはCloudWatch Logsへのログ出力ができれば良い。
キモになるのは、Lambdaの以下の部分。
package_type = "Image"
image_uri = "${aws_ecr_repository.getemployee.repository_url}:latest"
これで、ECRのコンテナイメージを参照しにいってくれる。
null_resource.image_push
を待ち合せないと、コンテナイメージの参照エラーになるので、depends_on
で順序性担保しておこう(今回のイメージのPUSHはけっこう時間かかるので、やっておかないとほぼ確実にエラーになる)。
また、この後CI/CDを回していくと、コンテナイメージのURIやバージョンは変わってしまうので、ignore_changes
で変わっても無視するようにしておく。
同様に、CI/CDを意識して以下のようにしておく。
- エイリアスに対するデプロイをするために、
aws_lambda_function.getemployee
でpublishを設定してバージョンを払い出し、払い出したバージョンに対してaws_lambda_alias.getemployee_prod
でエイリアスを割り付ける -
aws_lambda_alias.getemployee_prod
のバージョンもこの後のCI/CDで変わってしまうので、ignore_changes
する - API Gatewayや別のリソースと紐づける場合は、
aws_lambda_permission
でアクセス権を設定するが、ここでもエイリアスを意識できるようにするためにqualifierにaws_lambda_alias.getemployee_prod.name
を設定する(今回のサンプルではこの後API Gatewayと接続する)
API Gateway
API Gatewayは以下のように設定する。
ちょっと前まではAPI Gateway系のリソースをゴリゴリ定義しているが、結局中途半端にYAMLと合わせるとデグレードしまくるので、最近はYAMLに一本化するようにすることが多い。
################################################################################
# API Gateway #
################################################################################
resource "aws_api_gateway_rest_api" "go_container_image" {
name = local.apigateway_name
body = data.template_file.apigateway_body.rendered
}
data "template_file" "apigateway_body" {
template = file("./apigateway_body.yaml")
vars = {
title = local.apigateway_name
aws_account = data.aws_caller_identity.self.account_id
aws_region_name = data.aws_region.current.name
lambda_getemployee_function_name = aws_lambda_function.getemployee.function_name
}
}
resource "aws_api_gateway_stage" "prod" {
stage_name = "prod"
rest_api_id = aws_api_gateway_rest_api.go_container_image.id
deployment_id = aws_api_gateway_deployment.for_prod.id
cache_cluster_enabled = false
xray_tracing_enabled = false
}
resource "aws_api_gateway_deployment" "for_prod" {
rest_api_id = aws_api_gateway_rest_api.go_container_image.id
triggers = {
redeployment = sha1(jsonencode([
data.template_file.apigateway_body.rendered,
]))
}
lifecycle {
create_before_destroy = true
}
}
swagger: "2.0"
info:
description: "Lambdaコンテナイメージ用API Gateway"
title: ${title}
schemes:
- "https"
paths:
/employee:
get:
responses: {}
x-amazon-apigateway-integration:
httpMethod: "POST"
uri: "arn:aws:apigateway:${aws_region_name}:lambda:path/2015-03-31/functions/arn:aws:lambda:${aws_region_name}:${aws_account}:function:${lambda_getemployee_function_name}:Prod/invocations"
passthroughBehavior: "when_no_match"
timeoutInMillis: 29000
type: "aws_proxy"
この後記載するCI/CDでは、エイリアスで新旧振り分けをするために、API GatewayからのLambdaのアクセスもエイリアス向けにする。つまり、YAML中のuriは
uri: "arn:aws:apigateway:${aws_region_name}:lambda:path/2015-03-31/functions/arn:aws:lambda:${aws_region_name}:${aws_account}:function:${lambda_getemployee_function_name}:Prod/invocations"
としておく。
これで、ひとまずはLambdaコンテナイメージの自動構築までは完成だ。
デプロイして、
$ curl https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/employee?id=00001
とアクセスすると
{"id":"00001","name":"Taro"}
という結果が得られる。
ローカルテストをしてみよう
さて、せっかくコンテナを使うので、「これまで実際にLambdaに組み込まないと試験しにくかったんだけど……」というつらみがあったのを解消してみよう(別に、SAMとか使えばこれまでもやりようはあったけど)。Dockerfileのところにも記載したRIE(Runtime Interface Emulator)を使う。RIEについては、公式の開発者ガイドに詳しく記載されている。要は、HTTPサーバを立ててLambdaを起動してくれるのだ。
docker build
したら、以下のようにローカルでコンテナ起動すればよい。
$ docker run -p 9000:8080 [ビルドイメージ名] /main
/main
はあってもなくても問題ない。Dockerfileで、CMDで指定しているため、なくても勝手に引数は/main
で動作する。
これで起動したサーバに対して
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"queryStringParameters":{"id": "00001"}}' | jq -r
と投げ込めば良い。
気を付けなければいけないのは、期待するHTTPリクエストがGETだとしても、curlではPOSTにすること。
要は、API GatewayのLambda統合プロキシを疑似っているイメージだ。Bodyについても、API Gatewayのイベントを疑似る必要がある。
さて、これでローカルテストもできるし、なんならDocker ComposeでDynamoDB Localとつなぐことも可能だ!CIが捗る!
というわけでCI/CDもしてみよう
CI/CDは普通にパイプラインを組んであげれば良い。
LambdaのデプロイはSAMでやるのが一般的ではあるが、Terraformとリソース管理が分割されてしまってイケていないので、今回はCodeDeployを使う。
CodePipeline
CodePipelineはあまり捻らず、標準的に作成する。
必要に応じて承認ステージ等は追加してもらいたい。
なお、CodeDeployのプロバイダでは、インプットとなるアーティファクトは1種類しか設定できない。
今回は、CI/CDの中でLambdaのバージョンを新規に払いして動的にAppSpecを作成するため、ビルド時のアーティファクトをインプットにする。
################################################################################
# CodePipeline #
################################################################################
resource "aws_codepipeline" "lambda_container_image" {
name = local.codepipeline_pipeline_name
role_arn = aws_iam_role.codepipeline.arn
artifact_store {
type = "S3"
location = aws_s3_bucket.artifact.bucket
}
stage {
name = "Source"
action {
run_order = 1
name = "Source"
category = "Source"
owner = "AWS"
provider = "CodeCommit"
version = "1"
output_artifacts = ["SourceArtifact"]
configuration = {
RepositoryName = local.codecommit_repository_name
BranchName = "master"
}
}
}
stage {
name = "Build"
action {
run_order = 2
name = "Build"
category = "Build"
owner = "AWS"
provider = "CodeBuild"
version = "1"
input_artifacts = ["SourceArtifact"]
output_artifacts = ["BuildArtifact"]
configuration = {
ProjectName = aws_codebuild_project.lambda_container_image.name
}
}
}
stage {
name = "Deploy"
action {
run_order = 3
name = "Deploy"
category = "Deploy"
owner = "AWS"
provider = "CodeDeploy"
version = "1"
input_artifacts = [
"BuildArtifact",
]
configuration = {
ApplicationName = aws_codedeploy_app.lambda_container_image.name
DeploymentGroupName = aws_codedeploy_deployment_group.lambda_container_image.deployment_group_name
}
}
}
}
CodeBuild
CodeBuildもそれほど難しくはない。ZipのLambdaの場合はprivileged_mode
は不要だが、今回はコンテナを扱うためtrueに設定しておこう。environment_variable
では、この後出てくるBuildSpecに必要な環境変数を渡しておく。
################################################################################
# CodeBuild #
################################################################################
resource "aws_codebuild_project" "lambda_container_image" {
name = var.codebuild_project_name
service_role = aws_iam_role.codebuild.arn
source {
type = "CODEPIPELINE"
buildspec = "buildspec.yml"
}
artifacts {
type = "CODEPIPELINE"
}
environment {
type = "LINUX_CONTAINER"
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/amazonlinux2-x86_64-standard:3.0"
privileged_mode = "true"
environment_variable {
name = "AWS_REGION_NAME"
value = var.aws_region_name
}
environment_variable {
name = "ECR_REPOSITORY_NAME"
value = var.ecr_repository_name
}
environment_variable {
name = "LAMBDA_FUNCTION_NAME"
value = var.lambda_function_name
}
}
logs_config {
cloudwatch_logs {
group_name = aws_cloudwatch_log_group.codebuild.name
}
}
cache {
type = "LOCAL"
modes = [
"LOCAL_DOCKER_LAYER_CACHE",
]
}
}
BuildSpecとAppSpec
BuildSpecの中で払い出したLambdaのバージョンをAppSpecに設定したいので、AppSpecは以下のようにenvsubst
で環境変数を渡せるようにしておく。
version: 0.0
Resources:
- LambdaFunction:
Type: AWS::Lambda::Function
Properties:
Name: "${LAMBDA_FUNCTION_NAME}"
Alias: "Prod"
CurrentVersion: "${LAMBDA_CURRENT_VERSION}"
TargetVersion: "${LAMBDA_TARGET_VERSION}"
BuildSpecでは、上述したDockerfileをdocker build -t
して、ECRにPUSHする。
ここは、ECSのコンテナビルドに似ているのでわかりやすい。ECSのビルド同様、GitのCommitハッシュをタグにして冪等性を担保しよう。
version: 0.2
phases:
build:
commands:
- AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query 'Account' --output text)
- REPOSITORY_URI=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION_NAME}.amazonaws.com/${ECR_REPOSITORY_NAME}
- echo Container build started on `date`
- docker build -t ${REPOSITORY_URI}:latest .
- echo Container build finished on `date`
post_build:
commands:
- echo ECR login started on `date`
- $(aws ecr get-login --region ${AWS_REGION_NAME} --no-include-email)
- echo ECR login finished on `date`
- IMAGE_TAG=$(echo ${CODEBUILD_RESOLVED_SOURCE_VERSION} | cut -c 1-7)
- docker tag ${REPOSITORY_URI}:latest ${REPOSITORY_URI}:${IMAGE_TAG}
- echo ECR image push started on `date`
- docker push ${REPOSITORY_URI}:${IMAGE_TAG}
- docker push ${REPOSITORY_URI}:latest
- echo ECR image push finished on `date`
- echo Lambda publish started on `date`
- PUBLISH_RESULT=$(aws lambda update-function-code --function-name ${LAMBDA_FUNCTION_NAME} --image-uri ${REPOSITORY_URI}:${IMAGE_TAG} --publish)
- echo ${PUBLISH_RESULT}
- echo Lambda finished started on `date`
- export LAMBDA_CURRENT_VERSION=$(aws lambda get-alias --function-name ${LAMBDA_FUNCTION_NAME} --name Prod | jq -r .FunctionVersion)
- export LAMBDA_TARGET_VERSION=$(echo ${PUBLISH_RESULT} | jq -r .Version)
- envsubst < appspec_template.yaml > appspec.yaml
artifacts:
files:
- appspec.yaml
discard-paths: yes
上記の通り、LAMBDA_CURRENT_VERSION
はCLIのaws lambda get-alias
でエイリアスの最新版を取得する。また、LAMBDA_TARGET_VERSIONは、ビルド後のリソースをpublishした出力から取得する。
このため、CodeBuildのロールには以下の権限を付与しておこう。
- lambda:GetAlias
- lambda:UpdateFunctionCode
これらの変数をenvsubst
で置換して、CodeDeployに渡してあげよう。
ちなみに、appspec.yamlはappspec.ymlにするとCodeDeployがコケるので注意。
CodeDeploy
CodeDeployは特に難しいことはなく以下のように設定すれば良い。
################################################################################
# CodeDeploy #
################################################################################
resource "aws_codedeploy_app" "lambda_container_image" {
name = var.codedeploy_application_name
compute_platform = "Lambda"
}
resource "aws_codedeploy_deployment_group" "lambda_container_image" {
deployment_group_name = var.codedeploy_deployment_group_name
app_name = aws_codedeploy_app.lambda_container_image.name
service_role_arn = aws_iam_role.codedeploy.arn
deployment_config_name = "CodeDeployDefault.LambdaCanary10Percent5Minutes"
deployment_style {
deployment_type = "BLUE_GREEN"
deployment_option = "WITH_TRAFFIC_CONTROL"
}
}
これでパイプラインを走らせると、5分間は10%のトラフィックが新バージョンに流れ、90%のトラフィックが旧バージョンに流れるようになる。
SAMのCI/CDとほぼ同様の動作の完成だ!
というわけでCI/CDもしてみよう(SAMテンプレート編)
とは言え、これまでLambdaのCI/CDを作ってきた人は、CodePipelineのDeployステージのプロバイダをCloudFormationにして、SAMテンプレートでデプロイするという方法を選択したい人もいるだろう。(SAMテンプレートでコンテナイメージではないLambdaをデプロイするサンプルは以前の記事参照)
SAMテンプレートは、ちゃんとコンテナイメージのLambdaにも対応している。
参考になるのはクラスメソッド先生の記事だが、これはSAMテンプレートではなく、変換なしのCloudFormationテンプレートだ。
AWS公式のSAMに関するデベロッパーガイドで以下のように記載されている。
ImageUri
Lambda 関数のコンテナイメージ用の Amazon Elastic Container Registry (Amazon ECR) リポジトリの URI です。このプロパティは、PackageType プロパティが Image に設定されている場合にのみ適用され、それ以外の場合は無視されます。詳細については、AWS Lambda デベロッパーガイドの「Lambda でのコンテナイメージの使用」を参照してください。注意:PackageType プロパティが Image に設定されている場合は、ImageUri が必要になります。または、AWS SAM テンプレートファイルで必要な Metadata を使用してアプリケーションを構築する必要があります。詳細については、「アプリケーションの構築」を参照してください。
必要な Metadata エントリを使用してアプリケーションを構築することは、ImageUri よりも優先されるので、両方を指定すれば ImageUri は無視されます。
型: 文字列
必須: いいえ
AWS CloudFormation との互換性: このプロパティは、AWS::Lambda::Function Code データ型の ImageUri プロパティに直接渡されます。
CloudFormationではCodeの配下になるが、SAMの場合はImageプロパティと同列に記載して良いので、
PackageType: Image
ImageUri: !Ref 'EcrImageUri'
と書いておけば良い。
しかし、CI/CDで対応する場合、CloudFormationに対して変更後のイメージURIを動的に渡すことができない。
タグをlatestにしておけば気にする必要はないが、latestを参照してしまうとバージョンを戻してもECR側が戻らないため運用上の問題が発生する。
ここは、SAMテンプレートを
PackageType: Image
ImageUri: ${EcrImageUri}
としておき、CodeBuild側で以下のようにenvsubstしてビルドアーティファクトとしてCloudFormationに継承しよう。
phases:
# (中略)
post_build:
commands:
- export EcrImageUri=[イメージURI]:[タグ]
- envsubst '${EcrImageUri}' < template.yml > package.yml
# (中略)
artifacts:
type: zip
files:
- package.yml
これで、従来のCI/CDを大きく見直さなくてもコンテナイメージのLambdaをデプロイできるようになった!