Blog

Automate Lambda Dependencies with Terraform

17 Jan, 2024
Xebia Background Header Wave

Recently I have been working on a few projects that involved Lambda functions. This meant defining and building these functions and all infrastructure around them via Terraform. When doing this previously I have always struggled to find simple ways to integrate the dependencies into the deployment to minimise manual intervention or unnecessary convoluted steps, so this time around I decided to invest some time into finding a better solution! So this is a quick article to cover the solution I found along with its pros and (frustrating) con.

Dir Structure

For this solution to work the project directory should be structured in a way so that terraform can find the dependencies you are looking to install to your Lambda. I utilise Terraform Cloud  for my deployments so have structured my directory in order to reflect this. Terraform Cloud works by isolating the terraform directory out of the project and working from there. This means all relevant files must be within this directory. If you are using a local deployment method, this will afford you more freedom in terms of where you can store your files. Remember to adjust the path names to the correct location where necessary though.

NOTE: This example uses a python function structure, but can be adjusted to fit other languages with a little know how.

The dir structure is as follows:

project/
|-README.md
|-terraform/
|  |-function/
|  |  |-main.py
|  |-layer/
|  |  |-requirements.txt
|  |-main.tf
|  |-variables.tf

Our requirements.txt file should be in the form of a standard python requirements text file eg:

boto3==1.33.8
requests>=2.0.0

With this structure in place we can put together our terraform file.

Terraform resources

I have omitted all of the config details of the resources not in scope so we can focus on the layer config:

terraform {
  # ... config
}

provider "aws" {
  # ... config
}

resource "null_resource" "install_layer_dependencies" {
  provisioner "local-exec" {
    command = "pip install -r layer/requirements.txt -t layer/python/lib/python3.9/site-packages"
  }
  triggers = {
    trigger = timestamp()
  }
}

data "archive_file" "layer_zip" {
  type        = "zip"
  source_dir  = "layer"
  output_path = "layer.zip"
  depends_on = [
    null_resource.install_layer_dependencies
  ]
}

resource "aws_lambda_layer_version" "lambda_layer" {
  filename = "layer.zip"
  source_code_hash = data.archive_file.layer_zip.output_base64sha256
  layer_name = "my_layer_name"

  compatible_runtimes = ["python3.9"]
  depends_on = [
    data.archive_file.layer_zip
  ]
}

data "archive_file" "function_zip" {
  type        = "zip"
  source_dir  = "function"
  output_path = "function.zip"
}

resource "aws_lambda_function" "lambda" {
  # ... other config
  filename      = "function.zip"
  source_code_hash = data.archive_file.functip_zip.output_base64sha256
  runtime = "python3.9"

  role = aws_iam_role.role.arn

  layers = [
    aws_lambda_layer_version.lambda_layer.arn
  ]
  depends_on = [
    data.archive_file.function_zip,
    aws_lambda_layer_version.lambda_layer
  ]
}

resource "aws_iam_role" "role" {
  # ... config
}

resource "aws_iam_role_policy" "role_policy" {
  # ... config
}

The key part to focus on here is the null_resource. This is where the dependencies are installed. In our example we are reading from the requirements.txt file and installing those dependencies into the layer/ directory, which we then zip this file with the archive_file.layer_zip data source so it can be used by the aws_lambda_function. To adjust this process for your runtime and dir structure, change the command within the null_resource to fit your install needs. It is also worth taking note of the depends_on variable within the resources, as they help make sure everything is in place before the next stage is deployed.

The next thing to point out is the triggers section in the null_resource. Due to how the state file works, in order to deploy this resource repeatedly a trigger needs to be added. Ideally this trigger would be a change in the requirements.txt file, however without being able to add this same trigger to the archive_file.layer_zip and aws_lambda_layer_version resources using the change in requirements.txt file as a trigger leads to improperly packaged layers down the line. At this current time the best we can use is timestamp(). Using this as a trigger causes a layer version to be built on every apply, which means the layer is always packaged correctly, but also causes our frustrating con: we have to wait to build a new layer version on every apply, even if there are no changes.

Layer vs Package

We have used a layer for our dependencies in this example, however we can easily adjust this to be a standard package. By adjusting the null_resource to install the dependencies into the function/ directory, we can remove the layer zip and layer version resources, and use the zipped function file directly. As mentioned previously though, this will cause the dependencies to be built on every apply, so having the function and dependencies packaged together means the function code will also be redeployed every apply. More info on this process can be found here.

Summary

Although using this method does lead to slower deployment times as the layer has to be built every time, I feel the tradeoff is worth it. Dependencies can easily be added to the requirements.txt and they will be automatically and correctly deployed to your function. Overall I feel you are trading speed for a "fire and forget" dependency installation method. On top of this, as Terraform evolves, hopefully triggers will be added to other resources in order to remove this speed hurdle. This post only covers AWS but with this concept you can adjust the method to fit any severless platform you use!

Questions?

Get in touch with us to learn more about the subject and related solutions

Explore related posts