Deploying lambdas to AWS has always been painful when those Lambdas need more than just boto3 and when sticking to Infrastructure-as-Code. You can bring in the Serverless Framework, but it is complicated to bring into your CICD pipelines and has some issues with repeatability of deployments. So, what if you want to deploy a single Lambda?
In this post, I'll discuss the method I use to deploy a Lambda, written in Python 3, to AWS using Terraform and GitLab CI.
Project Structure
For this project, let's make some assumptions. First, it's that your project is configured with the following structure:
project_root
| .gitlab-ci.yml
|
└--lambda/
| | requirements.txt
| | function.py
|
└--terraform/
| versions.tf
| variables.tf
| ...
Second, it's that you already have Terraform working in your GitLab CI setup using Terraform images from ECR Public Gallery or Docker Hub. A plan job should kick off automatically (perhaps after validation) and an apply job can run (manually?) after the terraform plan
is completed. Basically, getting Terraform up and running in CI is out of the scope of this article, but I will show you how to make your apply job work with the Lambda.
Prep the Apply Job
My normal apply job looks like this:
terraform_apply:
stage: apply
before_script:
- export TF_VAR_project_title
- cd terraform/
- rm -rf .terraform
script:
- terraform init
- terraform apply -input=false $PLAN
when: manual
dependencies:
- terraform_plan
artifacts:
name: $CI_COMMIT_REF_SLUG
untracked: true
only:
- branches
I have globally defined TF_VAR_project_title
to be equal to $CI_PROJECT_TITLE
, so that I can tag AWS resources with the project name (making everything easier to find) and am using public.ecr.aws/hashicorp/terraform:1.1.2
as my global image.
We'll need to package up our Python dependencies, so Terraform will need access to pip. We can do this by installing the py3-pip package into the Terraform container. Add the following line to the before_script
in the apply job:
- apk add py3-pip
Your validate, plan, and destroy jobs don't need this. Just apply.
Packaging the Lambda
So, we need our Lambda's dependencies to be packaged up with the source code so that aws_lambda_function
can use it. Luckily, we can do this in Terraform using Terraform's null_resource
and archive_file
.
Let's ensure that we have the lambda_root
variable set to the relative path to the Lambda source from the Terraform directory. My definition looks like this:
variable "lambda_root" {
type = string
description = "The relative path to the source of the lambda"
default = "../lambda"
}
Now, we install our dependencies.
resource "null_resource" "install_dependencies" {
provisioner "local-exec" {
command = "pip install -r ${var.lambda_root}/requirements.txt -t ${var.lambda_root}/"
}
triggers = {
dependencies_versions = filemd5("${var.lambda_root}/requirements.txt")
source_versions = filemd5("${var.lambda_root}/function.py")
}
}
This has pip install our Python dependencies into the lambda/
folder, so that they can be zipped up with our code. The triggers
block determines when this will execute and our map is ensuring that happens when function.py
or requirements.txt
is changed. If you have additional files that should be watched, add them in with their own unique keys, such as:
helper_versions = filemd5("${var.lambda_root}/helpers.py")
Now, in order to ensure that cached versions of the Lambda aren't invoked by AWS, let's give our zip file a unique name for each version. We can do with by hashing our files together.
resource "random_uuid" "lambda_src_hash" {
keepers = {
for filename in setunion(
fileset(var.lambda_root, "function.py"),
fileset(var.lambda_root, "requirements.txt")
):
filename => filemd5("${var.lambda_root}/${filename}")
}
If you have any additional source files, include them in the setunion()
block.
Now that we have a unique name for the zip file, let's create it.
data "archive_file" "lambda_source" {
depends_on = [null_resource.install_dependencies]
excludes = [
"__pycache__",
"venv",
]
source_dir = var.lambda_root
output_path = "${random_uuid.lambda_src_hash.result}.zip"
type = "zip"
}
We ensure that the zip file runs after the dependencies are installed, exclude our virtual environment (change that from venv
to whatever you name yours) and pycache directories, and drop that zip file in the lambda/
directory.
Now we create the Lambda itself.
resource "aws_lambda_function" "lambda" {
function_name = "my_function"
role = aws_iam_role.lambda_role.arn
filename = data.archive_file.lambda_source.output_path
source_code_hash = data.archive_file.lambda_source.output_base64sha256
handler = var.lambda_handler
runtime = var.lambda_runtime
environment {
variables = {
LOG_LEVEL = var.lambda_log_level
}
}
depends_on = [
aws_iam_role_policy_attachment.lambda_execution,
aws_cloudwatch_log_group.task-creation,
]
reserved_concurrent_executions = var.lambda_concurrent_executions
tags = local.tags
I'm using a aws_iam_role
created to give the Lambda its needed permissions. What these are are dependent on what your Lambda does and is outside of the scope of this article.
I'm also using a bunch of variables:
lambda_handler
: your function reference. Assuming we were using a function calledhandler()
infunction.py
, it would befunction.handler
.lambda_runtime
: the runtime for you Lambda. I'm usingpython3.9
, but the full list is here.lambda_log_level
: the logging level I want to use for my Lambda. I set this toINFO
, but can switch it to debug or whatever I feel. Note that this is set viasetLevel
in the Python code itself and isn't just some magic that Lambda provides.lambda_concurrent_executions
: the number of simultaneous executions of the lambda that I'm willing to have. This depends on your needs.
Triggering Terraform Jobs on Lambda Changes
My validate and plan jobs normally look like this:
terraform_validate:
stage: validate
before_script:
- export TF_VAR_project_title
- cd terraform/
- rm -rf .terraform
- terraform --version
- terraform init
script:
- terraform validate
only:
refs:
- branches
changes:
- '*'
- terraform/**/*
terraform_plan:
stage: plan
before_script:
- export TF_VAR_project_title
- cd terraform/
- rm -rf .terraform
- terraform --version
- terraform init
script:
- terraform plan -out=$PLAN -input=false
artifacts:
name: plan
paths:
- terraform/${PLAN}
only:
refs:
- branches
changes:
- '*'
- terraform/**/*
needs:
- terraform_validate
So, these trigger when I change something in the terraform/
directory or anything at the project root (like my .gitlab-ci.yml
file). However, what happens when we make a change to our Lambda? The Terraform jobs won't run. So, let's fix that by adding the following line to the changes:
list in each of those jobs:
- lambda/**/*
Now, when we change our Python source or update the requirements.txt
file, our Terraform pipeline will kick off so that our new Lambda package can be deployed.
Comments