REST API Deployment on AWS Lambda with Terraform (2023)

Posted at (714 words, 4 minutes to read)
Tags: , , ,
Table of Contents

In this article I will show the way how to deploy AWS Lambda using Terraform, with support for code updates in a single workflow.

The problem with AWS Lambda is that at the time of its creation we need to have prepared code in zip archive or Docker image uploaded to ECR, unlike ECS / Kubernetes service, where we just specify the path to the repository, which can be empty at the creation stage. So to make an AWS Lambda deploy using Terraform we will need to prepare the code in the same Terraform recipe.

In this article I will use the Docker image as source for AWS Lambda, as it is essentially the only one normal way to run any complex applications in Lambda that have external dependencies (libraries). There is a way to package the dependencies in a zip archive too, but if the architecture of the computer where the dependencies were built is different from the one where AWS Lambda runs, there will be problems. That’s why Docker is the most clear and reliable option.

So this article will explore the creation of such resources:

Preparation

As usual, we start our work by creating a new project and declaring terraform dependencies:

mkdir lambda-demo; cd lambda-demo; touch main.tf

Describe Terraform dependencies in main.tf file

terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "5.17.0" }
  }
}

provider "aws" {
  profile = "default"
  region  = "us-east-1"
}

Creating HTTP application with FastAPI

Let’s create subfolder for our code and create 3 files:

mkdir code; touch code/app.py code/requirements.txt code/Dockerfile

Next, in the app.py file add the application code:

import os
import uvicorn
from fastapi import FastAPI
from mangum import Mangum


app = FastAPI()


@app.get("/")
def index():
    return "Hello, from AWS Lambda!"


handler = Mangum(app, lifespan="off")

if __name__ == "__main__":
    uvicorn_app = f"{os.path.basename(__file__).removesuffix('.py')}:app"
    uvicorn.run(uvicorn_app, host="0.0.0.0", port=8000, reload=True)

This code runs the FastAPI service, so we can write basically as much complex API as we want. Mangum is a wrapper for the Lambda API, it does all the request processing logic in Lambda internally, allowing FastAPI to work normally. The code in the __name__ == "__main__" section is needed to run the application during development — we can write our API locally as usual.

The following library should be added to requirements.txt:

fastapi==0.103.2
mangum==0.17.0
uvicorn==0.23.2

And finally the Dockerfile to build an image for our application:

FROM public.ecr.aws/lambda/python:3.11
ENV PYTHONUNBUFFERED=1

COPY requirements.txt ./
RUN pip3 install -r requirements.txt

COPY app.py ./
CMD [ "app.handler" ]

Creating ECR

We have the application ready, now we need to run it in AWS Lambda. First of all, we need to create a resource for ECR image. All the work below continues in the main.tf file.

# --- ECR ---

resource "aws_ecr_repository" "api" {
  name                 = "lambda-api"
  image_tag_mutability = "MUTABLE"
  force_delete         = true

  image_scanning_configuration {
    scan_on_push = true
  }
}

Build & Push Docker image

The next step is to build the image directly in the Terraform recipe. For this purpose null_resource will be used.

# --- Build & push image ---

locals {
  repo_url = aws_ecr_repository.api.repository_url
}

resource "null_resource" "image" {
  triggers = {
    hash = md5(join("-", [for x in fileset("", "./code/{*.py,*.txt,Dockerfile}") : filemd5(x)]))
  }

  provisioner "local-exec" {
    command = <<EOF
      aws ecr get-login-password | docker login --username AWS --password-stdin ${local.repo_url}
      docker build --platform linux/amd64 -t ${local.repo_url}:latest ./code
      docker push ${local.repo_url}:latest
    EOF
  }
}

data "aws_ecr_image" "latest" {
  repository_name = aws_ecr_repository.api.name
  image_tag       = "latest"
  depends_on      = [null_resource.image]
}

The local-exec part is a classic bash script that logs into ECR, builds and flushes the image. We just wrote it in a Terraform file directly.

The more interesting part is the triggers. It is needed to rebuild the image every time the files in the code folder are changed. The files are selected by mask, so if you need other extensions you should add them to the list.

And the last block is to get information about the last loaded image in ECR. We will need this next to update AWS Lambda.

Creating Lambda resource

As is typical in AWS, we need permissions to perform various actions. Lambda is no exception and needs a separate role as well. Let’s create a new role with access to Lambda functions and the ability to write logs.

# --- IAM Role ---

resource "aws_iam_role" "lambda" {
  name = "lambda"

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

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

Next, we create a resource for the Lambda function:

# --- Lambda ---

resource "aws_cloudwatch_log_group" "api" {
  name              = "/aws/lambda/api"
  retention_in_days = 14
}

resource "aws_lambda_function" "api" {
  function_name    = "api"
  role             = aws_iam_role.lambda.arn
  image_uri        = "${aws_ecr_repository.api.repository_url}:latest"
  package_type     = "Image"
  source_code_hash = trimprefix(data.aws_ecr_image.latest.id, "sha256:")
  timeout          = 10

  environment {
    variables = {}
  }

  depends_on = [
    null_resource.image,
    aws_iam_role_policy_attachment.lambda_logs,
    aws_cloudwatch_log_group.api,
  ]
}

Lambda public endpoint

Finally we need to create public endpoint. CORS block required to access API from browser. In this article all values is wildcard, but you can change it as your requirements, like: allow_origins = ["https://my-domain.com"]

# --- Lambda Endpoint ---

resource "aws_lambda_function_url" "api" {
  function_name      = aws_lambda_function.api.function_name
  authorization_type = "NONE"

  cors {
    allow_credentials = true
    allow_origins     = ["*"]
    allow_methods     = ["*"]
    allow_headers     = ["date", "keep-alive"]
    expose_headers    = ["keep-alive", "date"]
    max_age           = 86400
  }
}

output "api_url" {
  value = aws_lambda_function_url.api.function_url
}

Deploying

Initialise terraform and apply the configuration:

terraform init
terraform validate && terraform apply

Once this is complete, we will see a link to AWS Lambda:

Apply complete! Resources: 7 added, 0 changed, 0 destroyed.

Outputs:

api_url = "https://xxx.lambda-url.us-east-1.on.aws/"

Let’s try:

https $(terraform output -raw api_url)
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 25
Content-Type: application/json
Date: Thu, 12 Oct 2023 13:19:40 GMT
X-Amzn-Trace-Id: root=1-6527e150-3c0aed5e335681cb7e120cde;sampled=0;lineage=1da281dc:0
x-amzn-Remapped-content-length: 25
x-amzn-RequestId: a560d83b-40c4-4c27-a381-0bb630547a4a

"Hello, from AWS Lambda!"

It’s works!

Updating code and deploy new version

Since we have prepared all the necessary invalidations above, we simply update the app.py file and re-run terraform apply. I’ll just add v2 to the welcome text. Check it:

terminal screenshot

New application version available on same URL!

Note: https is binary from httpie.


Next, depending on requirements, we can add AWS Lambda to Route53 (so that there is a link to our domain), configure restriction policy. But these are topics for another article. Good luck with your AWS journey!