DEV Community

Cover image for Configuring a Lambda Function in a VPC to access external API via NAT Gateway and Store in DynamoDB using AWS PrivateLink
Chinmay Tonape
Chinmay Tonape

Posted on

Configuring a Lambda Function in a VPC to access external API via NAT Gateway and Store in DynamoDB using AWS PrivateLink

Introduction

AWS Lambda functions running inside a Virtual Private Cloud (VPC) provide enhanced security, network isolation, and direct access to other AWS services within the VPC. By placing Lambda in a VPC, we ensure it can securely communicate with private resources like databases while also accessing external APIs via a NAT Gateway. This setup is crucial for workloads that require controlled outbound internet access and private service interactions.

In this blog, we will configure a Lambda function inside a VPC that fetches Chuck Norris jokes from chucknorris.io via a NAT Gateway and stores them in a DynamoDB table using AWS PrivateLink.

Architecture Overview

Architecture

Step 1: Create a VPC with Public and Private Subnets

Create a new VPC with two public subnets and two private subnets. The public subnets will host the NAT Gateway, and the private subnets will be used for Lambda.
A NAT Gateway is required to allow private subnets to access the internet while keeping them secure.

################################################################################
# Create VPC and components
################################################################################

module "vpc" {
  source               = "./modules/vpc"
  name                 = "My-VPC"
  aws_region           = var.aws_region
  vpc_cidr_block       = var.vpc_cidr_block
  enable_dns_hostnames = var.enable_dns_hostnames
  aws_azs              = var.aws_azs
  common_tags          = local.common_tags
  naming_prefix        = local.naming_prefix
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create a DynamoDB Table

We need a DynamoDB table to store the Chuck Norris jokes.

################################################################################
# Creating DynamoDB table
################################################################################
resource "aws_dynamodb_table" "jokes-dynamodb-table" {
  name           = "Jokes_DynamoDB_Table"
  billing_mode   = "PROVISIONED"
  read_capacity  = 5
  write_capacity = 5
  hash_key       = "id"
  range_key      = "timestamp"

  attribute {
    name = "id"
    type = "S"
  }
  attribute {
    name = "timestamp"
    type = "S"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Create a VPC Interface Endpoint for DynamoDB

Instead of routing traffic over the internet, we will use AWS PrivateLink to securely access DynamoDB from private subnets.
We will create and attach a security group to VPC endpoint which will contain or lambda function.

################################################################################
# Create the security group for Lambda Function
################################################################################
resource "aws_security_group" "lambda_security_group" {
  description = "Allow traffic for Lambda Function"
  vpc_id      = module.vpc.vpc_id

  dynamic "ingress" {
    for_each = var.sg_ingress_ports
    iterator = sg_ingress

    content {
      description = sg_ingress.value["description"]
      from_port   = sg_ingress.value["port"]
      to_port     = sg_ingress.value["port"]
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

}

################################################################################
# Creating VPC endpoint attached to private subnets containing Lambda Function
################################################################################
resource "aws_vpc_endpoint" "sqs_vpc_ep_interface" {
  vpc_id              = module.vpc.vpc_id
  vpc_endpoint_type   = "Interface"
  service_name        = "com.amazonaws.${var.aws_region}.dynamodb"
  subnet_ids          = [module.vpc.private_subnets[0], module.vpc.private_subnets[1]]
  private_dns_enabled = false
  security_group_ids  = [aws_security_group.lambda_security_group.id]
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Create IAM Role for Lambda

The Lambda function requires an IAM role with permissions to access DynamoDB and VPC resources.
It will need acess to create ENIs for accessing the services via PrivateLink

################################################################################
# Lambda IAM role to assume the role
################################################################################
resource "aws_iam_role" "lambda_role" {
  name = "lambda_execution_role"
  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [{
      "Effect" : "Allow",
      "Principal" : {
        "Service" : "lambda.amazonaws.com"
      },
      "Action" : "sts:AssumeRole"
    }]
  })
}


################################################################################
# Create policy to acess the DynamoDB
################################################################################
resource "aws_iam_policy" "DynamoDBAccessPolicy" {
  name        = "DynamoDBAccessPolicy"
  description = "DynamoDBAccessPolicy"
  policy = jsonencode(
    {
      "Version" : "2012-10-17",
      "Statement" : [
        {
          "Action" : [
            "dynamodb:List*",
            "dynamodb:DescribeReservedCapacity*",
            "dynamodb:DescribeLimits",
            "dynamodb:DescribeTimeToLive"
          ],
          "Resource" : "*",
          "Effect" : "Allow"
        },
        {
          "Action" : [
            "dynamodb:BatchGet*",
            "dynamodb:DescribeStream",
            "dynamodb:DescribeTable",
            "dynamodb:Get*",
            "dynamodb:Query",
            "dynamodb:Scan",
            "dynamodb:BatchWrite*",
            "dynamodb:CreateTable",
            "dynamodb:Delete*",
            "dynamodb:Update*",
            "dynamodb:PutItem"
          ],
          "Resource" : [
            "arn:aws:dynamodb:*:*:table/Jokes_DynamoDB_Table"
          ],
          "Effect" : "Allow"
        }
      ]
    }
  )
}

################################################################################
# Assign policy to the role
################################################################################
resource "aws_iam_policy_attachment" "lambda_basic_execution" {
  name       = "lambda_basic_execution"
  roles      = [aws_iam_role.lambda_role.name]
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}


resource "aws_iam_policy_attachment" "lambda_eni_access" {
  name       = "lambda_eni_access"
  roles      = [aws_iam_role.lambda_role.name]
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaENIManagementAccess"
}

resource "aws_iam_policy_attachment" "lambda_dynamodb_access" {
  name       = "lambda_dynamodb_access"
  roles      = [aws_iam_role.lambda_role.name]
  policy_arn = aws_iam_policy.DynamoDBAccessPolicy.arn
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Create a Lambda Function

The Lambda function will:

  1. Fetch a joke from chucknorris.io
  2. Store the joke in the DynamoDB table

(Note: when lambda function gets called from function URL, it causes duplicate hits to lambda as browser requests favicons, the code will gnore the favicon requests)

import json
import requests
import os
import boto3

from datetime import datetime

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.getenv('DYNAMODB_TABLE'))

def lambda_handler(event, context):
    # Ignore the favicon requests when called from browse using function URL
    path = event.get("rawPath", "")
    if path == "/favicon.ico":
        return { 'statusCode': 404, 'body': 'Not Found' }

    # get the joke from chucknorris.io api
    response = requests.get(os.environ['API_URL'])
    jokeid = response.json().get("id", "Null")
    joke = response.json().get("value", "No joke found.")
    now = datetime.now()

    # put the joke details into dynamodb
    table.put_item(
        Item={
            'id': jokeid,
            'value': joke,
            'timestamp': now.strftime("%d/%m/%Y %H:%M:%S")
        }
    )

    # return html respose
    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps(joke)
    }
Enter fullscreen mode Exit fullscreen mode

Create a lambda layer for requsts library and create lambda function with function URL

################################################################################
# Compressing lambda_handler function code
################################################################################
data "archive_file" "lambda_function_archive" {
  type        = "zip"
  source_dir  = "${path.module}/lambda"
  output_path = "${path.module}/lambda_function.zip"
}

################################################################################
# Creating lambda layer for requests python library
################################################################################
resource "aws_lambda_layer_version" "requests_layer" {
  filename            = "${path.module}/requests_layer.zip"
  layer_name          = "requests_layer"
  compatible_runtimes = ["python3.12"]
  source_code_hash    = filebase64sha256("${path.module}/requests_layer.zip")
}

################################################################################
# Creating Lambda Function
################################################################################
resource "aws_lambda_function" "get_joke_lambda_function" {
  function_name = "ChuckNorrisJokes_Lambda"
  filename      = "${path.module}/lambda_function.zip"

  runtime     = "python3.12"
  handler     = "chucknorris_jokes.lambda_handler"
  layers      = [aws_lambda_layer_version.requests_layer.arn]
  memory_size = 128
  timeout     = 5

  vpc_config {
    subnet_ids         = module.vpc.private_subnets
    security_group_ids = [aws_security_group.lambda_security_group.id]
  }

  environment {
    variables = {
      API_URL        = "https://api.chucknorris.io/jokes/random",
      DYNAMODB_TABLE = "Jokes_DynamoDB_Table"
    }
  }

  source_code_hash = data.archive_file.lambda_function_archive.output_base64sha256

  role = aws_iam_role.lambda_role.arn
}

################################################################################
# Creating Lambda Function URL for accessing it via browser
################################################################################
resource "aws_lambda_function_url" "chucknorris_function_url" {
  function_name      = aws_lambda_function.get_joke_lambda_function.function_name
  authorization_type = "NONE" # Change to "AWS_IAM" for restricted access
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Create cloudwatch log group for logging

################################################################################
# Creating CloudWatch Log group for Lambda Function
################################################################################
resource "aws_cloudwatch_log_group" "get_joke_lambda_function_cloudwatch" {
  name              = "/aws/lambda/${aws_lambda_function.get_joke_lambda_function.function_name}"
  retention_in_days = 30
}
Enter fullscreen mode Exit fullscreen mode

Steps to Run Terraform

Follow these steps to execute the Terraform configuration:

terraform init
terraform plan 
terraform apply -auto-approve
Enter fullscreen mode Exit fullscreen mode

Upon successful completion, Terraform will provide relevant outputs.

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

Outputs:

lambda_function_url = "https://co564k26i32eowzqlum6xm5muy0logkk.lambda-url.us-east-1.on.aws/"
Enter fullscreen mode Exit fullscreen mode

Testing

Lambda Function in VPC:

Lambda in VPC

DynamoDB Table:

DynamoDB Table

Test Event:

Test Event

DynamoDB Scan:

DB Scan

Lambda Invocation using Function URL

Lambda Invocation using URL

DynamoDB scan:

DB scan

CloudWatch Logs:

Cloudwatch Logs

Cleanup

Remember to stop AWS components to avoid large bills.

terraform destroy -auto-approve
Enter fullscreen mode Exit fullscreen mode

Conclusion

This architecture provides a secure way to access DynamoDB using PrivateLink while allowing the Lambda function to communicate with external APIs through a NAT Gateway.

References

GitHub Repo: https://github.com/chinmayto/terraform-aws-lambda-in-vpc

Top comments (0)