Deploying AWS Lambda with Terraform

Pinned note: Writing this post does not mean I enjoy using Terraform.

This section is a bit verbose — feel free to skip straight to my GitHub.
Deploy with local Terraform: https://github.com/chengqing-su/lambda-deployment-via-terraform
Deploy with Docker: https://github.com/chengqing-su/lambda-deployment-via-dockerized-terraform

Deploying with Local Terraform

The minimal components of an AWS Lambda setup:

  • Lambda code: defines what the Lambda does and how it does it
  • AWS Lambda function’s execution role: defines what AWS resources and services the Lambda function is allowed to access
  • AWS Lambda function resource
  • AWS CloudWatch Log Group (optional): stores execution logs

I wrote a very simple demo that just prints log output. It is written in Node.js 12 and TypeScript.

1
2
3
4
5
6
7
8
9
10
├── README.md
├── deployment
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
├── package.json
├── src
│ └── index.ts
├── tsconfig.json
└── yarn.lock

If you find this section too verbose, you can go straight to the code on GitHub: https://github.com/chengqing-su/lambda-deployment-via-terraform

If you’re interested in how to deploy with Docker and why you’d want to, skip ahead to the “Containerized Deployment” section, or check out my code: https://github.com/chengqing-su/lambda-deployment-via-dockerized-terraform

Lambda Code

The application code lives under src. This is an extremely simple demo with no tests. If tests were included, they would go in a tests directory at the same level as src (a bit verbose, I know).

1
2
3
4
5
6
7
8
9
import {
CloudWatchLogsEvent
} from "aws-lambda";
export const handler = async (
event: CloudWatchLogsEvent
): Promise<void> => {
console.log(event)
console.log("This is test lambda")
}

Next is the infrastructure code, which lives under deployment.
Typically, you need to package the Lambda application code first. The code below builds the final deployable artifact and packages it into a zip archive.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
resource "null_resource" "package" {
provisioner "local-exec" {
working_dir = "${path.module}/../"
command = "yarn && yarn compile"
}
triggers = {
run_every_time = uuid()
}
}

data "archive_file" "lambda" {
type = "zip"
source_dir = "${path.module}/../dist"
output_path = "${path.module}/../function.zip"

depends_on = [null_resource.package]
}

AWS Lambda function’s execution role

The Lambda function’s execution role defines the permissions the function has to access AWS services and resources.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# lambda execution role
resource "aws_iam_role" "execution_role" {
name = "lambda_execution_role"

assume_role_policy = <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
}
}
]
}
EOF
}

resource "aws_iam_role_policy_attachment" "lambda_logs_policy" {
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
role = aws_iam_role.execution_role.name
}

AWS Lambda function resource

1
2
3
4
5
6
7
8
9
10
11
resource "aws_lambda_function" "function" {
function_name = var.function_name
handler = "index.handler"
role = aws_iam_role.execution_role.arn
runtime = "nodejs12.x"

filename = data.archive_file.lambda.output_path

depends_on = [data.archive_file.lambda]
}

AWS CloudWatch Log Group

Create a Log Group to store the Lambda execution logs.

1
2
3
4
resource "aws_cloudwatch_log_group" "logs" {
name = "/aws/lambda/${var.function_name}"
retention_in_days = 90
}

Deploying with Local Terraform

After all that explanation, here is how to actually deploy — which turns out to be straightforward.

1
2
3
cd deployment/
terraform init
terraform deploy -auto-prove

Containerized Deployment

The pain points of local deployment are:

  • If the required tooling is not installed, you have to set everything up from scratch. Even if the environment exists, you cannot be sure it is clean, or that other team members are using the correct versions. In this demo alone, you need Node.js 12 (the latest LTS at the time was 14), Yarn, and Terraform 0.14.4 — using a higher or lower version can cause unpredictable issues.
  • In real projects, you often work with multiple Lambda functions that may use different runtimes — some in Python, some in Ruby, some in Node.js. Installing all of those environments locally becomes a significant burden.

Containerization is therefore essential.

First, introduce a docker-compose.yaml at the project root. Here is an example for a Node.js project:

1
2
3
4
5
6
7
version: "2"
services:
dev:
image: node:12
working_dir: /app
volumes:
- .:/app

Next, add an auto directory at the same level as deployment to hold automation scripts. Here is an example Terraform wrapper script at auto/terraform:

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash -e

cd "$(dirname "$0")/.."

docker run --rm \
--volume "$(pwd):/app" \
--workdir "/app/deployment" \
-e AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY \
-e AWS_DEFAULT_REGION=ap-southeast-1 \
hashicorp/terraform:0.14.4 \
"${@}"

With auto/terraform in place, how do you deploy? Add one more automation script, auto/deploy, like this:

1
2
3
4
5
6
#!/bin/bash -e

"$(dirname "$0")"/compile

"$(dirname "$0")"/terraform init
"$(dirname "$0")"/terraform apply -auto-approve

Deployment then becomes extremely simple:

1
2
3
4
5
export AWS_ACCESS_KEY_ID=<YOUR_AWS_ACCESS_KEY_ID>
export AWS_SECRET_ACCESS_KEY=<YOUR_AWS_SECRET_ACCESS_KEY>
export AWS_DEFAULT_REGION=<YOUR_AWS_DEFAULT_REGION>

./auto/deploy