LocalStack: Giả lập AWS S3, Lambda, DynamoDB ngay trên máy Local — Không tốn một xu

Development tutorial - IT technology blog
Development tutorial - IT technology blog

2 giờ sáng, pipeline CI/CD báo đỏ. Lambda function deploy lên staging mà lỗi ResourceNotFoundException — cái DynamoDB table lại không tồn tại đúng lúc cần nhất. Mình ngồi debug, trace log, rồi nhận ra: toàn bộ workflow test của team đang gọi thẳng vào AWS dev account thật. Mỗi lần chạy test là mỗi lần cầu trời account không bị rate limit hay bill tăng vọt.

Đó là lúc mình bắt đầu nghiêm túc nhìn vào LocalStack.

Ba cách để phát triển ứng dụng AWS — và tại sao hai cái đầu đều có vấn đề

Trước khi vào LocalStack, thử điểm qua các lựa chọn để hiểu tại sao nó lại được dùng rộng như vậy.

Cách 1: Dùng AWS thật (dev account)

Team mới bắt đầu hay chọn cách này nhất. Tạo một AWS account riêng cho dev, developer kết nối trực tiếp lên đó. Nghe có vẻ ổn, nhưng thực tế thì không hẳn:

  • Chi phí: dù là môi trường dev, Lambda invocation + S3 storage + DynamoDB read/write vẫn tốn tiền. Team 5 người chạy 500 integration test mỗi ngày, cuối tháng bill cũng dễ lên đến $30–50 chỉ cho môi trường dev.
  • Phụ thuộc internet: không có mạng là không test được — mình từng bị kẹt ở sân bay 3 tiếng không làm được gì.
  • Conflict: hai developer cùng chạy test song song → state DynamoDB xung đột, test flaky không rõ nguyên nhân.
  • Cleanup: ai đó quên xóa S3 bucket sau khi test, data rác tích lũy theo thời gian.

Cách 2: Mock bằng thư viện (moto, unittest.mock)

Mock AWS calls trong Python với moto là lựa chọn khá phổ biến. Mình từng dùng moto cho một project và nó hoạt động tốt cho unit test thuần túy. Vấn đề chỉ lộ ra khi codebase lớn lên.

Moto hoạt động ở Python level, không phải ở network level. Boto3 gọi S3? Moto cover được. Nhưng nếu bạn có thêm một service Node.js hay Go cũng cần gọi cùng S3 bucket đó trong integration test — moto bó tay. Mock giỏi đến đâu cũng không thay được một endpoint HTTP thật mà mọi ngôn ngữ đều có thể gọi vào.

Cách 3: LocalStack — giả lập toàn bộ AWS stack ở network level

LocalStack chạy như một container Docker, expose endpoint HTTP giả lập gần như toàn bộ AWS services. Mọi SDK — Python boto3, Node.js AWS SDK, AWS CLI — chỉ cần trỏ endpoint về http://localhost:4566 là tương tác được như với AWS thật.

Offline hoàn toàn, không tốn tiền, không conflict giữa các developer, restart container là sạch slate. Đó là những gì LocalStack mang lại.

Tất nhiên không hoàn hảo: một số services phức tạp như Cognito, EKS, hay một số Lambda runtime cần LocalStack Pro (bản trả phí). Free tier cover S3, Lambda, DynamoDB, SQS, SNS, API Gateway — đủ cho 80% use case thực tế.

Setup LocalStack trong 5 phút

Yêu cầu

  • Docker đã cài và đang chạy
  • Python 3.8+ (nếu dùng awscli-local)
  • AWS CLI (để tương tác từ terminal)

Chạy LocalStack bằng Docker Compose

Tạo file docker-compose.yml trong project:

version: '3.8'
services:
  localstack:
    image: localstack/localstack:latest
    ports:
      - "4566:4566"
    environment:
      - SERVICES=s3,lambda,dynamodb,sqs
      - DEBUG=0
      - LAMBDA_EXECUTOR=docker
      - DOCKER_HOST=unix:///var/run/docker.sock
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"
      - "./localstack-data:/var/lib/localstack"
docker-compose up -d
# Kiểm tra LocalStack đã chạy chưa
curl http://localhost:4566/_localstack/health

Response JSON sẽ liệt kê các service đang running.

Cài awslocal — wrapper tiện lợi cho AWS CLI

pip install awscli-local
# awslocal = aws --endpoint-url=http://localhost:4566 --region us-east-1

Thực hành: S3, DynamoDB và Lambda với LocalStack

S3 — Upload và download file

# Tạo bucket
awslocal s3 mb s3://my-test-bucket

# Upload file
echo "Hello LocalStack" > test.txt
awslocal s3 cp test.txt s3://my-test-bucket/

# List objects
awslocal s3 ls s3://my-test-bucket/

# Download lại
awslocal s3 cp s3://my-test-bucket/test.txt downloaded.txt

Trong Python với boto3:

import boto3

# Trỏ endpoint về LocalStack
s3 = boto3.client(
    's3',
    endpoint_url='http://localhost:4566',
    region_name='us-east-1',
    aws_access_key_id='test',      # LocalStack chấp nhận bất kỳ giá trị nào
    aws_secret_access_key='test'
)

# Upload
s3.put_object(
    Bucket='my-test-bucket',
    Key='data/config.json',
    Body=b'{"env": "local"}'
)

# Download
response = s3.get_object(Bucket='my-test-bucket', Key='data/config.json')
print(response['Body'].read().decode('utf-8'))

DynamoDB — Tạo table và CRUD

# Tạo table
awslocal dynamodb create-table \
  --table-name Users \
  --attribute-definitions AttributeName=userId,AttributeType=S \
  --key-schema AttributeName=userId,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

# Thêm item
awslocal dynamodb put-item \
  --table-name Users \
  --item '{"userId": {"S": "u001"}, "name": {"S": "Nguyen Van A"}, "email": {"S": "[email protected]"}}'

# Query
awslocal dynamodb get-item \
  --table-name Users \
  --key '{"userId": {"S": "u001"}}'

Lambda — Deploy và invoke function

Tạo file handler.py:

import json

def lambda_handler(event, context):
    name = event.get('name', 'World')
    return {
        'statusCode': 200,
        'body': json.dumps({'message': f'Hello, {name}!'})
    }
# Đóng gói
zip function.zip handler.py

# Deploy lên LocalStack
awslocal lambda create-function \
  --function-name hello-function \
  --runtime python3.11 \
  --handler handler.lambda_handler \
  --role arn:aws:iam::000000000000:role/lambda-role \
  --zip-file fileb://function.zip

# Invoke
awslocal lambda invoke \
  --function-name hello-function \
  --payload '{"name": "LocalStack"}' \
  --cli-binary-format raw-in-base64-out \
  output.json

cat output.json
# {"statusCode": 200, "body": "{\"message\": \"Hello, LocalStack!\"}"}  

Tích hợp vào pytest — Chỗ mà nhiều người hay bỏ qua

Chạy LocalStack bằng tay mà không kéo vào test pipeline thì chỉ giải quyết được nửa vấn đề. Dưới đây là cách setup pytest fixture để tự động khởi tạo resources trước mỗi test — không cần setup thủ công gì thêm:

# conftest.py
import boto3
import pytest

ENDPOINT = 'http://localhost:4566'
REGION = 'us-east-1'
CREDS = {'aws_access_key_id': 'test', 'aws_secret_access_key': 'test'}

@pytest.fixture(scope='session')
def s3_client():
    return boto3.client('s3', endpoint_url=ENDPOINT, region_name=REGION, **CREDS)

@pytest.fixture(scope='session')
def dynamodb_client():
    return boto3.client('dynamodb', endpoint_url=ENDPOINT, region_name=REGION, **CREDS)

@pytest.fixture(autouse=True)
def setup_s3_bucket(s3_client):
    """Tạo bucket mới trước mỗi test, xóa sau khi xong"""
    bucket = 'test-bucket'
    s3_client.create_bucket(Bucket=bucket)
    yield bucket
    # Cleanup: xóa tất cả objects rồi xóa bucket
    objects = s3_client.list_objects_v2(Bucket=bucket).get('Contents', [])
    for obj in objects:
        s3_client.delete_object(Bucket=bucket, Key=obj['Key'])
    s3_client.delete_bucket(Bucket=bucket)

# test_s3.py
def test_upload_and_retrieve(s3_client, setup_s3_bucket):
    bucket = setup_s3_bucket
    s3_client.put_object(Bucket=bucket, Key='test.txt', Body=b'content')
    response = s3_client.get_object(Bucket=bucket, Key='test.txt')
    assert response['Body'].read() == b'content'

Chạy test với LocalStack đang chạy trong background:

pytest tests/ -v

Một số điều cần biết trước khi dùng thật

Data không persist mặc định. Restart container là mất sạch — S3 bucket, DynamoDB table, Lambda function đều về zero. Nếu cần giữ data giữa các lần chạy, mount volume như trong docker-compose ví dụ ở trên.

Lambda cold start chậm hơn bình thường. LocalStack dùng Docker-in-Docker để chạy Lambda, cold start đầu tiên có thể mất 10–30 giây. Các lần invoke sau nhanh hơn nhiều. Trade-off này hoàn toàn chấp nhận được so với deploy lên AWS thật chỉ để test.

Dùng biến môi trường để switch endpoint. Hardcode http://localhost:4566 trong code là cách nhanh nhất để tạo ra bug khó tìm khi deploy. Làm thế này thay:

import os

endpoint = os.environ.get('AWS_ENDPOINT_URL', None)  # None = dùng AWS thật
s3 = boto3.client('s3', endpoint_url=endpoint)

Chạy local thì set AWS_ENDPOINT_URL=http://localhost:4566 python app.py. Deploy lên staging hay prod thì không set biến này — boto3 tự kết nối AWS thật, không cần chỉnh code.

GitHub Actions cũng dùng được. Thêm localstack/setup-localstack action vào workflow là xong. Integration test chạy hoàn toàn offline trong runner, không phụ thuộc vào AWS account nào.

Kết

Sau cái đêm 2 giờ sáng đó, mình chuyển toàn bộ integration test của team sang LocalStack. Pipeline CI/CD nhanh hơn rõ rệt. Bill AWS dev account giảm gần 70%. Quan trọng hơn — developer test được offline hoàn toàn, dù đang trên tàu hay ở quán cà phê không có wifi ổn định.

LocalStack không giải quyết được mọi thứ. IAM policy phức tạp, service-specific behavior ở edge case — những thứ đó vẫn cần test trên AWS thật trước khi release. Nhưng cho công việc hàng ngày: viết feature, debug logic, chạy integration test — đây là lựa chọn thực dụng nhất mình biết.

Share: