Local Cloud Emulation in 2026: Stop Paying AWS to Run Your Unit Tests
Tuần này trên Hacker News có một thread khá hot về việc giả lập (emulate) cloud service ngay trên máy local. Đọc comment thì thấy ai cũng từng gặp cảnh này: đang code một feature nhỏ upload file lên S3, mà muốn test thì phải có credentials, phải có bucket riêng cho dev, phải chờ network, và cuối tháng nhìn bill thấy vài chục đô chỉ vì CI chạy test integration liên tục. Tệ hơn nữa là có bạn junior lỡ chạy script cleanup trỏ nhầm vào bucket staging.
Mình đã dùng local cloud emulation cho các dự án AWS khoảng 4 năm nay. Bài này chia sẻ cách mình setup thực tế: dùng tool gì cho tầng nào, code thế nào để switch giữa local và cloud mà không phải sửa logic, và mấy cái bẫy mà docs không nói rõ.
Tại sao không test thẳng trên cloud?
Test trên cloud thật cũng được thôi, nhưng có mấy vấn đề:
- Chậm: mỗi lần gọi API qua internet mất 50-300ms. Một test suite 500 case gọi S3/SQS/DynamoDB có thể mất vài phút thay vì vài giây.
-
- Tốn tiền và khó cô lập: mỗi dev, mỗi CI job cần resource riêng, nếu không sẽ đá nhau. Dọn dẹp resource sau test cũng là một bài toán.
-
- Rủi ro: credentials trên máy dev và CI là attack surface. Ít credentials hơn thì an toàn hơn.
-
- Không chạy offline: ngồi quán cà phê wifi chập chờn là coi như nghỉ code.
Nhưng cũng phải nói thẳng: emulator không phải cloud thật. IAM policy, eventual consistency, giới hạn throughput, behavior của các edge case… emulator thường làm đơn giản hoá hoặc bỏ qua. Nên chiến lược đúng là phân tầng, không phải thay thế hoàn toàn.
flowchart TD
A[Unit test] -->|in-process mock| B[moto / aws-sdk-client-mock]
C[Integration test local] -->|Docker container| D[LocalStack / MinIO / DynamoDB Local]
E[Staging / smoke test] -->|real cloud| F[AWS account riêng cho test]
B --> G{Nhanh, rẻ, ít chính xác}
D --> H{Cân bằng}
F --> I{Chậm, tốn tiền, chính xác nhất}
```
## Tầng 1: Mock in-process với moto
Với Python, `moto` là lựa chọn gần như mặc định. Từ moto 5.x, API đã gộp tất cả decorator cũ (`mock_s3`, `mock_sqs`...) thành một `mock_aws` duy nhất, nhớ update nếu đang dùng code cũ từ bản 4.x.
```python
# pip install "moto[s3,sqs]>=5.0" boto3 pytest
import boto3
import pytest
from moto import mock_aws
from myapp.storage import upload_invoice # hàm cần test
@pytest.fixture
def aws_env(monkeypatch):
# Chặn không cho lỡ tay gọi AWS thật
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
monkeypatch.setenv("AWS_DEFAULT_REGION", "ap-southeast-1")
with mock_aws():
s3 = boto3.client("s3")
s3.create_bucket(
Bucket="invoices",
CreateBucketConfiguration={"LocationConstraint": "ap-southeast-1"},
)
yield s3
def test_upload_invoice_sets_metadata(aws_env):
key = upload_invoice("invoices", customer_id=42, pdf_bytes=b"%PDF-1.7")
obj = aws_env.head_object(Bucket="invoices", Key=key)
assert obj["Metadata"]["customer-id"] == "42"
assert key.startswith("2026/")
```
Điểm quan trọng nhất ở đây là fixture set credentials giả **trước khi** tạo client. Mình từng thấy team để credentials thật trong `~/.aws/credentials`, quên wrap `mock_aws`, và test chạy thẳng lên production. Set env giả là lớp bảo vệ rẻ nhất.
Bên Node.js, tương đương là `aws-sdk-client-mock` cho AWS SDK v3. Nó mock ở tầng client command, không giả lập state, nên phù hợp cho unit test thuần hơn là test flow nhiều bước.
## Tầng 2: Emulator chạy bằng Docker
Khi cần test flow thật hơn (Lambda đọc từ SQS rồi ghi vào DynamoDB chẳng hạn), mình dùng container. Các lựa chọn phổ biến:
- **LocalStack**: giả lập hàng chục service AWS qua một endpoint duy nhất `localhost:4566`. Lưu ý: từ 2026 LocalStack đã thay đổi chính sách image và yêu cầu auth token cho nhiều use case, nên đọc kỹ license trước khi đưa vào CI của công ty.
- **DynamoDB Local**: image chính chủ từ AWS (`amazon/dynamodb-local`), rất ổn định.
- **MinIO**: S3-compatible, nhưng bản community đã chuyển sang chế độ maintenance từ cuối 2025. Dự án mới có thể cân nhắc các lựa chọn khác như Garage hoặc SeaweedFS.
- **Azurite** cho Azure Storage, **Firebase Emulator Suite** và **gcloud emulators** cho GCP.
Đây là `docker-compose.test.yml` mình hay dùng:
```yaml
services:
localstack:
image: localstack/localstack:4
ports:
- "4566:4566"
environment:
- SERVICES=s3,sqs,dynamodb
- LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN:-}
volumes:
- ./scripts/init-aws.sh:/etc/localstack/init/ready.d/init-aws.sh
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
interval: 3s
retries: 20
```
File `init-aws.sh` chạy tự động khi container ready, dùng để seed resource:
```bash
#!/bin/bash
set -euo pipefail
awslocal s3 mb s3://invoices
awslocal sqs create-queue --queue-name invoice-events
awslocal dynamodb create-table --table-name invoices --attribute-definitions AttributeName=id,AttributeType=S --key-schema AttributeName=id,KeyType=HASH --billing-mode PAY_PER_REQUEST
echo "AWS resources seeded"
```
`awslocal` là wrapper của AWS CLI (cài bằng `pip install awscli-local`), tự thêm `--endpoint-url` cho bạn. Khi setup xong, chạy `docker compose -f docker-compose.test.yml up -d --wait` rồi mới chạy test, flag `--wait` sẽ đợi healthcheck pass.
## Viết code để switch môi trường không đau đớn
Sai lầm phổ biến nhất mình gặp khi review code: rải `if (process.env.NODE_ENV === 'test')` khắp nơi. Cách đúng là **gom tất cả vào một chỗ tạo client**, và chỉ cấu hình endpoint qua biến môi trường.
```mermaid
flowchart LR
A[Business logic] --> B[AWS client factory]
B -->|AWS_ENDPOINT_URL có giá trị| C[LocalStack :4566]
B -->|không có| D[AWS thật]
```
Tin vui là từ khoảng 2024, cả AWS SDK v3 cho JS và boto3 đều hỗ trợ biến môi trường chuẩn `AWS_ENDPOINT_URL` (và `AWS_ENDPOINT_URL_S3` riêng cho từng service). Tức là nhiều khi bạn không cần sửa code chút nào. Nhưng với S3 thì vẫn còn một cái bẫy: path-style addressing.
```javascript
// src/aws/clients.js - AWS SDK v3 (@aws-sdk/client-s3 ^3.600)
import { S3Client } from "@aws-sdk/client-s3";
import { SQSClient } from "@aws-sdk/client-sqs";
const isLocal = Boolean(process.env.AWS_ENDPOINT_URL);
const region = process.env.AWS_REGION ?? "ap-southeast-1";
export const s3 = new S3Client({
region,
// Emulator không resolve được invoices.localhost:4566,
// nên phải ép dùng localhost:4566/invoices
forcePathStyle: isLocal,
});
export const sqs = new SQSClient({ region });
```
Nếu quên `forcePathStyle`, bạn sẽ gặp lỗi DNS kiểu `getaddrinfo ENOTFOUND invoices.localhost` và mất nửa tiếng debug. Mình đã mất nửa tiếng đó rồi, bạn không cần mất nữa.
Một lưu ý nữa là queue URL và ARN. Code production hay hardcode `arn:aws:sqs:ap-southeast-1:123456789012:...`. Hãy đưa hết vào config hoặc resolve động bằng `GetQueueUrl`, vì account ID trên emulator thường là `000000000000`.
## Những thứ emulator sẽ lừa bạn
Sau vài năm, đây là danh sách những lần test pass ở local nhưng fail trên cloud:
1. **IAM**: đa số emulator mặc định không enforce policy. Code chạy ngon ở local, lên staging thì `AccessDenied`. Giải pháp: luôn có một bước smoke test trên account AWS thật trước khi release.
2. **Giới hạn và quota**: Lambda payload 6MB, SQS message 256KB, DynamoDB item 400KB. Emulator có thể không check đủ hết.
3. **Timing**: ở local, SQS và event notification gần như tức thì. Trên cloud có độ trễ, nên test dạng "gửi xong đọc ngay" rất dễ flaky. Dùng polling với timeout thay vì `sleep` cố định.
4. **Version drift**: AWS ra API mới, emulator cần thời gian để theo kịp. Pin version image (`localstack/localstack:4.x.y`), đừng dùng `latest` trong CI.
## Kết luận
Local cloud emulation không phải để thay thế hoàn toàn cloud thật, mà để đẩy phần lớn feedback loop về máy local, nơi nó nhanh, rẻ và an toàn. Mấy việc bạn có thể làm ngay tuần này:
- **Chia test thành 3 tầng**: mock in-process (moto, aws-sdk-client-mock) cho unit test, emulator Docker cho integration, và một ít smoke test trên account AWS riêng.
- **Luôn set credentials giả** trong test fixture để không bao giờ lỡ gọi production.
- **Gom việc tạo client vào một factory**, dùng `AWS_ENDPOINT_URL` và nhớ `forcePathStyle` cho S3.
- **Pin version image** của emulator và đọc kỹ license, đặc biệt là LocalStack và MinIO vì chính sách của cả hai đã thay đổi gần đây.
- **Đừng tin 100% vào emulator**: IAM, quota và timing phải được kiểm chứng trên cloud thật.
Setup mất khoảng một buổi chiều, nhưng CI của team sẽ nhanh hơn thấy rõ, bill AWS cuối tháng dễ nhìn hơn, và quan trọng nhất là không ai phải chạy script cleanup mà tim đập thình thịch nữa.
All rights reserved