↓ 본문으로 건너뛰기

AWS Emulator LocalStack Moto

AWS Emulator 사용법: 무료 LocalStack 대체 설정하기

AWS의 무료 에뮬레이터를 Docker, Linux, GitHub Actions에 설정하고 boto3를 연결하는 법. 제 S3 로컬 테스트에서 LocalStack 4.12가 놓친 것을 이 에뮬레이터가 잡아낸 이야기도 담았습니다.

AWS Emulator 사용법: 무료 LocalStack 대체 설정하기

AWS가 2026년 11월 11일 AWS Emulator를 공개했습니다. 내 컴퓨터에서 직접 돌리는 AWS API의 로컬 사본입니다. 무료이고, AWS 계정이 필요 없고, 코드는 퍼블릭 도메인(CC0)이며, 지원하는 서비스가 200개가 넘습니다. LocalStack이 최신 이미지에 계정과 인증 토큰을 요구하기 시작한 지 7개월 만에 나온 터라, 많은 사람이 오래 기다려 온 무료 LocalStack 대체 도구로 이걸 눈여겨보고 있습니다.

이 글은 대부분 설정 이야기입니다. Mac이나 Windows에서는 Docker, Linux에서는 패키지, AWS CLI와 boto3가 에뮬레이터를 바라보게 하는 환경 변수, 그리고 Docker Compose, pytest, GitHub Actions까지요. 여기 나오는 명령은 전부 버전 1.0.342에서 제가 직접 돌려 봤습니다. 설정 다음에는 제 프로젝트 두 개의 S3 테스트를 여기에 돌렸을 때 무엇을 맞히고 무엇을 틀렸는지를 LocalStack 4.12, Moto, Floci와 나란히 놓고 봅니다. 두 프로젝트는 제 마켓플레이스 앱의 업로드 흐름, 그리고 직접 호스팅하는 Codemagic Patch 서버가 OTA 번들을 보관하는 버킷입니다.

1분 만에 보는 AWS Emulator
#

AWS는 컨테이너 이미지를 배포하지 않습니다. AWS의 Linux 패키지를 설치하는 아홉 줄짜리 Dockerfile로 이미지를 직접 만들고, 9297 포트로 띄운 다음, 환경 변수 몇 개를 설정하면 됩니다. 그때부터 SDK는 AWS 대신 에뮬레이터와 이야기하고, 코드는 하나도 바뀌지 않습니다.

docker build -t aws-emulator .           # the Dockerfile is below
mkdir -p ~/.aws/emulator
docker run --rm -d --name aws-emulator -p 127.0.0.1:9297:9297 \
  -e HOME="$HOME" -v "$HOME/.aws/emulator:$HOME/.aws/emulator" \
  aws-emulator -p 9297 --telemetry off

export AWS_ENDPOINT_URL=https://127.0.0.1:9297
export AWS_SHARED_CREDENTIALS_FILE=~/.aws/emulator/9297/emulator_credentials
export AWS_CONFIG_FILE=~/.aws/emulator/9297/config
export AWS_CA_BUNDLE=~/.aws/emulator/9297/ca.pem
aws sts get-caller-identity              # "Account": "000000000000"
제 Mac에서 녹화한 설정 전 과정입니다. 시작, 헬스 체크, 에뮬레이터의 계정, S3 업로드, 그리고 아직 못 하는 한 가지(CORS preflight가 501로 답합니다. 아래에서 자세히 다룹니다).

준비되기까지 0.5초 정도 걸리고, 가만히 있을 때 메모리를 175 MiB쯤 씁니다. 모든 게 메모리에 있어서 컨테이너를 멈추면 싹 지워집니다.

계정이 필요할까? (LocalStack 무료 플랜 vs AWS Emulator)
#

아니요. AWS Emulator, Moto, Floci는 모두 아무 데도 가입하지 않고 돌아갑니다. AWS Emulator는 AWS 자격 증명조차 원하지 않습니다. 시작할 때마다 자기 액세스 키를 새로 만들고, 그 키로 서명한 요청만 받습니다.

달라진 건 LocalStack입니다. 2026년 3월부터 최신 이미지는 LOCALSTACK_AUTH_TOKEN이 있어야 하는데, 이 토큰은 무료 계정(무료 플랜은 취미와 비상업적 용도를 겨냥합니다)이나 유료 계정에서 받습니다. 토큰이 없으면 컨테이너가 시작 단계에서 멈춥니다. 둘 다 시험해 봤습니다. latest 태그 이미지(2026.10.7, 11월 11일 빌드)는 종료 코드 55로 끝나고, 마지막 커뮤니티 릴리스이자 LocalStack이 직접 고정해 쓰라고 권하는 버전인 4.12는 토큰 없이도 아직 시작됩니다.

토큰 없이 띄운 LocalStack 최신 이미지, 그다음 버전을 고정한 4.12. 지금은 고정으로 버틸 수 있지만, 4.12에는 앞으로 수정이 나오지 않습니다.

AWS Emulator가 원하는 게 하나 있긴 합니다. 기본으로 켜져 있는 텔레메트리입니다. AWS는 요청, 코드, 자격 증명은 절대 포함되지 않는다고 하지만, 아무것도 보내지 않게 하고 싶다면 스위치가 세 가지 있습니다. 명령줄의 --telemetry off, 환경 변수 EMULATOR_TELEMETRY=off, 아니면 ~/.aws/emulator/config.toml에 이렇게 적는 겁니다.

[telemetry]
enabled = false

macOS·Windows에서 Docker로 AWS Emulator 설정하기
#

Mac이나 Windows에서는 Docker로 갑니다. 빈 폴더에 아래 내용을 Dockerfile로 저장하세요. AWS가 직접 만든 것으로, AWS의 Docker 가이드에 있습니다.

FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal
ADD https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator-x86_64.rpm /tmp/aws-emulator.rpm
RUN rpm -i /tmp/aws-emulator.rpm && rm /tmp/aws-emulator.rpm \
    && mkdir -p /home/emulator && chown 1000 /home/emulator
USER 1000
ENV HOME=/home/emulator
EXPOSE 9297
ENTRYPOINT ["aws-emulator", "-b", "0.0.0.0"]

Apple silicon이나 다른 ARM 머신에서는 x86_64.rpm을 aarch64.rpm으로 바꾸세요. 그다음 빌드하고 띄웁니다.

docker build -t aws-emulator .
mkdir -p ~/.aws/emulator
docker run --rm -d --name aws-emulator --memory=2g --memory-swap=2g \
  -p 127.0.0.1:9297:9297 \
  -e HOME="$HOME" -v "$HOME/.aws/emulator:$HOME/.aws/emulator" \
  aws-emulator -p 9297 --telemetry off

제 Mac에서 빌드는 88초 걸렸고 이미지는 1.67 GB입니다. 이 docker run 줄에서 보기보다 중요한 부분이 세 군데 있습니다.

  • -p 9297:9297이 아니라 -p 127.0.0.1:9297:9297. 짧게 쓰면 에뮬레이터가 네트워크 전체에 열립니다.
  • 볼륨과 -e HOME. 에뮬레이터는 시작할 때 ~/.aws/emulator/9297/에 파일 네 개를 씁니다. 자격 증명, AWS config 파일, 인증서(ca.pem), 그리고 Java 트러스트 스토어입니다. 이 폴더를 컨테이너 안의 같은 경로에 마운트해야 호스트의 SDK가 그 파일들을 읽을 수 있습니다.
  • 포트가 세 번 나옵니다. 바꾼다면 전부 바꾸세요. 에뮬레이터는 그 파일들에 자기 포트를 적어 넣기 때문에, 폴더를 공유하는 컨테이너 두 개는 서로의 파일을 덮어씁니다.

Linux에 AWS Emulator 설치하기
#

Linux에서는 Docker가 필요 없습니다. Debian 패키지와 RPM이 있습니다.

# Debian / Ubuntu (use aws-emulator_arm64.deb on ARM)
curl -fsSLo aws-emulator.deb https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator_amd64.deb
sudo apt-get install -y ./aws-emulator.deb

# Amazon Linux, Fedora, RHEL (aarch64.rpm on ARM)
sudo dnf install -y https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator-x86_64.rpm

aws-emulator --telemetry off

패키지는 150 MB입니다. 127.0.0.1:9297에서 포그라운드로 돌고, 같은 파일 네 개를 ~/.aws/emulator/9297/에 씁니다. Ctrl+C를 누르면 깔끔하게 멈추면서 그 파일들도 지웁니다. 테스트용으로 돌릴 때는 --idle-timeout 900을 붙이세요. 요청 없이 15분이 지나면 알아서 꺼지니까, 깜빡 잊은 에뮬레이터가 일주일 내내 떠 있을 일이 없습니다.

AWS CLI와 boto3를 에뮬레이터에 연결하기
#

변수는 이렇습니다. 표준 AWS 환경 변수를 읽는 SDK라면 무엇이든 이걸 가져가니까, 테스트 대상 코드에는 엔드포인트도 키도 필요 없습니다.

E=~/.aws/emulator/9297
export AWS_ENDPOINT_URL=https://127.0.0.1:9297
export AWS_SHARED_CREDENTIALS_FILE=$E/emulator_credentials
export AWS_CONFIG_FILE=$E/config
export AWS_CA_BUNDLE=$E/ca.pem
export AWS_REGION=us-east-1
export AWS_EC2_METADATA_DISABLED=true
export AWS_DISABLE_HOST_PREFIX_INJECTION=true
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE

curl -s --cacert $AWS_CA_BUNDLE $AWS_ENDPOINT_URL/__admin/health   # ok
aws sts get-caller-identity                                          # 000000000000

그러면 평범한 boto3가 그냥 동작합니다.

import boto3

s3 = boto3.client("s3")
s3.create_bucket(Bucket="uploads")
print(s3.list_buckets()["Buckets"])

꼭 남겨 둘 줄은 unset입니다. 환경 변수가 자격 증명 파일보다 우선하기 때문에, 진짜 AWS 키가 아직 export돼 있으면 SDK는 그 키로 서명하고, 에뮬레이터는 요청을 거부하고, 여러분은 이유를 찾느라 10분을 날립니다. aws sts get-caller-identity는 빠르게 확인하기 좋습니다. 에뮬레이터는 항상 계정 000000000000이거든요. 자기 계정 번호가 보인다면 AWS와 이야기하고 있는 겁니다.

그 밖에 제가 부딪혔거나 부딪힐 뻔한 것들입니다.

  • 엔드포인트에는 localhost가 아니라 127.0.0.1을 쓰세요. IP 주소면 SDK가 버킷 이름을 경로에 넣습니다. localhost면 JavaScript, Java, Go SDK가 버킷 이름을 호스트 이름으로 옮기는데(uploads.localhost), 에뮬레이터는 이걸 받지 않습니다.
  • HTTPS 전용이고, 시작할 때마다 인증서가 새로 만들어집니다. boto3, AWS CLI, Go는 AWS_CA_BUNDLE을 읽습니다. Node는 같은 ca.pem을 가리키는 NODE_EXTRA_CA_CERTS가 필요하고, Java는 JAVA_TOOL_OPTIONS로 truststore.p12를 씁니다. AWS_CA_BUNDLE은 그 프로세스가 신뢰하는 인증서를 통째로 바꿔 버리기 때문에, 실제 HTTPS API도 호출하는 테스트라면 둘을 합친 번들이 필요합니다.
  • 리셋이 없습니다. 내용을 비워 주는 관리용 경로가 없으니, 테스트마다 버킷 이름을 따로 주거나 테스트 묶음 사이에 에뮬레이터를 재시작하세요.
  • 키는 시작할 때마다 바뀝니다. .env 파일에 복사해 두지 말고 자격 증명 파일을 가리키게 하세요.
  • 앱이 엔드포인트를 명시해서 자기 클라이언트를 따로 만든다면(예를 들어 django-storages의 AWS_S3_ENDPOINT_URL), 그 값도 AWS_ENDPOINT_URL에서 가져오게 하세요. 안 그러면 그 클라이언트 하나만 계속 실제 서비스와 이야기합니다.

Docker Compose로 AWS Emulator 실행하기
#

이미 compose.yaml이 있는 프로젝트라면 아래 서비스를 쓰면 됩니다. 위의 Dockerfile로 이미지를 빌드하고, 에뮬레이터 파일을 홈 디렉터리 대신 프로젝트 폴더에 두고, 헬스 체크도 있어서 docker compose up --wait가 준비가 끝난 뒤에 돌아옵니다.

services:
  aws:
    build: ./aws-emulator            # the folder with the Dockerfile
    command: ["-p", "9297", "--telemetry", "off"]
    ports: ["127.0.0.1:9297:9297"]
    environment:
      HOME: ${PWD}/.aws-emulator-home
    volumes:
      - ./.aws-emulator-home/.aws/emulator:${PWD}/.aws-emulator-home/.aws/emulator
    mem_limit: 2g
    healthcheck:
      test: ["CMD", "curl", "-sf", "--cacert", "${PWD}/.aws-emulator-home/.aws/emulator/9297/ca.pem",
             "https://127.0.0.1:9297/__admin/health"]
      interval: 1s
      retries: 30
mkdir -p .aws-emulator-home/.aws/emulator
docker compose up -d --wait aws       # healthy in about 5 s with the image already built
export E=$PWD/.aws-emulator-home/.aws/emulator/9297   # then the variables from above

.aws-emulator-home/은 .gitignore에 넣으세요. 그 실행의 키가 들어 있습니다.

pytest에서 AWS Emulator 쓰기
#

테스트 자체에는 에뮬레이터 전용 코드가 없습니다. 제가 더하는 건 안전장치 하나입니다. 제가 걱정하는 실패는 빨간 테스트가 아니라, 진짜 버킷을 상대로 돌고 초록색으로 끝난 테스트이기 때문입니다.

# conftest.py
import os
import uuid

import boto3
import pytest


@pytest.fixture(scope="session", autouse=True)
def only_against_the_emulator():
    url = os.environ.get("AWS_ENDPOINT_URL", "")
    if not url.startswith(("https://127.0.0.1", "http://127.0.0.1")):
        pytest.exit(f"AWS_ENDPOINT_URL is {url!r}; refusing to run against real AWS")


@pytest.fixture
def s3():
    return boto3.client("s3")


@pytest.fixture
def bucket(s3):
    name = f"test-{uuid.uuid4().hex[:12]}"   # no reset route, so never reuse a name
    s3.create_bucket(Bucket=name)
    return name

그러면 테스트는 여느 테스트와 다를 게 없습니다.

import os

import requests


def test_presigned_upload_rejects_the_wrong_content_type(s3, bucket):
    url = s3.generate_presigned_url(
        "put_object", ExpiresIn=300,
        Params={"Bucket": bucket, "Key": "media/a.jpg", "ContentType": "image/jpeg"},
    )
    r = requests.put(url, data=b"<html>", headers={"Content-Type": "text/html"},
                     verify=os.environ["AWS_CA_BUNDLE"])
    assert r.status_code == 403

이건 제 마켓플레이스 앱의 업로드 흐름에서 가져온 테스트인데, 어떤 에뮬레이터를 고르느냐가 왜 중요한지 잘 보여 줍니다. 제가 시험한 다섯 개 중 세 개가 여기에 200을 돌려주거든요. 자세한 건 아래에서 다룹니다. 변경할 때마다 영향받는 테스트만 돌린다면 pytest-testmon도 손대지 않고 그대로 함께 쓸 수 있습니다.

GitHub Actions에서 AWS Emulator 실행하기
#

GitHub 러너에서는 Docker를 건너뛰고 Debian 패키지를 설치하세요. 한 스텝에서 띄운 백그라운드 프로세스는 잡이 끝날 때까지 계속 돕니다.

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v10

      - name: Start AWS Emulator
        run: |
          curl -fsSLo aws-emulator.deb https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator_amd64.deb
          sudo apt-get install -y ./aws-emulator.deb
          aws-emulator --telemetry off --idle-timeout 900 > emulator.log 2>&1 &
          E=$HOME/.aws/emulator/9297
          for i in $(seq 30); do
            curl -sf --cacert $E/ca.pem https://127.0.0.1:9297/__admin/health && break
            sleep 1
          done
          {
            echo "AWS_ENDPOINT_URL=https://127.0.0.1:9297"
            echo "AWS_SHARED_CREDENTIALS_FILE=$E/emulator_credentials"
            echo "AWS_CONFIG_FILE=$E/config"
            echo "AWS_CA_BUNDLE=$E/ca.pem"
            echo "AWS_REGION=us-east-1"
            echo "AWS_EC2_METADATA_DISABLED=true"
            echo "AWS_DISABLE_HOST_PREFIX_INJECTION=true"
          } >> "$GITHUB_ENV"

      - run: uv run pytest

같은 스텝을 새 ubuntu:24.04 컨테이너에서도 돌려 봤습니다(제 Mac이 ARM이라 arm64 패키지로요). 설치는 29초 걸렸는데 거의 전부가 150 MB 다운로드였고, 헬스 체크는 4초 안에 응답했습니다. 이 잡에는 실제 AWS 자격 증명을 아예 넣지 마세요. 잡 안의 무엇도 AWS에 닿지 않는다고 확신할 수 있는 가장 싼 방법입니다.

LTS/latest는 마지막으로 릴리스된 버전을 가리키는데, 아래에서 보겠지만 이게 하루에도 몇 번씩 바뀝니다. CI를 재현 가능하게 하고 싶다면 AWS 문서에 특정 버전을 설치하는 방법이 나와 있습니다. 파이프라인의 나머지를 제가 어떻게 짜는지는 요즘 제가 돌리는 GitHub Actions 파이프라인을 참고하세요.

LocalStack에서 옮겨 오기
#

이미 LocalStack으로 테스트를 돌리고 있다면, 옮기는 일은 대부분 설정입니다.

LocalStackAWS Emulator
엔드포인트http://localhost:4566https://127.0.0.1:9297과 AWS_CA_BUNDLE
자격 증명아무거나 (test / test)실행할 때마다 쓰는 파일, 또는 --auth off
계정 ID000000000000000000000000
설정 스크립트/etc/localstack/init/ready.d의 init hook없음: 헬스 체크 뒤에 fixture에서 리소스 생성
이미지localstack/localstack (4.12 이후는 토큰 필요)AWS 패키지로 직접 빌드

init hook을 많이 쓴다면 그 부분이 진짜로 손이 가는 곳입니다. 저는 테스트마다 버킷을 따로 만들어서 쓸 일이 없었습니다. 그리고 재시작하면 아무것도 남지 않습니다. 에뮬레이터는 전부 메모리에 들고 있으니까요.

S3 로컬 테스트에서 AWS Emulator가 잡아낸 것과 놓친 것
#

설정은 쉬운 부분입니다. 제가 알고 싶었던 건, 제 코드가 S3의 동작에 기대는 지점에서 이 에뮬레이터도 S3처럼 동작하느냐였습니다. 검사를 12개 썼고, 각각 AWS 문서에 적힌 실제 S3의 동작을 확인합니다.

  • 마켓플레이스 앱의 업로드 흐름에서 6개. 앱이 브라우저에 presigned URL을 건네면, 브라우저가 사진을 버킷에 바로 PUT하고(그래서 버킷에 CORS가 필요합니다), ObjectCreated 이벤트가 파일이 도착했다고 백엔드에 알립니다. 객체 알림에 관한 글에서 다룬 흐름인데, 웹훅 자리에 SQS를 넣었습니다.
  • Codemagic Patch 버킷에서 6개. Patch는 React Native 앱용 OTA 업데이트 서버입니다(Patch로 옮겨 간 이야기). 휴대폰이 번들을 내려받을 수 있도록 버킷은 공개인데, 업로드가 게시를 기다리는 _internal/ 프리픽스만은 예외라서 버킷 정책으로 이 프리픽스를 비공개로 묶어 둡니다.

이 검사들을 AWS Emulator 1.0.342(기본 상태로 한 번, --auth off로 한 번), LocalStack 4.12(기본 상태로, 그리고 S3_SKIP_SIGNATURE_VALIDATION=0으로), 서버 모드의 Moto 5.2.6, Floci 1.5.5, 그리고 Patch의 셀프 호스팅 번들이 쓰는 MinIO 포크인 Silo에 돌렸습니다.

실제로 돌린 결과입니다. 12개를 전부 통과한 건 없었습니다.

12개를 모두 통과한 구성은 없었고, 다들 6개에서 8개 사이였습니다. 합계보다 각자 어떤 검사를 놓쳤는지가 더 중요하니, 저한테 가장 아팠을 것부터 순서대로 적어 보겠습니다.

AWS Emulator가 권한에는 가장 엄격합니다. 제 앱이 JPEG 한 장을 위해 서명해 주는 presigned URL을 예로 들어 보죠. 기본 설정의 LocalStack 4.12, Moto, Floci에서는 같은 URL로 HTML 파일을 올릴 수 있었고, 200이 돌아왔습니다. 공개 파일을 서빙하는 버킷이라면 저장형 XSS가 터지기를 기다리는 셈입니다. AWS Emulator는 S3처럼 403으로 거부했고, 만료된 URL도 다시 거부했습니다. (LocalStack 4.12는 S3_SKIP_SIGNATURE_VALIDATION=0을 설정하면 둘 다 잡고, Floci는 만료는 잡지만 바꿔치기한 파일 형식은 통과시킵니다.) 새 S3 버킷은 Block Public Access가 켜진 채로 만들어져서 그걸 끄기 전까지 공개 버킷 정책이 거부된다는 걸 아는 것도 이것뿐이었고, 명시적 Deny를 낯선 사람뿐 아니라 서버 자신의 서명된 요청에도 적용한 것도 이것뿐이었습니다. 모든 테스트를 통과하고 나서 첫 실제 배포에서 터지는 실수가 딱 이런 것들입니다.

브라우저가 하는 일은 아직 테스트할 수 없습니다. CORS preflight(브라우저가 교차 출처 PUT 전에 보내는 OPTIONS 요청)는 501 Not Implemented를 받습니다. 기본값대로 인증이 켜져 있으면 서명 없는 요청도 전부 거부하기 때문에, 정책이 허용하더라도 공개 버킷에서의 익명 다운로드가 403을 받습니다. 두 빈틈 모두 이미 저장소에 등록돼 있습니다. 이 프로젝트는 알려진 빈틈을 issues/things-that-do-not-work/ 아래에 파일로 관리하는데, 11월 13일 기준으로 6,840개가 있었고 13,409개는 이미 things-that-now-work/로 옮겨져 있었습니다.

GitHub의 things-that-do-not-work 폴더에 있는 AWS Emulator의 S3 이슈 파일, 제목은 “7 net-new causes — CORS preflight answers 501…”
프로젝트의 자체 이슈 탐색 에이전트가 11월 9일에 등록한 CORS 빈틈.

--auth off는 한쪽을 얻는 대신 다른 쪽을 내줍니다. 인증을 끄면 익명 읽기가 되고 버킷 정책도 제대로 평가돼서, Patch 검사 6개가 모두 통과합니다. 그걸 해낸 설정은 이것 하나뿐입니다. 하지만 presigned URL도 더는 검사하지 않아서 업로드 쪽 검사는 2개로 떨어집니다. 에뮬레이터 두 개를 다른 포트에 띄워 하나는 인증을 켜고 하나는 끄면 양쪽을 다 덮을 수 있지만, 다음에 CI 파일을 읽을 사람에게 설명할 거리가 하나 늘어나죠.

변화가 빠릅니다. v1.0.290부터 v1.0.342까지의 릴리스가 사흘 만에 나왔고, 하나하나가 긴 수정 목록입니다(AWS 말로는 코딩 에이전트가 만들었다는데, 속도만 봐도 알 만합니다). 11월 11일 수정 가운데 하나는 S3의 “presigned GET refused with 403"이었습니다. 제 표의 어느 칸이든 다음 주면 어느 쪽으로든 달라져 있을 수 있습니다. CI에서는 버전을 고정하고, 올릴 때는 의식적으로 올리세요.

GitHub의 aws/aws-emulator 릴리스 페이지: 3시간 전에 나온 v1.0.342에 Latest 표시가 붙어 있고, 사이드바에 릴리스 9개가 더 보임
사흘에 릴리스 10개. 하나를 골라 고정하세요.

Patch 버킷: 실제 S3에서만 문제가 되는 정책 조건 하나
#

예상하지 못한 건 이 부분이었습니다. Patch의 셀프 호스팅 번들은 모든 걸 버킷 하나에 두고, 번들에 포함된 스토리지에 적용하는 정책도 뻔한 것입니다. 누구나 읽을 수 있되 _internal/ 아래만 예외죠.

{
  "Version": "2012-10-17",
  "Statement": [
    { "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::example-ota/*" },
    { "Effect": "Deny", "Principal": "*", "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::example-ota/_internal/*" }
  ]
}

Silo에서 관리자 자격 증명으로 서명하면 이 정책은 적힌 그대로 동작합니다. 낯선 사람은 _internal/을 읽지 못하고, 서버가 직접 _internal/에서 공개 프리픽스로 하는 복사(‘게시’ 단계)는 통과합니다. 그런데 실제 S3에서는 "Principal": "*"를 건 명시적 Deny가 여러분 자신의 자격 증명을 포함해 모두에게 적용되기 때문에, 같은 정책이 서버 자신의 게시 단계를 막아 버립니다. AWS Emulator는 그 복사를 403으로 거부했는데, AWS의 정책 평가 규칙에 따르면 S3가 하는 동작이 바로 그겁니다.

Patch 메인테이너들도 분명 이걸 알고 있습니다. AWS S3용 예시 정책에서는 Deny에 조건을 하나 더 붙이거든요.

"Condition": { "StringNotEquals": { "aws:PrincipalAccount": "YOUR_AWS_ACCOUNT_ID" } }

이 줄이 있으면 AWS Emulator(익명 쪽도 테스트할 수 있게 --auth off로)와 Moto 둘 다 워커의 게시는 허용하면서 _internal/ 익명 읽기는 여전히 거부합니다. 하지만 이 줄이 없는 버전을 잡아낸 건 AWS Emulator뿐이었습니다. Moto, LocalStack 4.12, Floci는 어느 쪽이든 복사를 통과시켰으니, 이 중 무엇을 썼어도 저는 망가진 정책을 그대로 배포했을 겁니다. 쓸 수 있는 가장 엄격한 에뮬레이터로 정책을 테스트해야 하는 이유로는 이게 제가 찾은 가장 분명한 사례입니다. MinIO 환경에서 쓰던 정책을 그 조건 없이 S3로 옮기면, 첫 실제 게시 전까지는 아무것도 문제를 알려 주지 않습니다.

(제 Patch 서버는 번들을 Tigris에 저장하는데, Tigris에는 버킷 정책이 아예 없어서 _internal/은 별도의 비공개 버킷에 둡니다. Patch 설정의 S3_INTERNAL_BUCKET이 그 용도입니다. 그리고 저는 Codemagic에 Patch에 관한 유료 기고 글도 씁니다. 이 글은 그런 글이 아니고, 누구에게서도 돈을 받지 않았습니다.)

지금이라면 무엇을 쓸지
#

준비 시간유휴 메모리이미지계정
AWS Emulator 1.0.3420.4–0.5초176 MiB1.67 GB (직접 빌드)불필요
LocalStack 4.120.9–2.2초90 MiB1.89 GB4.12는 불필요, 이후 버전은 토큰 필요
Moto 5.2.6 (서버)0.7–0.8초45 MiB560 MB불필요
Floci 1.5.50.3–0.4초11 MiB292 MB불필요

제 Mac의 Docker에서 각각 콜드 스타트 세 번씩 잰 값입니다. 신경 쓸 만큼 느린 건 하나도 없습니다.

백엔드 테스트라면 저는 AWS Emulator로 시작하겠습니다. 이게 잡아내는 실수(권한, 서명, Block Public Access)는 이게 아니면 프로덕션에서야 드러나는 것들이고, 이 목록에서 무료이면서 최신이고 S3를 만드는 사람들이 직접 만든 건 이것뿐이니까요. CORS preflight가 구현되기 전까지는 브라우저 쪽 검사(preflight, 그리고 공개 프리픽스에서의 익명 다운로드)를 위한 작은 잡 하나를 Floci로 남겨 두겠습니다. 엄격한 서명 검사를 켠 LocalStack 4.12도 이 검사들을 통과하지만, 멈춰 있는 버전이라 수정이 나오지 않습니다. 테스트가 Python이고 컨테이너를 띄우고 싶지 않다면 여전히 Moto의 인프로세스 mock이 가장 가볍습니다. 다만 presigned URL 검사만큼은 Moto에 맡기지 마세요.

검사 12개는 오후 한나절이면 자기 버킷에 맞게 쓸 수 있을 만큼 짧습니다. 어떤 에뮬레이터든, 이것까지 포함해서, 믿기 전에 꼭 한번 해 보길 권합니다. 표에 나온 도구는 저마다 다른 검사에서 실패했고, 그게 바로 여러분의 테스트가 조용히 넘어가 버릴 빈틈입니다.