<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>LocalStack · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/localstack/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Fri, 13 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/localstack/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>AWS Emulator 搭建教程：免费的 LocalStack 替代</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/aws-emulator-setup/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/aws-emulator-setup/</guid>
      <pubDate>Fri, 13 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>AWS Emulator</category>
      <category>LocalStack</category>
      <category>Moto</category>
      <category>Floci</category>
      <category>S3</category>
      <category>Testing</category>
      <category>Docker</category>
      <category>GitHub Actions</category>
      <category>Python</category>
      <category>Presigned URLs</category>
      <category>OTA Updates</category>
      <description>用 Docker、Linux 和 GitHub Actions 搭起 AWS 官方免费模拟器，让 boto3 指向它，再看它在我的 S3 本地测试里抓到哪些 LocalStack 4.12 漏掉的问题。</description>
      <content:encoded><![CDATA[<p>AWS 在 2026 年 11 月 11 日发布了 <a href="https://github.com/aws/aws-emulator"  target="_blank" rel="noreferrer">AWS Emulator</a>。它是 AWS API 的本地副本，跑在你自己的机器上：免费，不需要 AWS 账号，代码属于公有领域（CC0），覆盖 200 多个服务。距 LocalStack 开始要求当前镜像必须有账号和 auth token 才能运行，刚好过去七个月，所以很多人都把它看作自己一直在等的那个免费 LocalStack 替代。</p>
<p>这篇文章大部分是搭建步骤：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。</p>

<h2 class="relative group">一分钟看懂 AWS Emulator
    <div id="一分钟看懂-aws-emulator" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e5%88%86%e9%92%9f%e7%9c%8b%e6%87%82-aws-emulator" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>AWS 没有发布容器镜像。你要用一个九行的 Dockerfile 自己构建（它会安装官方的 Linux 安装包），在 9297 端口跑起来，再设几个环境变量。之后你的 SDK 连的就是模拟器而不是 AWS，代码一行都不用改：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker build -t aws-emulator .           <span class="c1"># the Dockerfile is below</span>
</span></span><span class="line"><span class="cl">mkdir -p ~/.aws/emulator
</span></span><span class="line"><span class="cl">docker run --rm -d --name aws-emulator -p 127.0.0.1:9297:9297 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -e <span class="nv">HOME</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">&#34;</span> -v <span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/.aws/emulator:</span><span class="nv">$HOME</span><span class="s2">/.aws/emulator&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  aws-emulator -p <span class="m">9297</span> --telemetry off
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_ENDPOINT_URL</span><span class="o">=</span>https://127.0.0.1:9297
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_SHARED_CREDENTIALS_FILE</span><span class="o">=</span>~/.aws/emulator/9297/emulator_credentials
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_CONFIG_FILE</span><span class="o">=</span>~/.aws/emulator/9297/config
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_CA_BUNDLE</span><span class="o">=</span>~/.aws/emulator/9297/ca.pem
</span></span><span class="line"><span class="cl">aws sts get-caller-identity              <span class="c1"># &#34;Account&#34;: &#34;000000000000&#34;</span></span></span></code></pre></div></div>
<figure><video
    class="my-0 rounded-md"
    autoplay loop muted playsinline preload="metadata"
    poster="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-setup.jpg" style="background:url('data:image/webp;base64,UklGRloAAABXRUJQVlA4IE4AAACQBACdASogABIAPw1yskwmIyIhKAqoxiGJ6QDG9BPdK4I2hDwC1QaVpF521UAA/ui2Gzha/NBVWRJqzq2oTgehY/P2cqAeQAizoM8AAAA=') center/cover no-repeat"
    width="1280"
    height="720"
    aria-label="终端：Dockerfile、启动 AWS Emulator 容器、健康检查返回 ok、aws sts get-caller-identity 返回账号 000000000000、一次 S3 上传，以及一个收到 HTTP 501 的 CORS 预检">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-setup.mp4" type="video/mp4">
  </video><figcaption>在我的 Mac 上录下的整个搭建过程：启动、健康检查、模拟器的账号、一次 S3 上传，还有它目前做不到的那件事（CORS 预检返回 501，下文细说）。</figcaption>
</figure>

<p>它大约半秒就能就绪，空闲时占大约 175 MiB 内存。所有数据都在内存里，所以容器一停就全没了。</p>

<h2 class="relative group">需要注册账号吗？（LocalStack 与 AWS Emulator 对比）
    <div id="需要注册账号吗localstack-与-aws-emulator-对比" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%9c%80%e8%a6%81%e6%b3%a8%e5%86%8c%e8%b4%a6%e5%8f%b7%e5%90%97localstack-%e4%b8%8e-aws-emulator-%e5%af%b9%e6%af%94" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>不需要。AWS Emulator、Moto 和 Floci 都不用注册任何东西就能跑。AWS Emulator 甚至不要你的 AWS 凭证：它每次启动都会自己生成一把 access key，并且只接受用这把 key 签名的请求。</p>
<p>变了的是 LocalStack。从 2026 年 3 月起，它的当前镜像需要一个 <code>LOCALSTACK_AUTH_TOKEN</code>，来自免费账号（免费套餐面向个人爱好和非商业用途）或付费账号。没有 token，容器在启动时就会停掉。两种我都试了：标签为 <code>latest</code> 的镜像（2026.10.7，11 月 11 日构建）以退出码 55 退出；而 4.12，也就是最后一个社区版、LocalStack 自己也建议锁定的版本，不带 token 仍然能启动。</p>
<figure><video
    class="my-0 rounded-md"
    autoplay loop muted playsinline preload="metadata"
    poster="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/localstack-needs-account.jpg" style="background:url('data:image/webp;base64,UklGRlYAAABXRUJQVlA4IEoAAABQBACdASogABIAPw10pUymIyGhKA1QxiGJ6QDJTPgAtfKr5WoM7/AUVx4AAP7ozNYDExNyKOZLAQjfzKlQvzdAcPxaBgAGLrjAAA==') center/cover no-repeat"
    width="1280"
    height="720"
    aria-label="终端：localstack/localstack:latest 报出 &#39;License activation failed&#39;，以退出码 55 退出；接着 localstack/localstack:4.12 启动，健康检查显示版本 4.12.0，S3 可用">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/localstack-needs-account.mp4" type="video/mp4">
  </video><figcaption>不带 token 的 LocalStack 当前镜像，然后是锁定的 4.12。锁定版本现在还能用，但 4.12 不会再有修复了。</figcaption>
</figure>

<p>AWS Emulator 确实想要的一样东西是遥测，而且默认开启。AWS 说遥测里绝不包含你的请求、代码或凭证，但如果你希望它什么都不发，有三个开关：命令行加 <code>--telemetry off</code>，环境变量设 <code>EMULATOR_TELEMETRY=off</code>，或者在 <code>~/.aws/emulator/config.toml</code> 里写上：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">telemetry</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">enabled</span> <span class="p">=</span> <span class="kc">false</span></span></span></code></pre></div></div>

<h2 class="relative group">在 macOS 或 Windows 上用 Docker 搭建 AWS Emulator
    <div id="在-macos-或-windows-上用-docker-搭建-aws-emulator" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9c%a8-macos-%e6%88%96-windows-%e4%b8%8a%e7%94%a8-docker-%e6%90%ad%e5%bb%ba-aws-emulator" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>在 Mac 或 Windows 上，走 Docker 这条路。在一个空文件夹里把下面的内容存成 <code>Dockerfile</code>。这是 AWS 自己的版本，出自<a href="https://docs.aws.amazon.com/emulator/latest/userguide/getting-started-docker.html"  target="_blank" rel="noreferrer">他们的 Docker 指南</a>：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">public.ecr.aws/amazonlinux/amazonlinux:2023-minimal</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">ADD</span> https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator-x86_64.rpm /tmp/aws-emulator.rpm<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">RUN</span> rpm -i /tmp/aws-emulator.rpm <span class="o">&amp;&amp;</span> rm /tmp/aws-emulator.rpm <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="o">&amp;&amp;</span> mkdir -p /home/emulator <span class="o">&amp;&amp;</span> chown <span class="m">1000</span> /home/emulator<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">USER</span><span class="w"> </span><span class="s">1000</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">ENV</span> <span class="nv">HOME</span><span class="o">=</span>/home/emulator<span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">EXPOSE</span><span class="w"> </span><span class="s">9297</span><span class="err">
</span></span></span><span class="line"><span class="cl"><span class="k">ENTRYPOINT</span> <span class="p">[</span><span class="s2">&#34;aws-emulator&#34;</span><span class="p">,</span> <span class="s2">&#34;-b&#34;</span><span class="p">,</span> <span class="s2">&#34;0.0.0.0&#34;</span><span class="p">]</span></span></span></code></pre></div></div>
<p>在 Apple 芯片或其他 ARM 机器上，把 <code>x86_64.rpm</code> 改成 <code>aarch64.rpm</code>。然后构建并启动：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker build -t aws-emulator .
</span></span><span class="line"><span class="cl">mkdir -p ~/.aws/emulator
</span></span><span class="line"><span class="cl">docker run --rm -d --name aws-emulator --memory<span class="o">=</span>2g --memory-swap<span class="o">=</span>2g <span class="se">\
</span></span></span><span class="line"><span class="cl">  -p 127.0.0.1:9297:9297 <span class="se">\
</span></span></span><span class="line"><span class="cl">  -e <span class="nv">HOME</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">&#34;</span> -v <span class="s2">&#34;</span><span class="nv">$HOME</span><span class="s2">/.aws/emulator:</span><span class="nv">$HOME</span><span class="s2">/.aws/emulator&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  aws-emulator -p <span class="m">9297</span> --telemetry off</span></span></code></pre></div></div>
<p>在我的 Mac 上构建花了 88 秒，镜像 1.67 GB。这行 <code>docker run</code> 里有三处比看上去重要：</p>
<ul>
<li><strong>用 <code>-p 127.0.0.1:9297:9297</code>，不要用 <code>-p 9297:9297</code>。</strong> 短的写法会把模拟器暴露给整个网络。</li>
<li><strong>卷挂载和 <code>-e HOME</code>。</strong> 启动时，模拟器会往 <code>~/.aws/emulator/9297/</code> 写四个文件：它的凭证、一个 AWS <code>config</code> 文件、它的证书（<code>ca.pem</code>），以及一个 Java 信任库。把这个文件夹以相同的路径挂进容器，宿主机上的 SDK 才读得到它们。</li>
<li><strong>端口号出现了三次。</strong> 要改就全部一起改。模拟器会把端口写进这些文件，两个共用同一个文件夹的容器会互相覆盖对方的文件。</li>
</ul>

<h2 class="relative group">在 Linux 上安装 AWS Emulator
    <div id="在-linux-上安装-aws-emulator" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9c%a8-linux-%e4%b8%8a%e5%ae%89%e8%a3%85-aws-emulator" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>在 Linux 上不需要 Docker，有现成的 Debian 包和 RPM 包：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Debian / Ubuntu (use aws-emulator_arm64.deb on ARM)</span>
</span></span><span class="line"><span class="cl">curl -fsSLo aws-emulator.deb https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator_amd64.deb
</span></span><span class="line"><span class="cl">sudo apt-get install -y ./aws-emulator.deb
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Amazon Linux, Fedora, RHEL (aarch64.rpm on ARM)</span>
</span></span><span class="line"><span class="cl">sudo dnf install -y https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator-x86_64.rpm
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">aws-emulator --telemetry off</span></span></code></pre></div></div>
<p>安装包 150 MB。它在前台运行，监听 <code>127.0.0.1:9297</code>，同样把那四个文件写到 <code>~/.aws/emulator/9297/</code>。按 Ctrl+C 就能干净地停掉它，文件也会一并删除。跑测试时加上 <code>--idle-timeout 900</code>，15 分钟没有请求它就会自己关掉，免得一个被忘掉的模拟器在那儿挂上一整周。</p>

<h2 class="relative group">让 AWS CLI 和 boto3 指向模拟器
    <div id="让-aws-cli-和-boto3-指向模拟器" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%ae%a9-aws-cli-%e5%92%8c-boto3-%e6%8c%87%e5%90%91%e6%a8%a1%e6%8b%9f%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>需要的环境变量就是这些。任何读取标准 AWS 环境变量的 SDK 都会用上它们，所以被测代码里既不用写 endpoint，也不用写 key：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">E</span><span class="o">=</span>~/.aws/emulator/9297
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_ENDPOINT_URL</span><span class="o">=</span>https://127.0.0.1:9297
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_SHARED_CREDENTIALS_FILE</span><span class="o">=</span><span class="nv">$E</span>/emulator_credentials
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_CONFIG_FILE</span><span class="o">=</span><span class="nv">$E</span>/config
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_CA_BUNDLE</span><span class="o">=</span><span class="nv">$E</span>/ca.pem
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_REGION</span><span class="o">=</span>us-east-1
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_EC2_METADATA_DISABLED</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">AWS_DISABLE_HOST_PREFIX_INJECTION</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nb">unset</span> AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">curl -s --cacert <span class="nv">$AWS_CA_BUNDLE</span> <span class="nv">$AWS_ENDPOINT_URL</span>/__admin/health   <span class="c1"># ok</span>
</span></span><span class="line"><span class="cl">aws sts get-caller-identity                                          <span class="c1"># 000000000000</span></span></span></code></pre></div></div>
<p>之后，最普通的 boto3 代码直接就能用：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">boto3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">s3</span> <span class="o">=</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span><span class="s2">&#34;s3&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">s3</span><span class="o">.</span><span class="n">create_bucket</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="s2">&#34;uploads&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">s3</span><span class="o">.</span><span class="n">list_buckets</span><span class="p">()[</span><span class="s2">&#34;Buckets&#34;</span><span class="p">])</span></span></span></code></pre></div></div>
<p><code>unset</code> 那一行一定要留着。环境变量的优先级高于凭证文件，所以如果你真实的 AWS key 还 export 着，SDK 就会用它们签名，模拟器拒绝请求，然后你白白花十分钟琢磨为什么。<code>aws sts get-caller-identity</code> 是个快速的自检：模拟器的账号永远是 <code>000000000000</code>。如果看到的是你自己的账号，说明你连的是 AWS。</p>
<p>其他几个我踩过、或者差点踩到的坑：</p>
<ul>
<li><strong>endpoint 里写 <code>127.0.0.1</code>，别写 <code>localhost</code>。</strong> 用 IP 地址时，SDK 会把 bucket 名放在路径里。用 <code>localhost</code> 时，JavaScript、Java 和 Go 的 SDK 会把它挪到主机名里（<code>uploads.localhost</code>），而模拟器不接受这种写法。</li>
<li><strong>只支持 HTTPS，而且每次启动都换一张新证书。</strong> boto3、AWS CLI 和 Go 会读 <code>AWS_CA_BUNDLE</code>。Node 需要把 <code>NODE_EXTRA_CA_CERTS</code> 指向同一个 <code>ca.pem</code>，Java 则通过 <code>JAVA_TOOL_OPTIONS</code> 使用 <code>truststore.p12</code>。<code>AWS_CA_BUNDLE</code> 会替换掉这个进程信任的证书，所以如果测试里还要调用真实的 HTTPS API，就需要一个合并后的证书包。</li>
<li><strong>没有重置功能。</strong> 没有哪个管理路由能把它清空，所以要给每个测试用一个独立的 bucket 名，或者在两组测试之间重启模拟器。</li>
<li><strong>key 每次启动都会变。</strong> 别把它们抄进 <code>.env</code> 文件，直接指向凭证文件。</li>
<li><strong>如果你的应用自己用显式的 endpoint 创建 client</strong>（比如 django-storages 的 <code>AWS_S3_ENDPOINT_URL</code>），也要用 <code>AWS_ENDPOINT_URL</code> 来设置它，否则这一个 client 还是会连到真实的服务。</li>
</ul>

<h2 class="relative group">用 Docker Compose 运行 AWS Emulator
    <div id="用-docker-compose-运行-aws-emulator" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%94%a8-docker-compose-%e8%bf%90%e8%a1%8c-aws-emulator" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果项目里已经有 <code>compose.yaml</code>，可以加上下面这个服务。它用上面的 Dockerfile 构建镜像，把模拟器的文件放在项目文件夹里而不是你的主目录，还带一个健康检查，所以 <code>docker compose up --wait</code> 会等它就绪后才返回：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">aws</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">./aws-emulator           </span><span class="w"> </span><span class="c"># the folder with the Dockerfile</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;-p&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;9297&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--telemetry&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;off&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;127.0.0.1:9297:9297&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HOME</span><span class="p">:</span><span class="w"> </span><span class="l">${PWD}/.aws-emulator-home</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./.aws-emulator-home/.aws/emulator:${PWD}/.aws-emulator-home/.aws/emulator</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">mem_limit</span><span class="p">:</span><span class="w"> </span><span class="l">2g</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-sf&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--cacert&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;${PWD}/.aws-emulator-home/.aws/emulator/9297/ca.pem&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">             </span><span class="s2">&#34;https://127.0.0.1:9297/__admin/health&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">1s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">30</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p .aws-emulator-home/.aws/emulator
</span></span><span class="line"><span class="cl">docker compose up -d --wait aws       <span class="c1"># healthy in about 5 s with the image already built</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">E</span><span class="o">=</span><span class="nv">$PWD</span>/.aws-emulator-home/.aws/emulator/9297   <span class="c1"># then the variables from above</span></span></span></code></pre></div></div>
<p>记得把 <code>.aws-emulator-home/</code> 加进 <code>.gitignore</code>，里面放着这次运行的 key。</p>

<h2 class="relative group">在 pytest 里使用 AWS Emulator
    <div id="在-pytest-里使用-aws-emulator" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9c%a8-pytest-%e9%87%8c%e4%bd%bf%e7%94%a8-aws-emulator" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>测试本身没有任何专门针对模拟器的东西。我额外加的是一道保护，因为我担心的失败不是测试变红，而是测试变绿了，却是对着一个真实的 bucket 跑的：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># conftest.py</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">uuid</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">boto3</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">pytest</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nd">@pytest.fixture</span><span class="p">(</span><span class="n">scope</span><span class="o">=</span><span class="s2">&#34;session&#34;</span><span class="p">,</span> <span class="n">autouse</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">only_against_the_emulator</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">url</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;AWS_ENDPOINT_URL&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">url</span><span class="o">.</span><span class="n">startswith</span><span class="p">((</span><span class="s2">&#34;https://127.0.0.1&#34;</span><span class="p">,</span> <span class="s2">&#34;http://127.0.0.1&#34;</span><span class="p">)):</span>
</span></span><span class="line"><span class="cl">        <span class="n">pytest</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;AWS_ENDPOINT_URL is </span><span class="si">{</span><span class="n">url</span><span class="si">!r}</span><span class="s2">; refusing to run against real AWS&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nd">@pytest.fixture</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">s3</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span><span class="s2">&#34;s3&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nd">@pytest.fixture</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">bucket</span><span class="p">(</span><span class="n">s3</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">name</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;test-</span><span class="si">{</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">()</span><span class="o">.</span><span class="n">hex</span><span class="p">[:</span><span class="mi">12</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>   <span class="c1"># no reset route, so never reuse a name</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3</span><span class="o">.</span><span class="n">create_bucket</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">name</span></span></span></code></pre></div></div>
<p>之后测试写起来就和平常一样：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">requests</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">test_presigned_upload_rejects_the_wrong_content_type</span><span class="p">(</span><span class="n">s3</span><span class="p">,</span> <span class="n">bucket</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">url</span> <span class="o">=</span> <span class="n">s3</span><span class="o">.</span><span class="n">generate_presigned_url</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;put_object&#34;</span><span class="p">,</span> <span class="n">ExpiresIn</span><span class="o">=</span><span class="mi">300</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">Params</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Bucket&#34;</span><span class="p">:</span> <span class="n">bucket</span><span class="p">,</span> <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="s2">&#34;media/a.jpg&#34;</span><span class="p">,</span> <span class="s2">&#34;ContentType&#34;</span><span class="p">:</span> <span class="s2">&#34;image/jpeg&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">r</span> <span class="o">=</span> <span class="n">requests</span><span class="o">.</span><span class="n">put</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="sa">b</span><span class="s2">&#34;&lt;html&gt;&#34;</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;text/html&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">                     <span class="n">verify</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;AWS_CA_BUNDLE&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">    <span class="k">assert</span> <span class="n">r</span><span class="o">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">403</span></span></span></code></pre></div></div>
<p>这个测试来自我那个交易平台应用的上传流程，它也很好地说明了为什么选哪个模拟器很重要：我试过的五个里，有三个对它返回 200。下文会细说。如果你每次改动只跑受影响的测试，<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-26-pytest-testmon/" >pytest-testmon</a> 不用任何修改就能配合这套用法。</p>

<h2 class="relative group">在 GitHub Actions 里运行 AWS Emulator
    <div id="在-github-actions-里运行-aws-emulator" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9c%a8-github-actions-%e9%87%8c%e8%bf%90%e8%a1%8c-aws-emulator" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>在 GitHub 的 runner 上，跳过 Docker，直接装 Debian 包。某一步里启动的后台进程，会在这个 job 余下的步骤里一直运行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">test</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Start AWS Emulator</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          curl -fsSLo aws-emulator.deb https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator_amd64.deb
</span></span></span><span class="line"><span class="cl"><span class="sd">          sudo apt-get install -y ./aws-emulator.deb
</span></span></span><span class="line"><span class="cl"><span class="sd">          aws-emulator --telemetry off --idle-timeout 900 &gt; emulator.log 2&gt;&amp;1 &amp;
</span></span></span><span class="line"><span class="cl"><span class="sd">          E=$HOME/.aws/emulator/9297
</span></span></span><span class="line"><span class="cl"><span class="sd">          for i in $(seq 30); do
</span></span></span><span class="line"><span class="cl"><span class="sd">            curl -sf --cacert $E/ca.pem https://127.0.0.1:9297/__admin/health &amp;&amp; break
</span></span></span><span class="line"><span class="cl"><span class="sd">            sleep 1
</span></span></span><span class="line"><span class="cl"><span class="sd">          done
</span></span></span><span class="line"><span class="cl"><span class="sd">          {
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;AWS_ENDPOINT_URL=https://127.0.0.1:9297&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;AWS_SHARED_CREDENTIALS_FILE=$E/emulator_credentials&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;AWS_CONFIG_FILE=$E/config&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;AWS_CA_BUNDLE=$E/ca.pem&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;AWS_REGION=us-east-1&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;AWS_EC2_METADATA_DISABLED=true&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;AWS_DISABLE_HOST_PREFIX_INJECTION=true&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          } &gt;&gt; &#34;$GITHUB_ENV&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run pytest</span></span></span></code></pre></div></div>
<p>我在一个全新的 <code>ubuntu:24.04</code> 容器里跑了同样的步骤（用的是 arm64 包，因为我的 Mac 是 ARM）。安装花了 29 秒，几乎全花在下载那 150 MB 上，健康检查在 4 秒内就有了响应。这个 job 里干脆别放真实的 AWS 凭证。要确保里面没有任何东西连到 AWS，这是最省事的办法。</p>
<p><code>LTS/latest</code> 指向的是最新发布的版本，下文会看到，它一天能变好几次。如果你希望 CI 可复现，AWS 有一页讲<a href="https://docs.aws.amazon.com/emulator/latest/userguide/versioning.html"  target="_blank" rel="noreferrer">如何安装指定版本</a>。至于流水线的其余部分我是怎么安排的，可以看<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-docker-compose-ci-github-actions/" >我现在在用的 GitHub Actions 流水线</a>。</p>

<h2 class="relative group">从 LocalStack 迁移过来
    <div id="从-localstack-迁移过来" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bb%8e-localstack-%e8%bf%81%e7%a7%bb%e8%bf%87%e6%9d%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果你的测试已经在用 LocalStack，迁移的大部分工作都是改配置：</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>LocalStack</th>
					<th>AWS Emulator</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Endpoint</td>
					<td><code>http://localhost:4566</code></td>
					<td><code>https://127.0.0.1:9297</code> 加上 <code>AWS_CA_BUNDLE</code></td>
			</tr>
			<tr>
					<td>凭证</td>
					<td>随便填（<code>test</code> / <code>test</code>）</td>
					<td>每次运行时它写出的文件，或者 <code>--auth off</code></td>
			</tr>
			<tr>
					<td>账号 ID</td>
					<td><code>000000000000</code></td>
					<td><code>000000000000</code></td>
			</tr>
			<tr>
					<td>初始化脚本</td>
					<td><code>/etc/localstack/init/ready.d</code> 里的 init hook</td>
					<td>没有：在健康检查之后，在 fixture 里创建资源</td>
			</tr>
			<tr>
					<td>镜像</td>
					<td><code>localstack/localstack</code>（4.12 之后需要 token）</td>
					<td>用 AWS 的安装包自己构建</td>
			</tr>
	</tbody>
</table>
<p>如果你大量使用 init hook，这部分会是真正要花工夫的地方。我没怎么用，因为每个测试都会自己建 bucket。另外，重启之后什么都不会留下：模拟器把所有东西都放在内存里。</p>

<h2 class="relative group">AWS Emulator 在我的 S3 本地测试里抓到了什么、漏掉了什么
    <div id="aws-emulator-在我的-s3-本地测试里抓到了什么漏掉了什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#aws-emulator-%e5%9c%a8%e6%88%91%e7%9a%84-s3-%e6%9c%ac%e5%9c%b0%e6%b5%8b%e8%af%95%e9%87%8c%e6%8a%93%e5%88%b0%e4%ba%86%e4%bb%80%e4%b9%88%e6%bc%8f%e6%8e%89%e4%ba%86%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>搭建是容易的部分。我真正想知道的是：在我的代码依赖 S3 行为的那些地方，它表现得像不像 S3。我写了 12 项检查，每一项都按 AWS 文档断言真实 S3 的行为：</p>
<ul>
<li><strong>六项来自交易平台应用的上传流程。</strong> 应用交给浏览器一个预签名 URL，浏览器把照片直接 PUT 进 bucket（所以 bucket 需要配 CORS），再由一个 <code>ObjectCreated</code> 事件告诉后端文件到了。这就是<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/tigris-object-notifications/" >我那篇讲对象通知的文章</a>里的流程，只是用 SQS 代替了 webhook。</li>
<li><strong>六项来自一个 Codemagic Patch 的 bucket。</strong> Patch 是给 React Native 应用用的 OTA 更新服务器（<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/" >我是怎么迁过去的</a>）。它的 bucket 是公开的，好让手机下载 bundle；只有 <code>_internal/</code> 前缀例外，上传的东西在那里等待发布，由一条 bucket 策略让这个前缀保持私有。</li>
</ul>
<p>我把这些检查分别跑在了 AWS Emulator 1.0.342（默认配置一次，<code>--auth off</code> 一次）、LocalStack 4.12（默认配置，以及设置 <code>S3_SKIP_SIGNATURE_VALIDATION=0</code> 之后）、server 模式下的 Moto 5.2.6、Floci 1.5.5，以及 Silo 上。Silo 是 Patch 自托管套件所用的那个 MinIO 分支。</p>
<figure><video
    class="my-0 rounded-md"
    autoplay loop muted playsinline preload="metadata"
    poster="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/s3-emulator-test-run.jpg" style="background:url('data:image/webp;base64,UklGRk4AAABXRUJQVlA4IEIAAAAQBACdASogABIAPw10rkymIyIhKAqoxiGJ6QDM0PgAtf/5Tl8J29EIQAD+6MzcUMCFo8DULSYQXf8FTN9rmZMAAAA=') center/cover no-repeat"
    width="1280"
    height="720"
    aria-label="终端：一个脚本对七种模拟器配置跑同样的 12 项 S3 检查，并打印出通过/失败矩阵；每种配置最终都是 12 项中通过 6 到 8 项">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/s3-emulator-test-run.mp4" type="video/mp4">
  </video><figcaption>真实的运行结果。没有一个 12 项全过。</figcaption>
</figure>

<p>没有一种配置 12 项全过，成绩都在 6 到 8 项之间。总分不如各自漏掉了哪几项重要，所以下面按对我伤害的大小，从最严重的说起。</p>
<p><strong>AWS Emulator 在权限上最严格。</strong> 拿我的 app 为一张 JPEG 签出的预签名 URL 来说：在默认配置的 LocalStack 4.12、Moto 和 Floci 上，我能用同一个 URL 上传一个 HTML 文件，还拿到 200。放在一个对外提供公开文件的 bucket 上，这就是一个随时会炸的存储型 XSS。AWS Emulator 和 S3 一样回了 403，URL 过期后也照样拒绝。（LocalStack 4.12 设置了 <code>S3_SKIP_SIGNATURE_VALIDATION=0</code> 之后两样都能拦住；Floci 能拦住过期，但拦不住被调包的文件类型。）也只有它知道新建的 S3 bucket 默认开着 Block Public Access，在你关掉之前公开的 bucket 策略会被拒绝；也只有它把显式的 <code>Deny</code> 用在了服务器自己签名的请求上，而不只是针对陌生人。恰恰是这类错误，会通过所有测试，然后在第一次真正部署时翻车。</p>
<p><strong>浏览器那一侧的事情，它暂时还测不了。</strong> CORS 预检（浏览器在跨源 PUT 之前发出的 <code>OPTIONS</code> 请求）会收到 <code>501 Not Implemented</code>。在默认开启认证的情况下，它还会拒绝所有未签名的请求，所以即使策略允许，从公开 bucket 匿名下载也会得到 403。这两个缺口都已经在仓库里登记了。这个项目把已知缺口以文件的形式放在 <code>issues/things-that-do-not-work/</code> 下，11 月 13 日那天有 6,840 个，另有 13,409 个已经挪到了 <code>things-that-now-work/</code>。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="GitHub 上 things-that-do-not-work 文件夹里 AWS Emulator 针对 S3 的 issue 文件，标题为 &ldquo;7 net-new causes — CORS preflight answers 501…&rdquo;"
    width="1400"
    height="804"
    src="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-cors-preflight-issue_hu_33033b091b284c0d.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-cors-preflight-issue_hu_33033b091b284c0d.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-cors-preflight-issue_hu_f0294a8a0a022038.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-cors-preflight-issue_hu_925176ba1af6cb05.webp"></span><figcaption>CORS 这个缺口，是项目自己负责找 issue 的 agent 在 11 月 9 日登记的。</figcaption></figure>
</p>
<p><strong><code>--auth off</code> 是拿这一半换那一半。</strong> 关掉认证后，匿名读取能用了，bucket 策略也能正确评估，所以 Patch 的六项检查全部通过，只有这一种配置做到了。但预签名 URL 也不再校验了，于是上传那一组只剩两项通过。在不同端口上跑两个模拟器，一个开认证、一个不开，就能把两半都覆盖到，只是下一个看 CI 文件的人，又多了一件得跟他解释的事。</p>
<p><strong>它变得很快。</strong> v1.0.290 到 v1.0.342 是三天之内发出来的，每个版本都带着一长串修复（AWS 说它是 coding agent 构建的，从这个节奏就看得出来），其中 11 月 11 日的一个修复就是 S3 的 &ldquo;presigned GET refused with 403&rdquo;。我表里的任何一项，到下周都可能变样，变好变坏都有可能。在 CI 里锁定一个版本，升级时有意识地去升。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="GitHub 上 aws/aws-emulator 的 releases 页面：v1.0.342 标着 Latest，3 小时前发布，侧边栏里还列着另外九个版本"
    width="1600"
    height="1125"
    src="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-releases_hu_e39a0eec6b783a7a.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-releases_hu_e39a0eec6b783a7a.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-releases_hu_df197ee02b7c435c.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-releases_hu_59bd3abd9b9aaf92.webp"></span><figcaption>三天十个版本。锁定一个吧。</figcaption></figure>
</p>

<h2 class="relative group">Patch 的 bucket：一个只在真实 S3 上才起作用的策略条件
    <div id="patch-的-bucket一个只在真实-s3-上才起作用的策略条件" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#patch-%e7%9a%84-bucket%e4%b8%80%e4%b8%aa%e5%8f%aa%e5%9c%a8%e7%9c%9f%e5%ae%9e-s3-%e4%b8%8a%e6%89%8d%e8%b5%b7%e4%bd%9c%e7%94%a8%e7%9a%84%e7%ad%96%e7%95%a5%e6%9d%a1%e4%bb%b6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这部分是我没想到的。Patch 的自托管套件把所有东西都放在一个 bucket 里，它给自带存储设置的策略也是最直观的那种：谁都能读，<code>_internal/</code> 下面除外：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;Version&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-10-17&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;Statement&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Allow&#34;</span><span class="p">,</span> <span class="nt">&#34;Principal&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span><span class="p">,</span> <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="s2">&#34;s3:GetObject&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;arn:aws:s3:::example-ota/*&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span> <span class="nt">&#34;Principal&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span><span class="p">,</span> <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="s2">&#34;s3:GetObject&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;arn:aws:s3:::example-ota/_internal/*&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>在 Silo 上用管理员凭证签名时，它的行为和字面意思一致：陌生人读不了 <code>_internal/</code>，服务器自己把文件从 <code>_internal/</code> 复制到公开前缀（也就是&quot;发布&quot;这一步）也能顺利完成。但在真实的 S3 上，带 <code>&quot;Principal&quot;: &quot;*&quot;</code> 的显式 <code>Deny</code> 对所有人生效，包括你自己的凭证，所以同一条策略会挡住服务器自己的发布步骤。AWS Emulator 用 403 拒绝了这次复制，按 AWS 的策略评估规则，S3 正是这么做的。</p>
<p>Patch 的维护者显然知道这一点，因为他们<a href="https://github.com/codemagic-ci-cd/codemagic-patch/blob/main/deploy/selfhost/aws-s3-bucket-policy.example.json"  target="_blank" rel="noreferrer">给 AWS S3 的示例策略</a>在 <code>Deny</code> 上多加了一个条件：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;Condition&#34;</span><span class="err">:</span> <span class="p">{</span> <span class="nt">&#34;StringNotEquals&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;aws:PrincipalAccount&#34;</span><span class="p">:</span> <span class="s2">&#34;YOUR_AWS_ACCOUNT_ID&#34;</span> <span class="p">}</span> <span class="p">}</span></span></span></code></pre></div></div>
<p>加上这一行之后，AWS Emulator（开着 <code>--auth off</code>，这样匿名那一半也能测）和 Moto 都允许 worker 发布，同时照样拒绝对 <code>_internal/</code> 的匿名读取。但只有 AWS Emulator 抓到了没加这一行的版本。Moto、LocalStack 4.12 和 Floci 不管有没有这一行都放行了复制，所以换成它们中的任何一个，我都会把有问题的策略发出去。要说为什么应该拿手头最严格的模拟器来测策略，这是我找到的最有力的理由。把 MinIO 环境里的策略搬到 S3 上又没加这个条件，在第一次真正发布之前，什么都不会报错。</p>
<p>（我自己的 Patch 服务器把 bundle 存在 Tigris 上，而 Tigris 根本没有 bucket 策略，所以我把 <code>_internal/</code> 放在一个单独的私有 bucket 里，Patch 配置里的 <code>S3_INTERNAL_BUCKET</code> 就是干这个用的。另外，我也在给 Codemagic 写关于 Patch 的付费客座文章。这篇不是，也没有人为它付钱。）</p>

<h2 class="relative group">现在我会用哪个
    <div id="现在我会用哪个" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%8e%b0%e5%9c%a8%e6%88%91%e4%bc%9a%e7%94%a8%e5%93%aa%e4%b8%aa" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>就绪时间</th>
					<th>空闲内存</th>
					<th>镜像</th>
					<th>账号</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>AWS Emulator 1.0.342</td>
					<td>0.4–0.5 s</td>
					<td>176 MiB</td>
					<td>1.67 GB（需自己构建）</td>
					<td>不需要</td>
			</tr>
			<tr>
					<td>LocalStack 4.12</td>
					<td>0.9–2.2 s</td>
					<td>90 MiB</td>
					<td>1.89 GB</td>
					<td>4.12 不需要，之后的版本要 token</td>
			</tr>
			<tr>
					<td>Moto 5.2.6（server 模式）</td>
					<td>0.7–0.8 s</td>
					<td>45 MiB</td>
					<td>560 MB</td>
					<td>不需要</td>
			</tr>
			<tr>
					<td>Floci 1.5.5</td>
					<td>0.3–0.4 s</td>
					<td>11 MiB</td>
					<td>292 MB</td>
					<td>不需要</td>
			</tr>
	</tbody>
</table>
<p>在我的 Mac 上用 Docker 测的，每个都冷启动了三次。没有哪个慢到会成为问题。</p>
<p>后端测试我会从 AWS Emulator 开始，因为它能抓到的错误（权限、签名、Block Public Access）恰恰是不然只会在生产环境里冒出来的那些；而且在这份名单里，只有它同时做到了免费、持续更新、出自做 S3 的那拨人之手。在 CORS 预检实现之前，我会留一个小 job，在 Floci 上跑面向浏览器的那几项检查（预检，以及从公开前缀匿名下载）。开了严格签名的 LocalStack 4.12 也能通过这些，但它已经冻结，不会再有修复。如果你的测试是 Python 写的，又不想跑容器，Moto 的进程内 mock 仍然是最轻量的选择，只是别指望它来检查预签名 URL。</p>
<p>这 12 项检查都很短，一个下午就能针对你自己的 bucket 写出来。我建议在信任任何模拟器之前都先这么做，包括这一个。表里的每个工具没通过的检查都不一样，而那些正是你的测试会悄悄放过去的缺口。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
