<?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>AWS Emulator · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/aws-emulator/</link>
    <description>Những câu chuyện ngắn về kỹ thuật phần mềm và lãnh đạo đội ngũ kỹ sư — performance review, tuyển dụng và mở rộng team, CI/CD, giám sát DevOps và AI agent. Viết từ Seoul.</description>
    <generator>Hugo</generator>
    <language>vi</language>
    <copyright>© 2026 Nhân Tài Đức</copyright>
    <lastBuildDate>Fri, 13 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/aws-emulator/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Cài đặt AWS Emulator: lựa chọn thay thế LocalStack miễn phí</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/aws-emulator-setup/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Cài AWS Emulator miễn phí bằng Docker, trên Linux và GitHub Actions, trỏ boto3 vào, rồi xem nó bắt được lỗi gì khi kiểm thử S3 local mà LocalStack 4.12 bỏ sót.</description>
      <content:encoded><![CDATA[<p>Ngày 11 tháng 11 năm 2026, AWS phát hành <a href="https://github.com/aws/aws-emulator"  target="_blank" rel="noreferrer">AWS Emulator</a>. Đây là một bản sao các API của AWS, chạy ngay trên máy của bạn (local). Nó miễn phí, không cần tài khoản AWS, mã nguồn thuộc phạm vi công cộng (public domain, giấy phép CC0), và hỗ trợ hơn 200 dịch vụ. Nó ra mắt bảy tháng sau khi LocalStack bắt đầu yêu cầu tài khoản và auth token cho image hiện hành của họ, nên nhiều người đang xem nó là lựa chọn thay thế LocalStack miễn phí mà họ chờ đợi bấy lâu.</p>
<p>Phần lớn bài này là hướng dẫn cài đặt: Docker trên máy Mac hoặc Windows, gói cài cho Linux, các biến môi trường để AWS CLI và boto3 gọi tới nó, Docker Compose, pytest và GitHub Actions. Mọi lệnh trong bài tôi đều tự chạy thử, trên phiên bản 1.0.342. Sau phần cài đặt là những gì nó làm đúng và làm sai khi tôi cho các bài kiểm thử (test) S3 của hai dự án của chính mình chạy trên nó, đặt cạnh LocalStack 4.12, Moto và Floci. Hai dự án đó là luồng upload trong ứng dụng marketplace của tôi, và bucket mà một server Codemagic Patch tự host (self-hosted) dùng để lưu các bundle cập nhật OTA.</p>

<h2 class="relative group">AWS Emulator là gì? Tóm tắt trong một phút
    <div id="aws-emulator-là-gì-tóm-tắt-trong-một-phút" 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-l%c3%a0-g%c3%ac-t%c3%b3m-t%e1%ba%aft-trong-m%e1%bb%99t-ph%c3%bat" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>AWS không phát hành sẵn container image. Bạn tự build image từ một Dockerfile chín dòng có nhiệm vụ cài gói Linux của họ, chạy nó trên cổng 9297, rồi đặt vài biến môi trường. Từ đó trở đi, SDK của bạn nói chuyện với trình giả lập (emulator) thay vì với AWS thật, còn code của bạn thì không phải sửa gì:</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="Terminal: Dockerfile, khởi động container AWS Emulator, health check trả về ok, aws sts get-caller-identity trả về tài khoản 000000000000, một lần upload lên S3, và một request CORS preflight nhận về HTTP 501">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/aws-emulator-setup.mp4" type="video/mp4">
  </video><figcaption>Toàn bộ quá trình cài đặt, quay trên máy Mac của tôi: khởi động, health check, tài khoản của trình giả lập, một lần upload lên S3, và thứ duy nhất nó chưa làm được (CORS preflight trả về 501, sẽ nói thêm ở bên dưới).</figcaption>
</figure>

<p>Nó sẵn sàng sau khoảng nửa giây, và lúc rảnh chỉ chiếm khoảng 175 MiB bộ nhớ. Mọi dữ liệu đều nằm trong bộ nhớ, nên dừng container là mất sạch.</p>

<h2 class="relative group">Có cần tài khoản không? (LocalStack vs AWS Emulator)
    <div id="có-cần-tài-khoản-không-localstack-vs-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="#c%c3%b3-c%e1%ba%a7n-t%c3%a0i-kho%e1%ba%a3n-kh%c3%b4ng-localstack-vs-aws-emulator" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Không. AWS Emulator, Moto và Floci đều chạy được mà không cần đăng ký gì cả. AWS Emulator thậm chí còn không cần thông tin xác thực (credentials) AWS của bạn: mỗi lần khởi động, nó tự tạo một access key riêng và chỉ chấp nhận những request được ký bằng key đó.</p>
<p>Bên đã thay đổi là LocalStack. Từ tháng 3 năm 2026, image hiện hành của họ cần một <code>LOCALSTACK_AUTH_TOKEN</code>, lấy từ một tài khoản miễn phí (gói miễn phí của họ nhắm tới việc dùng cho sở thích cá nhân và mục đích phi thương mại) hoặc từ tài khoản trả phí. Không có token thì container dừng ngay lúc khởi động. Tôi đã thử cả hai: image gắn tag <code>latest</code> (2026.10.7, build ngày 11 tháng 11) thoát với exit code 55, còn bản 4.12, bản phát hành cộng đồng (community) cuối cùng và cũng là phiên bản mà chính LocalStack khuyên nên ghim cố định (pin), thì vẫn khởi động được mà không cần 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="Terminal: localstack/localstack:latest thoát với thông báo &#39;License activation failed&#39; và exit code 55, sau đó localstack/localstack:4.12 khởi động và health check báo phiên bản 4.12.0 với S3 sẵn sàng">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/localstack-needs-account.mp4" type="video/mp4">
  </video><figcaption>Image hiện hành của LocalStack khi không có token, rồi đến bản 4.12 đã ghim. Hiện tại ghim phiên bản vẫn chạy được, nhưng 4.12 sẽ không nhận thêm bản sửa lỗi nào nữa.</figcaption>
</figure>

<p>Có một thứ AWS Emulator muốn lấy của bạn, đó là telemetry (dữ liệu đo lường sử dụng), được bật sẵn theo mặc định. AWS nói rằng dữ liệu này không bao giờ chứa request, code hay credentials của bạn, nhưng nếu bạn muốn nó không gửi gì cả thì có ba cách tắt: <code>--telemetry off</code> trên dòng lệnh, <code>EMULATOR_TELEMETRY=off</code> trong biến môi trường, hoặc đoạn sau trong <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">Cài đặt AWS Emulator bằng Docker trên macOS hoặc Windows
    <div id="cài-đặt-aws-emulator-bằng-docker-trên-macos-hoặc-windows" 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="#c%c3%a0i-%c4%91%e1%ba%b7t-aws-emulator-b%e1%ba%b1ng-docker-tr%c3%aan-macos-ho%e1%ba%b7c-windows" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trên máy Mac hoặc Windows thì Docker là con đường phù hợp. Lưu nội dung sau thành file <code>Dockerfile</code> trong một thư mục trống. Đây là Dockerfile do chính AWS cung cấp, lấy từ <a href="https://docs.aws.amazon.com/emulator/latest/userguide/getting-started-docker.html"  target="_blank" rel="noreferrer">hướng dẫn Docker của họ</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>Nếu dùng chip Apple silicon hay bất kỳ máy ARM nào khác, hãy đổi <code>x86_64.rpm</code> thành <code>aarch64.rpm</code>. Sau đó build và khởi động:</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>Trên máy Mac của tôi, bước build mất 88 giây và image nặng 1.67 GB. Có ba phần trong dòng <code>docker run</code> đó quan trọng hơn vẻ ngoài của chúng:</p>
<ul>
<li><strong><code>-p 127.0.0.1:9297:9297</code>, không phải <code>-p 9297:9297</code>.</strong> Cách viết ngắn sẽ mở trình giả lập ra cho cả mạng của bạn truy cập.</li>
<li><strong>Volume và <code>-e HOME</code>.</strong> Lúc khởi động, trình giả lập ghi bốn file vào <code>~/.aws/emulator/9297/</code>: credentials của nó, một file <code>config</code> của AWS, chứng chỉ của nó (<code>ca.pem</code>), và một Java trust store. Mount thư mục đó vào đúng cùng đường dẫn bên trong container chính là cách để SDK trên máy host đọc được các file này.</li>
<li><strong>Số cổng xuất hiện ba lần.</strong> Nếu đổi cổng thì phải đổi ở mọi chỗ. Trình giả lập ghi số cổng vào các file đó, và hai container dùng chung một thư mục sẽ ghi đè file của nhau.</li>
</ul>

<h2 class="relative group">Cài AWS Emulator trên Linux
    <div id="cài-aws-emulator-trên-linux" 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="#c%c3%a0i-aws-emulator-tr%c3%aan-linux" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trên Linux thì không cần Docker. Có sẵn gói Debian và gói 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>Gói cài nặng 150 MB. Nó chạy ở tiền cảnh (foreground) trên <code>127.0.0.1:9297</code> và ghi đúng bốn file như trên vào <code>~/.aws/emulator/9297/</code>. Nhấn Ctrl+C sẽ dừng nó một cách gọn gàng và xóa luôn các file đó. Khi chạy kiểm thử, hãy thêm <code>--idle-timeout 900</code> để nó tự tắt sau 15 phút không nhận request nào, như vậy một trình giả lập bị bỏ quên sẽ không nằm chạy suốt cả tuần.</p>

<h2 class="relative group">Trỏ AWS CLI và boto3 vào trình giả lập
    <div id="trỏ-aws-cli-và-boto3-vào-trình-giả-lập" 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="#tr%e1%bb%8f-aws-cli-v%c3%a0-boto3-v%c3%a0o-tr%c3%acnh-gi%e1%ba%a3-l%e1%ba%adp" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là các biến cần đặt. SDK nào đọc biến môi trường AWS chuẩn cũng sẽ tự nhận chúng, nên code đang được kiểm thử không cần khai báo endpoint hay key nào:</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>Sau đó, boto3 không cần cấu hình gì thêm cũng chạy được:</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>Dòng <code>unset</code> là dòng đừng bỏ. Biến môi trường được ưu tiên hơn file credentials, nên nếu key AWS thật của bạn vẫn còn được export trong shell thì SDK sẽ ký request bằng key đó, trình giả lập từ chối request, và bạn mất mười phút ngồi thắc mắc tại sao. <code>aws sts get-caller-identity</code> là cách kiểm tra nhanh: trình giả lập luôn là tài khoản <code>000000000000</code>. Nếu bạn thấy số tài khoản của chính mình, tức là bạn đang nói chuyện với AWS thật.</p>
<p>Vài thứ khác tôi đã vấp phải, hoặc suýt vấp:</p>
<ul>
<li><strong>Giữ <code>127.0.0.1</code> trong endpoint, đừng dùng <code>localhost</code>.</strong> Với địa chỉ IP, các SDK đặt tên bucket trong đường dẫn (path). Với <code>localhost</code>, SDK cho JavaScript, Java và Go lại chuyển tên bucket vào host name (<code>uploads.localhost</code>), và trình giả lập không chấp nhận kiểu đó.</li>
<li><strong>Chỉ có HTTPS, và mỗi lần khởi động là một chứng chỉ mới.</strong> boto3, AWS CLI và SDK cho Go đọc <code>AWS_CA_BUNDLE</code>. Node cần <code>NODE_EXTRA_CA_CERTS</code> trỏ tới cùng file <code>ca.pem</code> đó, còn Java dùng <code>truststore.p12</code> thông qua <code>JAVA_TOOL_OPTIONS</code>. <code>AWS_CA_BUNDLE</code> thay thế toàn bộ danh sách chứng chỉ tin cậy của tiến trình (process) đó, nên một bài kiểm thử mà còn gọi tới một API HTTPS thật thì cần một bundle gộp cả hai lại.</li>
<li><strong>Không có cách reset.</strong> Không có route quản trị nào để xóa sạch dữ liệu, nên hãy cho mỗi bài kiểm thử một tên bucket riêng, hoặc khởi động lại trình giả lập giữa các bộ kiểm thử (test suite).</li>
<li><strong>Key thay đổi sau mỗi lần khởi động.</strong> Đừng chép chúng vào file <code>.env</code>. Hãy trỏ tới file credentials.</li>
<li><strong>Nếu ứng dụng của bạn tự tạo client với endpoint được khai báo cụ thể</strong> (ví dụ <code>AWS_S3_ENDPOINT_URL</code> của django-storages), hãy lấy giá trị đó từ <code>AWS_ENDPOINT_URL</code> luôn, nếu không thì riêng client đó vẫn tiếp tục gọi tới dịch vụ thật.</li>
</ul>

<h2 class="relative group">Chạy AWS Emulator bằng Docker Compose
    <div id="chạy-aws-emulator-bằng-docker-compose" 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="#ch%e1%ba%a1y-aws-emulator-b%e1%ba%b1ng-docker-compose" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với dự án đã có sẵn <code>compose.yaml</code>, service dưới đây build image từ Dockerfile ở trên, lưu các file của trình giả lập trong thư mục dự án thay vì thư mục home của bạn, và có health check, nên <code>docker compose up --wait</code> chỉ trả về khi nó đã sẵn sàng:</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>Nhớ thêm <code>.aws-emulator-home/</code> vào <code>.gitignore</code>. Thư mục này chứa key của lần chạy đó.</p>

<h2 class="relative group">Dùng AWS Emulator với pytest để kiểm thử S3 local
    <div id="dùng-aws-emulator-với-pytest-để-kiểm-thử-s3-local" 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="#d%c3%b9ng-aws-emulator-v%e1%bb%9bi-pytest-%c4%91%e1%bb%83-ki%e1%bb%83m-th%e1%bb%ad-s3-local" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bản thân các bài kiểm thử không có gì dành riêng cho trình giả lập. Thứ tôi thêm vào là một lớp bảo vệ, vì kiểu thất bại tôi lo nhất không phải là một test đỏ (fail), mà là một test xanh (pass) nhưng thực ra đã chạy trên một bucket thật:</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>Khi đó, một bài kiểm thử trông cũng như mọi bài khác:</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>Bài này lấy từ luồng upload của ứng dụng marketplace của tôi, và nó là ví dụ tốt cho thấy vì sao chọn trình giả lập nào lại quan trọng: ba trong năm công cụ tôi thử trả về 200 cho request này. Phần sau sẽ nói kỹ hơn. Nếu mỗi lần thay đổi code bạn chỉ chạy những bài kiểm thử bị ảnh hưởng, thì <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-26-pytest-testmon/" >pytest-testmon</a> vẫn dùng được với thiết lập này mà không phải sửa gì.</p>

<h2 class="relative group">Chạy AWS Emulator trên GitHub Actions
    <div id="chạy-aws-emulator-trên-github-actions" 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="#ch%e1%ba%a1y-aws-emulator-tr%c3%aan-github-actions" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trên runner của GitHub, bỏ qua Docker và cài thẳng gói Debian. Tiến trình chạy nền được khởi động ở một step sẽ tiếp tục chạy cho tới hết 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>Tôi đã chạy đúng các bước này trong một container <code>ubuntu:24.04</code> mới tinh (dùng gói arm64, vì máy Mac của tôi chạy ARM). Bước cài đặt mất 29 giây, gần như toàn bộ là thời gian tải 150 MB, và health check trả lời trong vòng 4 giây. Đừng thêm bất kỳ credentials AWS thật nào vào job này. Đó là cách rẻ nhất để chắc chắn không có gì trong job chạm tới AWS.</p>
<p><code>LTS/latest</code> là bản nào được phát hành gần nhất, và như bạn sẽ thấy ở dưới, bản đó thay đổi vài lần mỗi ngày. Nếu muốn CI cho kết quả tái lập được (reproducible), AWS có một trang hướng dẫn <a href="https://docs.aws.amazon.com/emulator/latest/userguide/versioning.html"  target="_blank" rel="noreferrer">cài một phiên bản cụ thể</a>. Còn cách tôi bố trí phần còn lại của pipeline thì bạn xem <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-docker-compose-ci-github-actions/" >các pipeline GitHub Actions tôi đang chạy</a>.</p>

<h2 class="relative group">Chuyển từ LocalStack sang AWS Emulator
    <div id="chuyển-từ-localstack-sang-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="#chuy%e1%bb%83n-t%e1%bb%ab-localstack-sang-aws-emulator" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu các bài kiểm thử của bạn đang chạy trên LocalStack, phần lớn việc chuyển đổi chỉ là thay đổi cấu hình:</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> cùng với <code>AWS_CA_BUNDLE</code></td>
			</tr>
			<tr>
					<td>Credentials</td>
					<td>gì cũng được (<code>test</code> / <code>test</code>)</td>
					<td>file nó ghi ra mỗi lần chạy, hoặc <code>--auth off</code></td>
			</tr>
			<tr>
					<td>ID tài khoản</td>
					<td><code>000000000000</code></td>
					<td><code>000000000000</code></td>
			</tr>
			<tr>
					<td>Script khởi tạo</td>
					<td>init hook trong <code>/etc/localstack/init/ready.d</code></td>
					<td>không có: tạo tài nguyên trong fixture, sau khi health check xong</td>
			</tr>
			<tr>
					<td>Image</td>
					<td><code>localstack/localstack</code> (cần token từ sau 4.12)</td>
					<td>tự build từ gói của AWS</td>
			</tr>
	</tbody>
</table>
<p>Init hook là phần thật sự tốn công nếu bạn dùng chúng nhiều. Tôi thì không, vì mỗi bài kiểm thử tự tạo bucket riêng. Và không có gì còn lại sau khi khởi động lại: trình giả lập giữ mọi thứ trong bộ nhớ.</p>

<h2 class="relative group">AWS Emulator bắt được gì trong các bài kiểm thử S3 của tôi, và bỏ sót gì
    <div id="aws-emulator-bắt-được-gì-trong-các-bài-kiểm-thử-s3-của-tôi-và-bỏ-sót-gì" 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-b%e1%ba%aft-%c4%91%c6%b0%e1%bb%a3c-g%c3%ac-trong-c%c3%a1c-b%c3%a0i-ki%e1%bb%83m-th%e1%bb%ad-s3-c%e1%bb%a7a-t%c3%b4i-v%c3%a0-b%e1%bb%8f-s%c3%b3t-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cài đặt là phần dễ. Điều tôi muốn biết là: ở những chỗ code của tôi phụ thuộc vào cách S3 hoạt động, nó có cư xử giống S3 không. Tôi viết 12 phép kiểm tra, mỗi cái assert đúng hành vi của S3 thật theo tài liệu của AWS:</p>
<ul>
<li><strong>Sáu cái lấy từ luồng upload của ứng dụng marketplace.</strong> Ứng dụng đưa cho trình duyệt một presigned URL (URL ký sẵn), trình duyệt PUT ảnh thẳng vào bucket (nên bucket cần cấu hình CORS), và một sự kiện <code>ObjectCreated</code> báo cho backend biết file đã tới nơi. Đó là luồng trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/tigris-object-notifications/" >bài viết của tôi về object notification</a>, chỉ khác là ở đây SQS đóng vai webhook.</li>
<li><strong>Sáu cái lấy từ một bucket của Codemagic Patch.</strong> Patch là server cập nhật OTA cho ứng dụng React Native (<a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >cách tôi chuyển sang dùng nó</a>). Bucket của nó để public để điện thoại tải được bundle, trừ prefix <code>_internal/</code>, nơi các bản upload nằm chờ được phát hành (publish), và một bucket policy giữ cho prefix đó ở chế độ riêng tư.</li>
</ul>
<p>Tôi chạy chúng trên AWS Emulator 1.0.342 (một lần với cấu hình mặc định và một lần với <code>--auth off</code>), LocalStack 4.12 (với cấu hình mặc định, và với <code>S3_SKIP_SIGNATURE_VALIDATION=0</code>), Moto 5.2.6 ở chế độ server, Floci 1.5.5, và Silo, bản fork của MinIO mà bộ cài tự host của Patch dùng.</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="Terminal: một script chạy cùng 12 phép kiểm tra S3 trên bảy cấu hình trình giả lập và in ra bảng đạt/trượt; cấu hình nào cũng chỉ đạt từ 6 đến 8 trên 12">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/aws-emulator-setup/s3-emulator-test-run.mp4" type="video/mp4">
  </video><figcaption>Lần chạy thật. Không cấu hình nào đạt đủ 12.</figcaption>
</figure>

<p>Không cấu hình nào qua được cả 12 phép kiểm tra; tất cả đều nằm trong khoảng từ 6 đến 8. Tổng điểm không quan trọng bằng việc mỗi công cụ bỏ sót phép kiểm tra nào, nên dưới đây là những chỗ lẽ ra đã gây hại cho tôi, theo thứ tự từ nặng nhất.</p>
<p><strong>AWS Emulator khắt khe nhất về phân quyền.</strong> Lấy ví dụ presigned URL mà ứng dụng của tôi ký cho đúng một file JPEG. Với LocalStack 4.12 ở cấu hình mặc định, Moto và Floci, tôi dùng chính URL đó để upload một file HTML và vẫn nhận về 200. Trên một bucket phục vụ file công khai, đó là một lỗ hổng stored XSS chỉ chờ ngày bị khai thác. AWS Emulator từ chối với mã 403, đúng như S3, và tiếp tục từ chối khi URL đã hết hạn. (LocalStack 4.12 bắt được cả hai nếu bạn đặt <code>S3_SKIP_SIGNATURE_VALIDATION=0</code>, còn Floci chỉ bắt được URL hết hạn chứ không phát hiện file bị tráo kiểu.) Nó cũng là công cụ duy nhất biết rằng bucket S3 mới tạo có Block Public Access bật sẵn, nên một bucket policy public sẽ bị từ chối cho tới khi bạn tắt tính năng đó, và là công cụ duy nhất áp một <code>Deny</code> tường minh (explicit) lên cả các request có chữ ký của chính server, chứ không chỉ lên người lạ. Đó đúng là những lỗi qua được mọi bài kiểm thử rồi mới hỏng ở lần triển khai (deploy) thật đầu tiên.</p>
<p><strong>Nó chưa kiểm thử được bất cứ thứ gì trình duyệt làm.</strong> Một request CORS preflight (request <code>OPTIONS</code> mà trình duyệt gửi trước một lệnh PUT khác origin) nhận về <code>501 Not Implemented</code>. Khi bật xác thực (authentication), vốn là mặc định, nó còn từ chối mọi request không có chữ ký, nên một lượt tải ẩn danh từ bucket public sẽ nhận 403 ngay cả khi policy cho phép. Cả hai chỗ thiếu này đều đã được ghi nhận trong repository. Dự án lưu những chỗ còn thiếu đã biết thành từng file trong <code>issues/things-that-do-not-work/</code>, và tính đến ngày 13 tháng 11 có 6,840 file như vậy, trong khi 13,409 file khác đã được chuyển sang <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="File issue về S3 của AWS Emulator, tiêu đề &ldquo;7 net-new causes — CORS preflight answers 501…&rdquo;, trong thư mục things-that-do-not-work trên GitHub"
    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>Chỗ thiếu về CORS, do chính agent tìm lỗi của dự án ghi nhận ngày 9 tháng 11.</figcaption></figure>
</p>
<p><strong><code>--auth off</code> đổi nửa này lấy nửa kia.</strong> Khi tắt xác thực, đọc ẩn danh chạy được và bucket policy được đánh giá đúng, nên cả sáu phép kiểm tra của Patch đều đạt. Đó là cấu hình duy nhất làm được điều này. Nhưng khi đó presigned URL cũng không còn được kiểm tra, nên nhóm kiểm tra luồng upload tụt xuống chỉ còn đạt hai. Chạy hai trình giả lập trên hai cổng khác nhau, một cái bật xác thực và một cái tắt, sẽ bao được cả hai nửa, dù như vậy lại thêm một thứ phải giải thích cho người tiếp theo đọc file CI.</p>
<p><strong>Nó thay đổi rất nhanh.</strong> Các bản v1.0.290 đến v1.0.342 ra đời chỉ trong ba ngày, bản nào cũng kèm một danh sách sửa lỗi dài (AWS nói rằng chính các coding agent đã xây dựng nó, và nhìn tốc độ là thấy), và một trong các bản sửa ngày 11 tháng 11 là lỗi &ldquo;presigned GET refused with 403&rdquo; trong S3. Bất cứ ô nào trong bảng của tôi cũng có thể khác đi vào tuần sau, theo chiều tốt lên hay xấu đi. Hãy ghim phiên bản trong CI và chỉ nâng cấp khi đã chủ động cân nhắc.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Trang releases của aws/aws-emulator trên GitHub: v1.0.342 được đánh dấu Latest, phát hành 3 giờ trước đó, cùng chín bản phát hành khác liệt kê ở thanh bên"
    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>Mười bản phát hành trong ba ngày. Hãy ghim lấy một bản.</figcaption></figure>
</p>

<h2 class="relative group">Bucket của Patch: một điều kiện trong policy chỉ có ý nghĩa trên S3 thật
    <div id="bucket-của-patch-một-điều-kiện-trong-policy-chỉ-có-ý-nghĩa-trên-s3-thật" 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="#bucket-c%e1%bb%a7a-patch-m%e1%bb%99t-%c4%91i%e1%bb%81u-ki%e1%bb%87n-trong-policy-ch%e1%bb%89-c%c3%b3-%c3%bd-ngh%c4%a9a-tr%c3%aan-s3-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là phần tôi không lường trước. Bộ cài tự host của Patch giữ mọi thứ trong một bucket duy nhất, và policy mà nó áp cho storage đi kèm là kiểu hiển nhiên nhất: ai cũng đọc được, trừ những gì nằm dưới <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>Trên Silo, khi ký bằng credentials admin, policy này làm đúng như những gì nó ghi: người lạ không đọc được <code>_internal/</code>, còn lệnh copy của chính server từ <code>_internal/</code> sang prefix public (bước &ldquo;publish&rdquo;) vẫn chạy qua. Trên S3 thật, một <code>Deny</code> tường minh với <code>&quot;Principal&quot;: &quot;*&quot;</code> áp dụng cho tất cả mọi người, kể cả credentials của chính bạn, nên cùng policy đó sẽ chặn luôn bước publish của server. AWS Emulator từ chối lệnh copy đó với mã 403, đúng như quy tắc đánh giá policy của AWS mô tả cách S3 xử lý.</p>
<p>Đội ngũ bảo trì Patch rõ ràng biết chuyện này, vì <a href="https://github.com/codemagic-ci-cd/codemagic-patch/blob/main/deploy/selfhost/aws-s3-bucket-policy.example.json"  target="_blank" rel="noreferrer">policy mẫu cho AWS S3</a> của họ thêm một điều kiện vào <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>Có dòng đó, cả AWS Emulator (chạy với <code>--auth off</code> để kiểm thử được cả nửa ẩn danh) lẫn Moto đều cho worker publish, trong khi vẫn từ chối các lượt đọc ẩn danh vào <code>_internal/</code>. Nhưng chỉ AWS Emulator bắt được phiên bản thiếu dòng đó. Moto, LocalStack 4.12 và Floci đều cho lệnh copy đi qua dù có hay không có điều kiện, nên công cụ nào trong số đó cũng sẽ để tôi đưa một policy lỗi lên production. Đó là lý lẽ rõ ràng nhất tôi tìm được cho việc kiểm thử policy trên trình giả lập khắt khe nhất hiện có. Chép một policy từ cấu hình MinIO sang S3 mà thiếu điều kiện đó thì sẽ chẳng có gì báo lỗi cho tới lần publish thật đầu tiên.</p>
<p>(Server Patch của riêng tôi lưu bundle trên Tigris, nơi hoàn toàn không có bucket policy, nên tôi để <code>_internal/</code> trong một bucket private riêng. <code>S3_INTERNAL_BUCKET</code> trong cấu hình của Patch sinh ra chính là cho việc này. Tôi cũng có viết bài khách mời có trả phí về Patch cho Codemagic. Bài này không nằm trong số đó, và không ai trả tiền cho nó.)</p>

<h2 class="relative group">Giờ tôi sẽ dùng cái nào
    <div id="giờ-tôi-sẽ-dùng-cái-nào" 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="#gi%e1%bb%9d-t%c3%b4i-s%e1%ba%bd-d%c3%b9ng-c%c3%a1i-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Thời gian sẵn sàng</th>
					<th>Bộ nhớ khi rảnh</th>
					<th>Image</th>
					<th>Tài khoản</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 (bạn tự build)</td>
					<td>không cần</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>không cần với 4.12, các bản sau cần 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>không cần</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>không cần</td>
			</tr>
	</tbody>
</table>
<p>Đo trên máy Mac của tôi trong Docker, mỗi công cụ ba lần khởi động nguội (cold start). Không công cụ nào chậm tới mức đáng bận tâm.</p>
<p>Với kiểm thử backend, tôi sẽ bắt đầu bằng AWS Emulator, vì những lỗi nó bắt được (phân quyền, chữ ký, Block Public Access) là những lỗi mà nếu không có nó thì chỉ lộ ra trên production, và đây cũng là công cụ duy nhất trong danh sách vừa miễn phí, vừa đang được cập nhật, lại do chính những người làm ra S3 phát triển. Cho tới khi CORS preflight được hỗ trợ, tôi sẽ giữ một job nhỏ chạy trên Floci cho các phép kiểm tra liên quan tới trình duyệt (preflight, và tải ẩn danh từ prefix public). LocalStack 4.12 với chế độ kiểm tra chữ ký nghiêm ngặt cũng đạt các phép này, nhưng nó đã bị đóng băng và sẽ không nhận bản sửa lỗi nào nữa. Nếu bài kiểm thử của bạn viết bằng Python và bạn không muốn chạy container, các mock chạy ngay trong tiến trình (in-process) của Moto vẫn là lựa chọn nhẹ nhất. Chỉ đừng trông cậy vào nó cho các phép kiểm tra presigned URL.</p>
<p>Bộ 12 phép kiểm tra này đủ ngắn để bạn viết lại cho bucket của mình trong một buổi chiều, và tôi khuyên bạn nên làm vậy trước khi tin vào bất kỳ trình giả lập nào, kể cả AWS Emulator. Mỗi công cụ trong bảng trượt ở những phép kiểm tra khác nhau, và đó chính là những chỗ hổng mà bài kiểm thử của bạn sẽ lặng lẽ cho qua.</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>
