Post

LocalStack and Terragrunt

LocalStack and Terragrunt

Streamlining Cloud Development with LocalStack and Terragrunt

The landscape of cloud development is constantly evolving. With a growing emphasis on efficiency, consistency, and cost management we need a way to build locally to minimize cloud costs. My new scaffold project addresses these challenges head-on by providing a robust framework for managing AWS infrastructure locally fusing together LocalStack and Terraform through Terragrunt. It is built to allow for rapid development and is designed to be flexible and extensible.

The Big Problem: Cloud Development Challenges

Developing and testing infrastructure code directly on a cloud provider like AWS can be slow and expensive. Each change requires a deployment cycle, leading to long feedback loops and potentially high costs. This issue is a plague for us currently, as we have to wait for terragrunt apply’s to finish in Github Actions, and the process if extremely time consuming for our software engineers. Our apply infrastructure is also problematic, as we can’t currently apply from local machines without destroying state remotely. Furthermore, maintaining consistent environments across different stages, from an engineer’s machine to the production environment, can be a significant challenge.

The Solution: A Hybrid Approach

This project offers a solution by combining the power of two key technologies:

  • LocalStack: A local cloud service emulator that provides an API for common AWS services. This allows software engineers to build and test their applications and infrastructure locally, without an internet connection and without incurring any AWS charges. It is entirely hosted locally in Docker containers.

  • Terragrunt: A thin wrapper for Terraform that keeps your configurations DRY (Don’t Repeat Yourself). Terragrunt helps manage multiple environments by allowing you to define your infrastructure once and reuse it across different stages, such as dev, staging, and prod.

Key Features of the Scaffold

My project is designed with best practices in mind, offering several key features that benefit engineering teams:

  • Cost-Effective Local Development: By using LocalStack, software engineers can iterate on infrastructure changes without spending a dime on AWS services. This is especially useful for early-stage development, new feature development, and prototyping/testing.

  • Consistent Multi-Environment Deployment: The Terragrunt setup ensures that the same Terraform modules and configurations are used for both the local and production environments. This dramatically reduces the risk of environment-specific bugs and drift.

  • Modular and Reusable Code: The scaffold is built on reusable Terraform modules. This approach promotes maintainability and allows teams to build new infrastructure quickly by assembling pre-tested components.

  • Automated Testing: The project includes a framework for automated tests to validate that the infrastructure behaves as expected, adding a layer of reliability to the deployment process.

  • Production Safeguards: Terragrunt’s workflow, which includes plan and apply stages, provides a clear and controlled way to manage changes in production. This allows for peer review and confirmation before any resources are modified.

How It Works

The core of the scaffold lies in its directory structure. It separates reusable Terraform modules from the environment-specific Terragrunt configuration files. This separation allows you to define a resource (e.g., an S3 bucket) once as a Terraform module and then instantiate it multiple times using Terragrunt for different environments (e.g., a local bucket and a production bucket). The LocalStack setup is integrated to automatically use the local endpoint when a specific terragrunt.hcl file is executed in the local environment.

AWS Example Structure

graph TD
    %% Style definitions
    classDef s3 fill:#FF9900,stroke:#FF9900,color:white,stroke-width:2px
    classDef sqs fill:#6EBF49,stroke:#6EBF49,color:white,stroke-width:2px
    classDef lambda fill:#FF5252,stroke:#FF5252,color:white,stroke-width:2px
    classDef iam fill:#5F83BE,stroke:#5F83BE,color:white,stroke-width:2px

    %% Nodes
    Bucket1["S3 Bucket 1"]:::s3
    Bucket2["S3 Bucket 2"]:::s3

    Queue1["SQS Queue 1"]:::sqs
    Queue2["SQS Queue 2"]:::sqs
    Queue3["SQS Queue 3"]:::sqs

    Lambda1["Lambda Function 1"]:::lambda
    Lambda2["Lambda Function 2"]:::lambda
    Lambda3["Lambda Function 3"]:::lambda

    IAMRole["IAM Role"]:::iam
    IAMPolicy["IAM Policy"]:::iam

    %% Connections
    Bucket1 -->|"S3 Event: ObjectCreated"| Queue1
    Queue1 -->|"Event Source Mapping"| Lambda1
    Queue2 -->|"Event Source Mapping"| Lambda2
    Queue3 -->|"Event Source Mapping"| Lambda3

    Lambda1 -->|"Can send messages to"| Queue2
    Lambda2 -->|"Can process messages"| Queue2

    IAMRole -->|"Assumed by"| Lambda1
    IAMRole -->|"Assumed by"| Lambda2
    IAMRole -->|"Assumed by"| Lambda3

    IAMPolicy -->|"Attached to"| IAMRole

    %% Data flow explanation
    User["User/Client\n(Uploads file)"] -->|"PUT Object"| Bucket1

    %% subgraph "Data Flow"
    %%     direction LR
    %%     UploadFile["1. Upload file to S3"] --> TriggerNotification["2. S3 notification to SQS"] --> InvokeLambda1["3. Lambda1 processes message"] --> SendToQueue2["4. Lambda1 sends message to Queue2"] --> InvokeLambda2["5. Lambda2 processes message"] --> CompleteProcessing["6. Processing complete"]
    %% end

Local

The Local environment is the default environment. It is used to test and develop infrastructure locally. It is configured to use the LocalStack endpoint, which allows you to run Terraform commands locally without an internet connection. Here, you can define your infrastructure using the LocalStack provider like follow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
locals {
  environment = "local"
  aws_account_id = "000000000000"  # LocalStack default
  is_localstack = true
}

inputs = {
  environment = local.environment
  aws_account_id = local.aws_account_id
  is_localstack = local.is_localstack
}

generate "provider" {
  path      = "provider_generated.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
  terraform {
    required_providers {
      aws = {
        source  = "hashicorp/aws"
        version = "~> 5.0"
      }
    }
    required_version = ">= 1.0"
  }

  provider "aws" {
    # LocalStack configuration
    access_key = "test"
    secret_key = "test"
    region     = "us-east-1"
    skip_credentials_validation = true
    skip_metadata_api_check     = true
    skip_requesting_account_id  = true
    s3_use_path_style = true

    endpoints {
      s3 = "https://localhost.localstack.cloud:4566"
      sqs = "https://localhost.localstack.cloud:4566"
      lambda = "https://localhost.localstack.cloud:4566"
      iam = "https://localhost.localstack.cloud:4566"
    }
  }
EOF
}

and to add more endpoint types, you can add them to the endpoints block. The following example adds the Route53, SNS, and StepFunctions endpoints:

1
2
3
4
5
6
7
8
9
10
endpoints {
  s3     = "https://localhost.localstack.cloud:4566"
  sqs    = "https://localhost.localstack.cloud:4566"
  lambda = "https://localhost.localstack.cloud:4566"
  iam    = "https://localhost.localstack.cloud:4566"

  route53        = "http://localhost:4566"
  stepfunctions = "http://localhost:4566"
  sns           = "https://localhost.localstack.cloud:4566"
}

Production

When you are ready to move to production, you can simply use the following contents block:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
locals {
  environment = "production"
  aws_account_id = "PRODUCTION_ACCOUNT_ID"  # Replace with actual production account ID
  is_localstack = false
}

inputs = {
  environment = local.environment
  aws_account_id = local.aws_account_id
  is_localstack = local.is_localstack
}

generate "provider" {
  path      = "provider_generated.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
  terraform {
    required_providers {
      aws = {
        source  = "hashicorp/aws"
        version = "~> 5.0"
      }
    }
    required_version = ">= 1.0"
  }

  provider "aws" {
    # Production configuration
    region = "us-east-1"
  }
EOF
}

Testing and Logging

To get things like logs from your lambda functions, you can use the following scripts that I have defined (I am still planning to expand the scope of the test project to include more features):

1
2
3
4
5
6
7
8
# Make the script executable
chmod +x utilities/get_lambda_logs.sh

# View logs for lambda1-local (default if no argument provided)
./utilities/get_lambda_logs.sh

# View logs for a specific lambda (e.g., lambda2-local)
./utilities/get_lambda_logs.sh 2

The lambda logs are easily grabbed from the LocalStack console using:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/bash
# Make script executable with: chmod +x get_lambda_logs.sh

# Check if argument is provided
if [ $# -eq 0 ]; then
  # Default to lambda1 if no argument provided
  LAMBDA_NUM=1
else
  # Use the provided argument
  LAMBDA_NUM=$1
fi

# Set the log group name based on the lambda number
LOG_GROUP_NAME="/aws/lambda/lambda${LAMBDA_NUM}-local"

# Get the latest log stream for the specified lambda
LATEST_LOG_STREAM=$(awslocal logs describe-log-streams --log-group-name "$LOG_GROUP_NAME" | jq -r '.logStreams | max_by(.creationTime) | .logStreamName')

# Check if log stream was found
if [ -z "$LATEST_LOG_STREAM" ] || [ "$LATEST_LOG_STREAM" == "null" ]; then
  echo "No log streams found for $LOG_GROUP_NAME"
  exit 1
fi

echo "Getting logs for lambda${LAMBDA_NUM}-local from stream: $LATEST_LOG_STREAM"

# Get the log events
awslocal logs get-log-events --log-group-name "$LOG_GROUP_NAME" --log-stream-name "$LATEST_LOG_STREAM" | jq

Final Thoughts

This is more than just a template; it’s a complete workflow that simplifies Infrastructure as Code. By embracing this scaffold, teams can speed up their development cycles, reduce costs, and build a more reliable and consistent deployment pipeline from the ground up. The ability to test locally and deploy to production in a controlled manner is a huge benefit for both engineering teams and finance departments. You can find it here: https://github.com/newyork167/LocalStack-Terragrunt-Scaffold.

This post is licensed under CC BY 4.0 by the author.