Back to Discussions

AWS Lambda in VPC: Intermittent S3 Timeouts & Slow Cold Starts Despite NAT Gateway for Public Access

11 Views
Jul 7, 2026, 3:08:19 AM

We're developing a serverless backend for a mobile application, heavily leveraging AWS Lambda functions. One particular Lambda function (let's call it UserEventHandler) processes incoming user events. Its primary tasks are twofold: storing structured event data in an AWS RDS PostgreSQL instance and asynchronously uploading detailed raw event logs to an S3 bucket.

To ensure secure access to our RDS database, which resides in private subnets, we've configured our UserEventHandler Lambda to run within our VPC. This setup places the Lambda in private subnets, and we've deployed a NAT Gateway in public subnets with corresponding route table entries to allow the Lambda to make outbound calls to public internet services, specifically S3 and potentially external APIs.

Our environment details are as follows:

  • AWS Lambda Runtime: Python 3.9 (latest Boto3 bundled)
  • AWS Region: ap-south-1
  • Database: AWS RDS PostgreSQL 13.x (in private subnets within the same VPC)
  • VPC Configuration: We have a standard VPC setup with 3 public subnets (where the NAT Gateway resides) and 3 private subnets (where the Lambda and RDS instances are placed).
  • Security Groups: The Lambda's security group allows outbound traffic to the RDS security group on port 5432. The RDS security group allows inbound traffic from the Lambda's security group on port 5432. For S3, the Lambda's security group has an outbound rule allowing All Traffic (0.0.0.0/0), which we assumed would handle S3 access via the NAT Gateway.

The RDS connection works reliably and relatively quickly after the initial connection pooling. However, we're consistently observing two critical issues related to S3 operations during Lambda cold starts:

  1. Elevated Cold Start Times: The UserEventHandler Lambda takes significantly longer (8-12 seconds) to initialize and process the first request after a cold start, compared to warm invocations (under 200ms).
  2. Intermittent S3 Timeouts/Delays: Specifically, the boto3.client('s3').put_object() call experiences substantial delays (sometimes 5-10 seconds) or even botocore.exceptions.ConnectTimeoutError during cold starts. After the cold start, subsequent S3 operations on warm invocations are fast and reliable.

Here's a simplified version of the Python code within our Lambda function:

import json
import os
import boto3
import psycopg2
import logging

logger = logging.getLogger()
logger.setLevel(os.environ.get('LOG_LEVEL', 'INFO'))

# Environment variables
DB_HOST = os.environ.get('DB_HOST')
DB_NAME = os.environ.get('DB_NAME')
DB_USER = os.environ.get('DB_USER')
DB_PASSWORD = os.environ.get('DB_PASSWORD')
S3_BUCKET = os.environ.get('S3_BUCKET_NAME')

s3_client = boto3.client('s3')
db_conn = None

def get_db_connection():
    global db_conn
    if not db_conn or db_conn.closed:
        logger.info("Establishing new DB connection...")
        try:
            db_conn = psycopg2.connect(
                host=DB_HOST,
                database=DB_NAME,
                user=DB_USER,
                password=DB_PASSWORD,
                connect_timeout=5
            )
        except Exception as e:
            logger.error(f"Error connecting to database: {e}")
            raise
    return db_conn

def lambda_handler(event, context):
    request_id = context.aws_request_id
    logger.info(f"Processing event {request_id}")

    try:
        # 1. Store data in RDS
        conn = get_db_connection()
        with conn.cursor() as cur:
            # Example: insert a record
            user_id = event.get('userId')
            event_type = event.get('eventType')
            cur.execute("INSERT INTO events (user_id, event_type) VALUES (%s, %s);", (user_id, event_type))
            conn.commit()
            logger.info(f"Event for user {user_id} stored in RDS.")

        # 2. Upload raw event data to S3
        event_data_bytes = json.dumps(event).encode('utf-8')
        s3_key = f"raw-events/{request_id}.json"
        
        logger.info(f"Attempting to upload {len(event_data_bytes)} bytes to S3 bucket {S3_BUCKET} with key {s3_key}")
        
        # This is where the delay/timeout happens during cold starts
        s3_client.put_object(
            Bucket=S3_BUCKET,
            Key=s3_key,
            Body=event_data_bytes,
            ContentType='application/json'
        )
        logger.info(f"Raw event {request_id} uploaded to S3.")

        return {
            'statusCode': 200,
            'body': json.dumps({'message': 'Event processed successfully!'})
        }
    except botocore.exceptions.ConnectTimeoutError as e:
        logger.error(f"S3 Connect Timeout Error: {e}")
        return {
            'statusCode': 500,
            'body': json.dumps({'message': 'S3 operation timed out during cold start.'})
        }
    except Exception as e:
        logger.error(f"Unhandled error: {e}")
        # Invalidate DB connection on unhandled error to force re-establishment
        global db_conn
        if db_conn:
            db_conn.close()
            db_conn = None
        return {
            'statusCode': 500,
            'body': json.dumps({'message': 'Internal Server Error'})
        }

Expected Behavior: We expected S3 operations, like RDS connections, to be fast and reliable, even during cold starts, given the NAT Gateway provides outbound internet access. The s3_client.put_object() call should complete within milliseconds.

Actual Behavior: During cold starts, the Lambda takes 8-12 seconds to become ready. The s3_client.put_object() call specifically introduces a significant portion of this delay, sometimes leading to botocore.exceptions.ConnectTimeoutError or even the entire Lambda timing out if its timeout is set too aggressively (e.e. 30 seconds). Warm invocations exhibit no such issues.

Error Message Example:

[ERROR] 2023-10-27T10:30:10.567Z <request-id> S3 Connect Timeout Error: Connect timeout on endpoint URL: "https://my-log-bucket-123.s3.ap-south-1.amazonaws.com/raw-events/abc-123.json"
Traceback (most recent call last):
  File "/var/task/lambda_function.py", line 75, in lambda_handler
    ContentType='application/json'
  File "/var/runtime/botocore/client.py", line 403, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/var/runtime/botocore/client.py", line 695, in _make_api_call
    http, parsed_response = self._endpoint.make_request(request_dict, request_context)
  File "/var/runtime/botocore/endpoint.py", line 105, in make_request
    return self._send_request(request_dict, request_context)
  File "/var/runtime/botocore/endpoint.py", line 170, in _send_request
    raise ConnectTimeoutError(endpoint_url=request.url, error=e)
botocore.exceptions.ConnectTimeoutError: Connect timeout on endpoint URL: "https://my-log-bucket-123.s3.ap-south-1.amazonaws.com/raw-events/abc-123.json"

It seems like the NAT Gateway is not providing the expected performance for S3 access during cold starts, or there's some underlying network configuration causing this specific bottleneck. We're trying to understand the root cause and the most efficient way to resolve this without compromising security or significantly increasing costs.

Advertisement

Community Insights

1
A
ADITYA KUMAR
7/7/26, 3:08 AM

This is a classic problem encountered by developers running AWS Lambda functions in a Virtual Private Cloud (VPC), especially when they need to access both private resources (like RDS) and public AWS services (like S3) concurrently. The symptoms you're experiencing – high cold start latency and intermittent timeouts for S3 operations – are directly related to the network path your Lambda is taking to reach S3.

Why the Problem Occurs: The Root Cause

When your Lambda function is configured to run within a VPC (i.e., you've specified subnets and security groups), AWS creates an Elastic Network Interface (ENI) for your Lambda in the specified subnets. This ENI allows your Lambda to communicate with other resources inside your VPC, such as your RDS instance. The provisioning of this ENI during a cold start is the primary contributor to Lambda's cold start latency when in a VPC.

Now, for accessing public AWS services like S3 (which has a public endpoint), a Lambda in a private subnet must route its traffic to the internet. You've correctly identified that a NAT Gateway is the mechanism for private subnets to reach the internet. While a NAT Gateway does enable this connectivity, it adds another hop and potential bottleneck, especially during cold starts when the network path is first being established and translated. The ConnectTimeoutError further indicates a problem with establishing the initial connection.

The crucial piece of information often missed is that S3 is a global service with public endpoints. When you place a Lambda in a private subnet and attempt to access S3, even though S3 is an AWS service, its default access method is over the public internet. This means traffic still needs to go out through the NAT Gateway, then to the internet, and finally to the S3 public endpoint. This public internet routing via NAT Gateway, combined with the initial ENI setup for the VPC Lambda, creates the significant cold start overhead.

The Solution: AWS VPC Gateway Endpoint for S3

The most efficient and secure way to resolve this is to leverage an AWS VPC Gateway Endpoint for S3. A Gateway Endpoint for S3 allows your Lambda function to access S3 directly from within your VPC without requiring a NAT Gateway, an Internet Gateway, or even public IP addresses for your Lambda. The traffic remains entirely within the AWS network, improving both performance and security.

When you configure a Gateway Endpoint for S3, AWS automatically adds route table entries to your specified subnets, directing S3-bound traffic to the endpoint instead of the NAT Gateway. This bypasses the NAT Gateway entirely for S3 traffic, significantly reducing network latency and improving cold start times for S3 operations.

Corrected Configuration (Infrastructure-as-Code Example)

Since the fix is an infrastructure change and not an application code change (your boto3 S3 client will automatically use the endpoint if configured correctly), here's how you would define a VPC Gateway Endpoint for S3 using AWS CloudFormation (a similar approach applies to Terraform or AWS CLI):

Resources:
  # ... other resources like your Lambda function, subnets, etc. ...

  S3VPCEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref YourVPC # Reference to your VPC ID
      ServiceName: !Sub 'com.amazonaws.${AWS::Region}.s3' # S3 Gateway Endpoint Service Name
      RouteTableIds: # List of Route Table IDs associated with your private subnets
        - !Ref PrivateSubnet1RouteTable
        - !Ref PrivateSubnet2RouteTable
        - !Ref PrivateSubnet3RouteTable
      VpcEndpointType: Gateway # This is crucial for S3/DynamoDB
      PolicyDocument: # Optional: Restrict S3 access via this endpoint (e.g., only to specific buckets)
        Version: '2012-10-17'
        Statement:
          - Sid: AllowAllS3Access
            Effect: Allow
            Principal: '*'
            Action: 's3:*'
            Resource: '*'
          # Example to restrict access to only your S3 bucket
          # - Sid: AllowS3BucketAccess
          #   Effect: Allow
          #   Principal: '*'
          #   Action:
          #     - s3:GetObject
          #     - s3:PutObject
          #   Resource:
          #     - !Sub 'arn:aws:s3:::${S3BucketName}'
          #     - !Sub 'arn:aws:s3:::${S3BucketName}/*'

Key Changes Explained:

  1. Type: AWS::EC2::VPCEndpoint: Declares a new VPC Endpoint.
  2. VpcId: The ID of your existing VPC where the Lambda resides.
  3. ServiceName: !Sub 'com.amazonaws.${AWS::Region}.s3': This is the specific service name for the S3 Gateway Endpoint in your region. AWS automatically provides this.
  4. RouteTableIds: This is critical. You must associate the Gateway Endpoint with the route tables of all the private subnets where your Lambda functions are deployed. This tells your private subnets to route S3 traffic through this endpoint.
  5. VpcEndpointType: Gateway: Specifies it's a Gateway Endpoint, which is suitable for S3 and DynamoDB.
  6. PolicyDocument: An optional (but recommended) IAM policy to control which S3 actions and resources can be accessed through this endpoint. By default, if omitted, it allows full access. It's a good practice to restrict it to only the buckets your Lambda needs.

Once this Gateway Endpoint is deployed and the route tables are updated, your boto3.client('s3') calls from within the VPC-enabled Lambda will automatically discover and use this endpoint, routing S3 traffic internally within AWS without touching the NAT Gateway or public internet.

Corrected Application Code (Conceptual)

Your application code remains largely the same. boto3 is smart enough to detect and use VPC Gateway Endpoints automatically if they are correctly configured in your VPC. No changes are typically needed in your s3_client.put_object() call:

# ... (imports and initial setup are identical) ...

def lambda_handler(event, context):
    # ... (RDS access code is identical) ...

    # This S3 call will now implicitly route through the VPC Gateway Endpoint
    # if the infrastructure is configured correctly.
    s3_client.put_object(
        Bucket=S3_BUCKET,
        Key=s3_key,
        Body=event_data_bytes,
        ContentType='application/json'
    )
    logger.info(f"Raw event {request_id} uploaded to S3 via VPC Endpoint.")

    # ... (error handling and return are identical) ...

Edge Cases and Related Pitfalls

  1. Interface Endpoints vs. Gateway Endpoints: While S3 and DynamoDB use Gateway Endpoints (which are free and modify route tables), most other AWS services (e.g., SQS, SNS, EC2 APIs) use Interface Endpoints. Interface Endpoints are powered by AWS PrivateLink, cost money per hour and per GB of data, and require security groups and ENIs within your subnets. Ensure you select the correct type for the service you're trying to reach.
  2. Endpoint Policies: Always define an explicit endpoint policy to restrict access. A permissive policy (like AllowAllS3Access above) can create security vulnerabilities if not properly managed. Restrict access to only the necessary S3 buckets or prefixes.
  3. DNS Resolution: Ensure your VPC's DNS resolution settings (DHCP Options Set) are configured to use the AmazonProvidedDNS. This ensures that S3 service names resolve to the private IPs of the VPC endpoint. If you're using custom DNS servers, you might need to configure conditional forwarders.
  4. Impact on NAT Gateway: After implementing the S3 Gateway Endpoint, S3 traffic will no longer go through your NAT Gateway. This will reduce NAT Gateway data processing costs and potentially allow you to scale down or optimize your NAT Gateway setup if S3 traffic was a significant portion of its load. However, other public internet traffic will still go through the NAT Gateway.

💡 Bonus Tip: Leverage CloudWatch Metrics for Cold Start Analysis

To precisely understand and monitor your Lambda's cold start performance, keep an eye on the Duration metric in CloudWatch, especially when filtered by Init Duration. This metric specifically measures the time taken for the runtime environment to initialize (including ENI provisioning). A significant Init Duration for VPC-enabled Lambdas points to network setup overhead, which VPC Endpoints can help mitigate. Additionally, analyze Invocation duration for warm starts. Combining these gives you a full picture of your function's performance characteristics.

Unlock Global Discussions

The TechLab Forum is reserved for our premium engineering community. Upgrade now to participate in high-level architectural debates.

Get Pro Access