LoginSignup
19
9

More than 1 year has passed since last update.

TerraformでLambdaコンテナイメージを自動構築する

Last updated at Posted at 2021-07-11

はじめに

昨年末にGAされたLambdaのコンテナイメージ機能。
プラットフォームとしてのデプロイ方法が増えて、ますますLambdaの使い勝手が良くなってきている。

今回は、通常のZipによるデプロイではなくて、コンテナイメージでのデプロイをTerraformで自動化してみよう。

前提知識としては以下だ。

  • TerraformによるLambdaのデプロイ経験がある
  • SAMによるLambdaのデプロイ経験がある(参考記事)
  • Dockerの基本のキは理解している(参考記事)

ソースコード

今回は動けば何でもよいので、AWSリソースとも接続しない、テキトーにJSONを返すだけのコードにしよう。
クエリからidを受け取り、それに応じた名前を返すような機能(getemployee)とする。

main.go
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(後述)の部分だけ少し変更している。

Dockerfile
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.tf
################################################################################
# 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.tf
################################################################################
# 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に一本化するようにすることが多い。

apigateway.tf
################################################################################
# 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
  }
}
apigateway_body.yaml
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.tf
################################################################################
# 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.tf
################################################################################
# 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で環境変数を渡せるようにしておく。

appspec_template.yaml
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ハッシュをタグにして冪等性を担保しよう。

buildspec.yml
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.tf
################################################################################
# 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プロパティと同列に記載して良いので、

template.yml抜粋
      PackageType: Image
      ImageUri: !Ref 'EcrImageUri'

と書いておけば良い。

しかし、CI/CDで対応する場合、CloudFormationに対して変更後のイメージURIを動的に渡すことができない。
タグをlatestにしておけば気にする必要はないが、latestを参照してしまうとバージョンを戻してもECR側が戻らないため運用上の問題が発生する。

ここは、SAMテンプレートを

template.yml抜粋
      PackageType: Image
      ImageUri: ${EcrImageUri}

としておき、CodeBuild側で以下のようにenvsubstしてビルドアーティファクトとしてCloudFormationに継承しよう。

buildspec.yml抜粋
phases:
  # (中略)
  post_build:
    commands:
      - export EcrImageUri=[イメージURI]:[タグ]
      - envsubst '${EcrImageUri}' < template.yml > package.yml
  # (中略)
artifacts:
  type: zip
  files:
    - package.yml

これで、従来のCI/CDを大きく見直さなくてもコンテナイメージのLambdaをデプロイできるようになった!

19
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
9