↓ 跳过正文

AWS Emulator LocalStack Moto

AWS Emulator 搭建教程:免费的 LocalStack 替代

用 Docker、Linux 和 GitHub Actions 搭起 AWS 官方免费模拟器,让 boto3 指向它,再看它在我的 S3 本地测试里抓到哪些 LocalStack 4.12 漏掉的问题。

AWS Emulator 搭建教程:免费的 LocalStack 替代

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"
在我的 Mac 上录下的整个搭建过程:启动、健康检查、模拟器的账号、一次 S3 上传,还有它目前做不到的那件事(CORS 预检返回 501,下文细说)。

它大约半秒就能就绪,空闲时占大约 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 仍然能启动。

不带 token 的 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 自己的版本,出自他们的 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/ 写四个文件:它的凭证、一个 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 都会用上它们,所以被测代码里既不用写 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: 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,里面放着这次运行的 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,迁移的大部分工作都是改配置:

LocalStackAWS Emulator
Endpointhttp://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 之后需要 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 项全过。

没有一种配置 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/。

GitHub 上 things-that-do-not-work 文件夹里 AWS Emulator 针对 S3 的 issue 文件,标题为 “7 net-new causes — CORS preflight answers 501…”
CORS 这个缺口,是项目自己负责找 issue 的 agent 在 11 月 9 日登记的。

--auth off 是拿这一半换那一半。 关掉认证后,匿名读取能用了,bucket 策略也能正确评估,所以 Patch 的六项检查全部通过,只有这一种配置做到了。但预签名 URL 也不再校验了,于是上传那一组只剩两项通过。在不同端口上跑两个模拟器,一个开认证、一个不开,就能把两半都覆盖到,只是下一个看 CI 文件的人,又多了一件得跟他解释的事。

它变得很快。 v1.0.290 到 v1.0.342 是三天之内发出来的,每个版本都带着一长串修复(AWS 说它是 coding agent 构建的,从这个节奏就看得出来),其中 11 月 11 日的一个修复就是 S3 的 “presigned GET refused with 403”。我表里的任何一项,到下周都可能变样,变好变坏都有可能。在 CI 里锁定一个版本,升级时有意识地去升。

GitHub 上 aws/aws-emulator 的 releases 页面:v1.0.342 标着 Latest,3 小时前发布,侧边栏里还列着另外九个版本
三天十个版本。锁定一个吧。

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.3420.4–0.5 s176 MiB1.67 GB(需自己构建)不需要
LocalStack 4.120.9–2.2 s90 MiB1.89 GB4.12 不需要,之后的版本要 token
Moto 5.2.6(server 模式)0.7–0.8 s45 MiB560 MB不需要
Floci 1.5.50.3–0.4 s11 MiB292 MB不需要

在我的 Mac 上用 Docker 测的,每个都冷启动了三次。没有哪个慢到会成为问题。

后端测试我会从 AWS Emulator 开始,因为它能抓到的错误(权限、签名、Block Public Access)恰恰是不然只会在生产环境里冒出来的那些;而且在这份名单里,只有它同时做到了免费、持续更新、出自做 S3 的那拨人之手。在 CORS 预检实现之前,我会留一个小 job,在 Floci 上跑面向浏览器的那几项检查(预检,以及从公开前缀匿名下载)。开了严格签名的 LocalStack 4.12 也能通过这些,但它已经冻结,不会再有修复。如果你的测试是 Python 写的,又不想跑容器,Moto 的进程内 mock 仍然是最轻量的选择,只是别指望它来检查预签名 URL。

这 12 项检查都很短,一个下午就能针对你自己的 bucket 写出来。我建议在信任任何模拟器之前都先这么做,包括这一个。表里的每个工具没通过的检查都不一样,而那些正是你的测试会悄悄放过去的缺口。

评论

评论经审核后显示。