LocalStackとDockerを活用したローカルクラウドテスト:CI/CDにおけるゼロコストのAWSモック検証

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

背景と導入の理由

半年前、私たちのステージング環境におけるAWSの請求額は、テスト用インフラだけで月額1,400ドルに達していました。エンジニアがS3、SQS、DynamoDBに関連するプルリクエストを作成するたびに、GitHub ActionsパイプラインがTerraform経由で一時的なサンドボックスリソースをプロビジョニングしていたためです。このアプローチにより忠実度の高いテストは実現できたものの、15分もの待ち時間と多大なフラストレーションが発生していました。

パイプラインの実行は遅延を極めました。リモートのCloudFormationスタックやAWS APIのハンドシェイクの待機だけで、コミットごとに8〜10分が費やされていました。さらに悪いことに、一時的なAWSネットワークのタイムアウトによって約6回に1回の割合で誤検知(偽陽性)のエラーが発生していました。エンジニアたちは待ち行列を避けるため、テストスイートをスキップしたり、複数のPRをまとめたりするようになってしまいました。

私たちは内製のモックを構築することも検討しましたが、そのアイデアはすぐに断念しました。複雑なAWSの署名付きURL、メッセージの受信ハンドル、DynamoDBのページネーション条件などをエミュレートする仕組みを自作することは、運用の泥沼(オペレーショナル・ターピット)に陥るだけだったからです。

そこで代わりに、Docker上でLocalStackを動かす構成を標準化しました。LocalStackはマシン上でAWS互換のエミュレータを実行し、S3、SQS、SNS、DynamoDBなどのサービスを単一のエッジポート4566経由で公開します。アプリケーションコード、AWS SDK、CLIツールは標準的なAWSプロトコルと通信しますが、ワークステーションの外部へネットワークパケットが送信されることは一切ありません。

インストール

LocalStackの実行に必要なのは、DockerとDocker Composeのみです。LocalStackはPythonのパッケージマネージャー(pip install localstack)やスタンドアロンバイナリ経由でもインストール可能ですが、コンテナ化することでLinux、macOS、ビルドランナー間で一貫した動作を保証できます。

1. Docker Composeのセットアップ

テストディレクトリまたはリポジトリのルートにdocker-compose.ymlファイルを追加します。最新のLocalStack(v2以降およびv3以降)では、初回リクエスト時にサービスが遅延ロードされるため、以前のような肥大化した環境変数宣言は不要になりました。

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. コンテナの起動

エミュレータデーモンを起動します:

docker compose up -d

次に、awscli-localをインストールします。この軽量ラッパーは標準のaws CLIコマンドをインターセプトし、あらかじめ設定されたモック認証情報とともにhttp://localhost:4566へリダイレクトします:

pip install awscli-local

S3サブシステムを確認して、モックゲートウェイが正常に動作していることを検証します:

awslocal s3 ls

設定

ローカルクラウドのモックを実用的なものにするには、自動ブートストラップスクリプト、動的なSDKエンドポイント設定、無駄のないCIワークフローステップの3つが必要です。

1. 起動時のリソースブートストラップ

LocalStackは、内部サービスのヘルスチェックに合格するとすぐに、/etc/localstack/init/ready.d/配下にマウントされた初期化スクリプトを実行します。このフックを利用して、テーブルやキューを自動作成します。

init-resources.shを作成します:

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

echo "モッククラウドリソースを初期化中..."

# ドキュメント保存用バケットの作成
wslocal s3 mb s3://incoming-documents

# 可視性タイムアウト30秒の取り込みキューを作成
awslocal sqs create-queue \
    --queue-name document-processing-queue \
    --attributes VisibilityTimeout=30

# オンデマンドキャパシティでメタデータテーブルをプロビジョニング
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 "ローカルリソースの準備が完了しました。"

このスクリプトをdocker-compose.yml内で読み取り専用としてマウントします:

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

2. アプリケーションコードからローカルエンドポイントへの接続

本番用コード側では、接続先がAWSなのかLocalStackなのかを意識させるべきではありません。環境変数AWS_ENDPOINT_URLを使用して、ファクトリ関数内でシームレスに接続先を切り替えます。以下はboto3を使用した実績あるPythonの実装例です:

import os
import boto3

# 本番環境ではNoneにフォールバックし、boto3が公式のAWSエンドポイントを向くようにする
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. GitHub Actionsでの自動化

LocalStackをジョブのサービスコンテナとして実行することで、プルリクエスト時のクラウド権限設定、IAMロール、シークレットのローテーションが不要になります。LocalStackはランナーホスト上で直接起動します:

name: Integration Tests

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: Python 3.11のセットアップ
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'

      - name: テスト依存関係のインストール
        run: |
          python -m pip install --upgrade pip
          pip install pytest boto3 awscli-local

      - name: モックインフラの初期化
        run: |
          chmod +x ./init-resources.sh
          ./init-resources.sh

      - name: テストスイートの実行
        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

検証とモニタリング

迅速なフィードバックも、アサーションの信頼性が高く、失敗原因の調査が容易であって初めて価値を持ちます。

1. エンドポイントのヘルスチェック診断

LocalStackは/_localstack/health経由でリアルタイムの診断情報を提供します。このエンドポイントにクエリを送信すると、アクティブな各サブシステムのステータスが返されます:

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

正常なインスタンスでは、オンデマンドで準備完了となったサービスが次のようにレポートされます:

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

いずれかのサービスがdisabledまたはerrorを返した場合は、テストスイートを実行する前にビルドを失敗させてください。

2. 状態変化の検証

テストのアサーション間における状態の変更は、CLIから直接確認できます。例えば、S3へのアップロードが完了したかの確認や、イベントワーカー起動後のキューの深さ(メッセージ数)のチェックなどが可能です:

# アップロードされた請求書を確認
awslocal s3 ls s3://incoming-documents/

# 処理キュー内の保留中メッセージ数を確認
awslocal sqs get-queue-attributes \
    --queue-url http://localhost:4566/000000000000/document-processing-queue \
    --attribute-names ApproximateNumberOfMessages

3. テスト失敗時の原因診断

アサーションが403、404、500エラーで失敗した場合は、コンテナの標準出力を確認してください。LocalStackはインターセプトしたすべてのHTTPリクエストを、対象のアクションやリクエストボディのペイロードと共に出力します:

docker logs localstack-main --tail 50

その効果はすぐに現れました。PRのテスト実行時間は14分から2分35秒へと大幅に短縮されました。ステージング環境のクラウド費用は月額1,400ドル削減され、誤検知によるパイプラインの中断もゼロになりました。開発者は実際のクラウドインフラに手を触れることなく、手元のノートPCから1秒未満で決定論的なテストを繰り返し実行できるようになりました。

Share: