Kiểm thử Cloud cục bộ với LocalStack và Docker: Mock AWS không tốn chi phí trong CI/CD

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

Bối cảnh & Lý do

Sáu tháng trước, hóa đơn AWS môi trường staging của chúng tôi chạm mốc 1.400 USD mỗi tháng chỉ riêng cho hạ tầng kiểm thử. Mỗi khi một kỹ sư tạo pull request có can thiệp đến S3, SQS hoặc DynamoDB, pipeline GitHub Actions lại tự động khởi tạo các tài nguyên sandbox tạm thời thông qua Terraform. Cách tiếp cận này đem lại độ chính xác rất cao khi kiểm thử, nhưng đổi lại là thời gian chờ đợi lên tới 15 phút cùng vô vàn ức chế.

Các lượt chạy pipeline diễn ra chậm chạp như rùa bò. Việc chờ đợi các remote CloudFormation stack và bắt tay API với AWS ngốn mất 8 đến 10 phút cho mỗi commit. Tệ hơn nữa, tình trạng timeout mạng chập chờn từ AWS gây ra lỗi false-positive (báo lỗi sai) trong khoảng 1 trên mỗi 6 lần chạy. Các kỹ sư bắt đầu tìm cách bỏ qua bộ test suite hoặc gom nhiều PR lại với nhau chỉ để né hàng đợi kéo dài.

Chúng tôi từng cân nhắc tự xây dựng các mock in-house, nhưng đã sớm gạt bỏ ý tưởng đó. Việc tự tay viết trình giả lập cho các signed URL phức tạp của AWS, receipt handle của tin nhắn, hay các điều kiện phân trang trong DynamoDB nhanh chóng biến thành một bãi lầy vận hành khổng lồ.

Thay vào đó, chúng tôi chuẩn hóa quy trình với LocalStack chạy bên trong Docker. LocalStack vận hành một trình giả lập tương thích hoàn toàn với AWS ngay trên máy của bạn, cung cấp các dịch vụ như S3, SQS, SNS và DynamoDB thông qua một edge port duy nhất là 4566. Mã nguồn ứng dụng, các AWS SDK và công cụ CLI vẫn giao tiếp qua các giao thức AWS tiêu chuẩn — nhưng không hề có bất kỳ gói tin mạng nào rời khỏi máy trạm của bạn.

Cài đặt

Để chạy LocalStack, bạn chỉ cần Docker và Docker Compose. Dù bạn có thể cài đặt LocalStack qua trình quản lý gói của Python (pip install localstack) hoặc các bản binary độc lập, việc đóng gói container sẽ đảm bảo hành vi nhất quán trên Linux, macOS và các build runner.

1. Thiết lập Docker Compose

Thêm tệp docker-compose.yml vào thư mục kiểm thử hoặc thư mục gốc của repository. Với các phiên bản LocalStack hiện đại (v2+ và v3+), các dịch vụ sẽ được nạp theo cơ chế lazy loading ở lần gọi đầu tiên, do đó bạn không còn cần các khai báo biến môi trường rườm rà như trước.

services:
  localstack:
    container_name: localstack-main
    image: localstack/localstack:3.2.0
    ports:
      - "127.0.0.1:4566:4566"
    environment:
      - DEBUG=0
      - DOCKER_HOST=unix:///var/run/docker.sock
    volumes:
      - "./localstack-data:/var/lib/localstack"
      - "/var/run/docker.sock:/var/run/docker.sock"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"]
      interval: 5s
      timeout: 3s
      retries: 5

2. Khởi động Container

Khởi chạy daemon giả lập:

docker compose up -d

Tiếp theo, hãy cài đặt awscli-local. Lớp wrapper gọn nhẹ này sẽ chặn các lệnh CLI aws tiêu chuẩn và điều hướng chúng về http://localhost:4566 kèm thông tin xác thực mock đã được điền sẵn:

pip install awscli-local

Xác minh mock gateway hoạt động bình thường bằng cách kiểm tra hệ thống con S3:

awslocal s3 ls

Cấu hình

Để việc mock cloud cục bộ phát huy hiệu quả thực tế cần 3 yếu tố: script khởi tạo tự động, cấu hình endpoint SDK động, và các bước workflow CI tinh gọn.

1. Khởi tạo tài nguyên khi khởi động

LocalStack sẽ thực thi các script khởi tạo được mount vào /etc/localstack/init/ready.d/ ngay khi các dịch vụ nội bộ vượt qua bước kiểm tra health check. Hãy tận dụng hook này để tự động tạo các bảng và hàng đợi.

Tạo tệp init-resources.sh:

#!/usr/bin/env bash
set -euo pipefail

echo "Đang khởi tạo tài nguyên cloud giả lập..."

# Tạo bucket chứa tài liệu thô
awslocal s3 mb s3://incoming-documents

# Tạo hàng đợi tiếp nhận với visibility timeout 30 giây
awslocal sqs create-queue \
    --queue-name document-processing-queue \
    --attributes VisibilityTimeout=30

# Khởi tạo bảng metadata với dung lượng on-demand
awslocal dynamodb create-table \
    --table-name DocumentMetadata \
    --attribute-definitions AttributeName=DocumentId,AttributeType=S \
    --key-schema AttributeName=DocumentId,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST

echo "Tài nguyên cục bộ đã sẵn sàng."

Mount script ở chế độ chỉ đọc (read-only) bên trong docker-compose.yml của bạn:

    volumes:
      - "./init-resources.sh:/etc/localstack/init/ready.d/init-resources.sh:ro"
      - "/var/run/docker.sock:/var/run/docker.sock"

2. Trỏ mã nguồn ứng dụng về Endpoint cục bộ

Mã nguồn chạy trên production không bao giờ cần biết nó đang giao tiếp với AWS thật hay LocalStack. Hãy sử dụng biến môi trường AWS_ENDPOINT_URL để chuyển đổi đích đến một cách liền mạch trong các factory function. Dưới đây là triển khai bằng Python đã qua thực chiến sử dụng boto3:

import os
import boto3

# Mặc định là None trên production để boto3 trỏ đến các endpoint AWS chính thức
AWS_ENDPOINT_URL = os.getenv("AWS_ENDPOINT_URL") 
AWS_REGION = os.getenv("AWS_DEFAULT_REGION", "us-east-1")

def get_s3_client():
    return boto3.client(
        "s3",
        endpoint_url=AWS_ENDPOINT_URL,
        region_name=AWS_REGION,
        aws_access_key_id=os.getenv("AWS_ACCESS_KEY_ID", "test"),
        aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY", "test"),
    )

def upload_invoice(bucket_name: str, file_name: str, content: bytes) -> str:
    client = get_s3_client()
    client.put_object(
        Bucket=bucket_name,
        Key=file_name,
        Body=content,
        ContentType="application/pdf"
    )
    return f"s3://{bucket_name}/{file_name}"

3. Tự động hóa trong GitHub Actions

Chạy LocalStack dưới dạng một service container của job giúp tránh được việc phải thiết lập quyền cloud, IAM role và luân chuyển secret trong các pull request. LocalStack sẽ khởi động trực tiếp ngay trên máy chủ runner:

name: Kiểm thử tích hợp

on:
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      localstack:
        image: localstack/localstack:3.2.0
        ports:
          - 4566:4566
        options: >-
          --health-cmd "curl -f http://localhost:4566/_localstack/health || exit 1"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 10

    steps:
      - uses: actions/checkout@v4

      - name: Thiết lập Python 3.11
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'

      - name: Cài đặt các dependency kiểm thử
        run: |
          python -m pip install --upgrade pip
          pip install pytest boto3 awscli-local

      - name: Khởi tạo hạ tầng mock
        run: |
          chmod +x ./init-resources.sh
          ./init-resources.sh

      - name: Chạy bộ test suite
        env:
          AWS_ENDPOINT_URL: http://localhost:4566
          AWS_DEFAULT_REGION: us-east-1
          AWS_ACCESS_KEY_ID: test
          AWS_SECRET_ACCESS_KEY: test
        run: |
          pytest tests/integration/ -v --maxfail=1

Xác minh & Giám sát

Phản hồi nhanh chỉ thực sự hữu ích khi các assertion đáng tin cậy và các ca thất bại dễ dàng điều tra nguyên nhân.

1. Chẩn đoán trạng thái Endpoint

LocalStack cung cấp thông tin chẩn đoán trực tiếp qua /_localstack/health. Gửi truy vấn tới endpoint này sẽ trả về trạng thái của từng hệ thống con đang hoạt động:

curl -s http://localhost:4566/_localstack/health | jq .

Một instance khỏe mạnh sẽ báo cáo các dịch vụ đã sẵn sàng theo yêu cầu:

{
  "services": {
    "dynamodb": "running",
    "s3": "running",
    "sqs": "running"
  },
  "edition": "community",
  "version": "3.2.0"
}

Nếu bất kỳ dịch vụ nào báo disabled hoặc error, hãy dừng sớm bản build trước khi chạy các bộ test suite.

2. Kiểm tra các thay đổi trạng thái

Xác nhận các thay đổi trạng thái giữa các assertion kiểm thử trực tiếp qua CLI. Chẳng hạn, xác minh xem tệp tải lên đã vào S3 hay chưa hoặc kiểm tra độ dài hàng đợi sau khi kích hoạt một event worker:

# Xác minh hóa đơn đã tải lên
awslocal s3 ls s3://incoming-documents/

# Kiểm tra số lượng tin nhắn đang chờ xử lý trong hàng đợi
awslocal sqs get-queue-attributes \
    --queue-url http://localhost:4566/000000000000/document-processing-queue \
    --attribute-names ApproximateNumberOfMessages

3. Chẩn đoán các ca kiểm thử thất bại

Khi một assertion gặp lỗi 403, 404 hoặc 500, hãy kiểm tra standard output của container. LocalStack sẽ ghi log chi tiết từng yêu cầu HTTP bị chặn kèm action mục tiêu và payload của phần thân:

docker logs localstack-main --tail 50

Hiệu quả mang lại là thấy ngay tức thì. Thời gian chạy kiểm thử cho mỗi PR giảm mạnh từ 14 phút xuống còn 2 phút 35 giây. Chi phí cloud cho môi trường staging tiết kiệm được 1.400 USD mỗi tháng, và tình trạng gián đoạn pipeline do lỗi false-positive giảm về con số không. Các lập trình viên có thể lặp lại chu kỳ kiểm thử chuẩn xác trong tích tắc ngay trên laptop của mình mà không cần chạm tới hạ tầng cloud thực tế.

Share: