AWS 在 2026 年 11 月 11 日发布了 AWS Emulator。它是 AWS API 的本地副本,跑在你自己的机器上:免费,不需要 AWS 账号,代码属于公有领域(CC0),覆盖 200 多个服务。距 LocalStack 开始要求当前镜像必须有账号和 auth token 才能运行,刚好过去七个月,所以很多人都把它看作自己一直在等的那个免费 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 bundle 的 bucket。
一分钟看懂 AWS Emulator#
AWS 没有发布容器镜像。你要用一个九行的 Dockerfile 自己构建(它会安装官方的 Linux 安装包),在 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"它大约半秒就能就绪,空闲时占大约 175 MiB 内存。所有数据都在内存里,所以容器一停就全没了。
需要注册账号吗?(LocalStack 与 AWS Emulator 对比)#
不需要。AWS Emulator、Moto 和 Floci 都不用注册任何东西就能跑。AWS Emulator 甚至不要你的 AWS 凭证:它每次启动都会自己生成一把 access key,并且只接受用这把 key 签名的请求。
变了的是 LocalStack。从 2026 年 3 月起,它的当前镜像需要一个 LOCALSTACK_AUTH_TOKEN,来自免费账号(免费套餐面向个人爱好和非商业用途)或付费账号。没有 token,容器在启动时就会停掉。两种我都试了:标签为 latest 的镜像(2026.10.7,11 月 11 日构建)以退出码 55 退出;而 4.12,也就是最后一个社区版、LocalStack 自己也建议锁定的版本,不带 token 仍然能启动。
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 自己的版本,出自他们的 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 芯片或其他 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 127.0.0.1:9297:9297,不要用-p 9297:9297。 短的写法会把模拟器暴露给整个网络。 - 卷挂载和
-e HOME。 启动时,模拟器会往~/.aws/emulator/9297/写四个文件:它的凭证、一个 AWSconfig文件、它的证书(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 都会用上它们,所以被测代码里既不用写 endpoint,也不用写 key:
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 key 还 export 着,SDK 就会用它们签名,模拟器拒绝请求,然后你白白花十分钟琢磨为什么。aws sts get-caller-identity 是个快速的自检:模拟器的账号永远是 000000000000。如果看到的是你自己的账号,说明你连的是 AWS。
其他几个我踩过、或者差点踩到的坑:
- endpoint 里写
127.0.0.1,别写localhost。 用 IP 地址时,SDK 会把 bucket 名放在路径里。用localhost时,JavaScript、Java 和 Go 的 SDK 会把它挪到主机名里(uploads.localhost),而模拟器不接受这种写法。 - 只支持 HTTPS,而且每次启动都换一张新证书。 boto3、AWS CLI 和 Go 会读
AWS_CA_BUNDLE。Node 需要把NODE_EXTRA_CA_CERTS指向同一个ca.pem,Java 则通过JAVA_TOOL_OPTIONS使用truststore.p12。AWS_CA_BUNDLE会替换掉这个进程信任的证书,所以如果测试里还要调用真实的 HTTPS API,就需要一个合并后的证书包。 - 没有重置功能。 没有哪个管理路由能把它清空,所以要给每个测试用一个独立的 bucket 名,或者在两组测试之间重启模拟器。
- key 每次启动都会变。 别把它们抄进
.env文件,直接指向凭证文件。 - 如果你的应用自己用显式的 endpoint 创建 client(比如 django-storages 的
AWS_S3_ENDPOINT_URL),也要用AWS_ENDPOINT_URL来设置它,否则这一个 client 还是会连到真实的服务。
用 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: 30mkdir -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,里面放着这次运行的 key。
在 pytest 里使用 AWS Emulator#
测试本身没有任何专门针对模拟器的东西。我额外加的是一道保护,因为我担心的失败不是测试变红,而是测试变绿了,却是对着一个真实的 bucket 跑的:
# 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 的 runner 上,跳过 Docker,直接装 Debian 包。某一步里启动的后台进程,会在这个 job 余下的步骤里一直运行:
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 容器里跑了同样的步骤(用的是 arm64 包,因为我的 Mac 是 ARM)。安装花了 29 秒,几乎全花在下载那 150 MB 上,健康检查在 4 秒内就有了响应。这个 job 里干脆别放真实的 AWS 凭证。要确保里面没有任何东西连到 AWS,这是最省事的办法。
LTS/latest 指向的是最新发布的版本,下文会看到,它一天能变好几次。如果你希望 CI 可复现,AWS 有一页讲如何安装指定版本。至于流水线的其余部分我是怎么安排的,可以看我现在在用的 GitHub Actions 流水线。
从 LocalStack 迁移过来#
如果你的测试已经在用 LocalStack,迁移的大部分工作都是改配置:
| LocalStack | AWS Emulator | |
|---|---|---|
| Endpoint | http://localhost:4566 | https://127.0.0.1:9297 加上 AWS_CA_BUNDLE |
| 凭证 | 随便填(test / test) | 每次运行时它写出的文件,或者 --auth off |
| 账号 ID | 000000000000 | 000000000000 |
| 初始化脚本 | /etc/localstack/init/ready.d 里的 init hook | 没有:在健康检查之后,在 fixture 里创建资源 |
| 镜像 | localstack/localstack(4.12 之后需要 token) | 用 AWS 的安装包自己构建 |
如果你大量使用 init hook,这部分会是真正要花工夫的地方。我没怎么用,因为每个测试都会自己建 bucket。另外,重启之后什么都不会留下:模拟器把所有东西都放在内存里。
AWS Emulator 在我的 S3 本地测试里抓到了什么、漏掉了什么#
搭建是容易的部分。我真正想知道的是:在我的代码依赖 S3 行为的那些地方,它表现得像不像 S3。我写了 12 项检查,每一项都按 AWS 文档断言真实 S3 的行为:
- 六项来自交易平台应用的上传流程。 应用交给浏览器一个预签名 URL,浏览器把照片直接 PUT 进 bucket(所以 bucket 需要配 CORS),再由一个
ObjectCreated事件告诉后端文件到了。这就是我那篇讲对象通知的文章里的流程,只是用 SQS 代替了 webhook。 - 六项来自一个 Codemagic Patch 的 bucket。 Patch 是给 React Native 应用用的 OTA 更新服务器(我是怎么迁过去的)。它的 bucket 是公开的,好让手机下载 bundle;只有
_internal/前缀例外,上传的东西在那里等待发布,由一条 bucket 策略让这个前缀保持私有。
我把这些检查分别跑在了 AWS Emulator 1.0.342(默认配置一次,--auth off 一次)、LocalStack 4.12(默认配置,以及设置 S3_SKIP_SIGNATURE_VALIDATION=0 之后)、server 模式下的 Moto 5.2.6、Floci 1.5.5,以及 Silo 上。Silo 是 Patch 自托管套件所用的那个 MinIO 分支。
没有一种配置 12 项全过,成绩都在 6 到 8 项之间。总分不如各自漏掉了哪几项重要,所以下面按对我伤害的大小,从最严重的说起。
AWS Emulator 在权限上最严格。 拿我的 app 为一张 JPEG 签出的预签名 URL 来说:在默认配置的 LocalStack 4.12、Moto 和 Floci 上,我能用同一个 URL 上传一个 HTML 文件,还拿到 200。放在一个对外提供公开文件的 bucket 上,这就是一个随时会炸的存储型 XSS。AWS Emulator 和 S3 一样回了 403,URL 过期后也照样拒绝。(LocalStack 4.12 设置了 S3_SKIP_SIGNATURE_VALIDATION=0 之后两样都能拦住;Floci 能拦住过期,但拦不住被调包的文件类型。)也只有它知道新建的 S3 bucket 默认开着 Block Public Access,在你关掉之前公开的 bucket 策略会被拒绝;也只有它把显式的 Deny 用在了服务器自己签名的请求上,而不只是针对陌生人。恰恰是这类错误,会通过所有测试,然后在第一次真正部署时翻车。
浏览器那一侧的事情,它暂时还测不了。 CORS 预检(浏览器在跨源 PUT 之前发出的 OPTIONS 请求)会收到 501 Not Implemented。在默认开启认证的情况下,它还会拒绝所有未签名的请求,所以即使策略允许,从公开 bucket 匿名下载也会得到 403。这两个缺口都已经在仓库里登记了。这个项目把已知缺口以文件的形式放在 issues/things-that-do-not-work/ 下,11 月 13 日那天有 6,840 个,另有 13,409 个已经挪到了 things-that-now-work/。

--auth off 是拿这一半换那一半。 关掉认证后,匿名读取能用了,bucket 策略也能正确评估,所以 Patch 的六项检查全部通过,只有这一种配置做到了。但预签名 URL 也不再校验了,于是上传那一组只剩两项通过。在不同端口上跑两个模拟器,一个开认证、一个不开,就能把两半都覆盖到,只是下一个看 CI 文件的人,又多了一件得跟他解释的事。
它变得很快。 v1.0.290 到 v1.0.342 是三天之内发出来的,每个版本都带着一长串修复(AWS 说它是 coding agent 构建的,从这个节奏就看得出来),其中 11 月 11 日的一个修复就是 S3 的 “presigned GET refused with 403”。我表里的任何一项,到下周都可能变样,变好变坏都有可能。在 CI 里锁定一个版本,升级时有意识地去升。

Patch 的 bucket:一个只在真实 S3 上才起作用的策略条件#
这部分是我没想到的。Patch 的自托管套件把所有东西都放在一个 bucket 里,它给自带存储设置的策略也是最直观的那种:谁都能读,_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 都允许 worker 发布,同时照样拒绝对 _internal/ 的匿名读取。但只有 AWS Emulator 抓到了没加这一行的版本。Moto、LocalStack 4.12 和 Floci 不管有没有这一行都放行了复制,所以换成它们中的任何一个,我都会把有问题的策略发出去。要说为什么应该拿手头最严格的模拟器来测策略,这是我找到的最有力的理由。把 MinIO 环境里的策略搬到 S3 上又没加这个条件,在第一次真正发布之前,什么都不会报错。
(我自己的 Patch 服务器把 bundle 存在 Tigris 上,而 Tigris 根本没有 bucket 策略,所以我把 _internal/ 放在一个单独的私有 bucket 里,Patch 配置里的 S3_INTERNAL_BUCKET 就是干这个用的。另外,我也在给 Codemagic 写关于 Patch 的付费客座文章。这篇不是,也没有人为它付钱。)
现在我会用哪个#
| 就绪时间 | 空闲内存 | 镜像 | 账号 | |
|---|---|---|---|---|
| AWS Emulator 1.0.342 | 0.4–0.5 s | 176 MiB | 1.67 GB(需自己构建) | 不需要 |
| LocalStack 4.12 | 0.9–2.2 s | 90 MiB | 1.89 GB | 4.12 不需要,之后的版本要 token |
| Moto 5.2.6(server 模式) | 0.7–0.8 s | 45 MiB | 560 MB | 不需要 |
| Floci 1.5.5 | 0.3–0.4 s | 11 MiB | 292 MB | 不需要 |
在我的 Mac 上用 Docker 测的,每个都冷启动了三次。没有哪个慢到会成为问题。
后端测试我会从 AWS Emulator 开始,因为它能抓到的错误(权限、签名、Block Public Access)恰恰是不然只会在生产环境里冒出来的那些;而且在这份名单里,只有它同时做到了免费、持续更新、出自做 S3 的那拨人之手。在 CORS 预检实现之前,我会留一个小 job,在 Floci 上跑面向浏览器的那几项检查(预检,以及从公开前缀匿名下载)。开了严格签名的 LocalStack 4.12 也能通过这些,但它已经冻结,不会再有修复。如果你的测试是 Python 写的,又不想跑容器,Moto 的进程内 mock 仍然是最轻量的选择,只是别指望它来检查预签名 URL。
这 12 项检查都很短,一个下午就能针对你自己的 bucket 写出来。我建议在信任任何模拟器之前都先这么做,包括这一个。表里的每个工具没通过的检查都不一样,而那些正是你的测试会悄悄放过去的缺口。
