<?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>S3 Event Notifications · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/s3-event-notifications/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/s3-event-notifications/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>S3に直接アップロードしても画像圧縮は手放さない：Tigrisのオブジェクト通知とDjango</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/tigris-object-notifications/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/tigris-object-notifications/</guid>
      <pubDate>Sat, 07 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Tigris</category>
      <category>S3</category>
      <category>S3 Event Notifications</category>
      <category>Presigned URLs</category>
      <category>Image Uploads</category>
      <category>Image Compression</category>
      <category>Object Storage</category>
      <category>Webhooks</category>
      <category>Django</category>
      <category>Fly.io</category>
      <category>Discord</category>
      <description>署名付きURLでS3に直接アップロードすれば速いのですが、ファイルが届いたことをバックエンドは知りようがなく、画像圧縮やサムネイル生成が厄介になります。Tigrisのオブジェクト通知がそれを解決します。バケットがDjangoのWebhookにPOSTし、ユーザーがまだ入力しているあいだに写真が処理されます。同じ仕組みで、ログファイルをDiscordのアラートに変えました。</description>
      <content:encoded><![CDATA[<p>私がこれまで作ってきた画像アップロードには、いつも同じ綱引きがありました。アップロードする側は、写真をとにかく速く上げたい。私のほうは、サーバーで画像圧縮をかけ、サムネイルを作り、blurhashと色をいくつか抜き出し、入っていてはいけないメタデータを落として、アプリのほかの部分が期待する形で保存したい。私の要望リストをいちばん手軽に片づける方法はファイルをAPI経由で送ることで、それこそが相手の要望を遅くしている張本人なのです。</p>
<p>速くする方法は署名付きURLです。APIがURLに署名し、クライアントはそのURLにファイルを直接PUTして、バイト列はAPIにまったく触れません。定番の答えになっているのには、それだけの理由があります。ただ、そうした瞬間にバックエンドは蚊帳の外に置かれます。URLを渡したきり、何の音沙汰もありません。ファイルは届いたのか。想定どおりのサイズなのか。そもそもJPEGなのか。誰かがバックエンドに教えなければならないのですが、よくある選択肢はどれも、どこか物悲しいものです。クライアントが「終わりました」と知らせてくれるのを信じる（クライアントは嘘をつき、落ち、電波を失います）。バケットを定期的にポーリングする（遅くて無駄が多い）。最初に読まれたときに遅延処理する（最初の閲覧者が代償を払ううえ、ファイルをいつから安全に参照できるのかは相変わらず分かりません）。S3に直接アップロードするのが実際より難しそうに見えるのは、このせいです。アップロード自体は簡単です。足りないのは合図のほうです。</p>
<p>オブジェクト通知が、その合図です。私がFly.ioで使っているS3互換ストレージのTigrisは、バケットでオブジェクトが作成または削除されるたびに、小さなJSONをアプリにPOSTしてくれます。AWSで相当するのはS3イベント通知ですが、こちらは自分のコードに何かが届く前に、LambdaやSQSのキュー、EventBridgeを配線することになるのが普通です（SNSならHTTPSエンドポイントにPOSTできますが、その前にサブスクリプションの確認と署名の検証が待っています）。Tigrisの場合は、自分が持っているURLへのHTTPリクエスト1本で済みます。ストレージからバックエンドへの合図としては、これ以上ないくらい単純です。バケットがバックエンドと話せるように、こちらが橋を架けなくて済む。この機能はそのためにあります。</p>
<p>この記事では、流れを最初から最後まで追います。設定、イベントを受け取るDjangoのエンドポイント、サーバー側の処理を一切手放さない署名付きURLでのアップロード、そして落とし穴（CORS、サイズ制限、有効期限、複数ファイル）です。最後に、同じ合図を裏方の仕事に使った話をします。バケットに届いたログファイルがDiscordのアラートになり、何か月も面倒を見てきたcronジョブを消せた話です。</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%9f%ad%e3%81%84%e7%ad%94%e3%81%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Tigrisのダッシュボードでバケットを開き、Settings、Object notificationsと進みます。アプリ上のHTTPSのURLを指定し、Basic認証を選びます（Tigrisが<code>Authorization</code>ヘッダーに載せて送るユーザー名とパスワードです。もうひとつの選択肢はBearerトークンです）。関心のあるキーについてだけ通知が来るよう、フィルターも追加します。フィルターはSQLに似た式で、「この記事で扱う3つのプレフィックスだけ」ならこう書きます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">WHERE</span><span class="w"> </span><span class="o">`</span><span class="k">key</span><span class="o">`</span><span class="w"> </span><span class="n">REGEXP</span><span class="w"> </span><span class="s2">&#34;^(uploads/raw|logs/api|media)/&#34;</span></span></span></code></pre></div></div>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Tigrisのバケット設定、Notificationsタブ：オブジェクト通知がオン、Webhook URL、ユーザー名とパスワードのBasic認証、SQLライクなキーのフィルター"
    width="1204"
    height="671"
    src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_7e9da7c6ca1125db.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_7e9da7c6ca1125db.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_4ddc968851d1f052.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_4ddc968851d1f052.webp"></span><figcaption>設定はこれで全部です。アプリのHTTPS URL、Basic認証、キーのフィルター。バケット名とURLは例に差し替えてあります。</figcaption></figure>
</p>
<p>あとは変更があるたびに、Tigrisがこんなバッチを送ってきます。</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;events&#34;</span><span class="p">:</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="nt">&#34;eventName&#34;</span><span class="p">:</span> <span class="s2">&#34;OBJECT_CREATED_PUT&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;eventTime&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-11-07T09:12:44Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;bucket&#34;</span><span class="p">:</span> <span class="s2">&#34;example-bucket&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;object&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="s2">&#34;uploads/raw/1f3c.jpg&#34;</span><span class="p">,</span> <span class="nt">&#34;size&#34;</span><span class="p">:</span> <span class="mi">48213</span><span class="p">,</span> <span class="nt">&#34;eTag&#34;</span><span class="p">:</span> <span class="s2">&#34;9b2c…&#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><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>目にするイベント名は<code>OBJECT_CREATED_PUT</code>、<code>OBJECT_CREATED_MULTIPART</code>、<code>OBJECT_CREATED_COPY</code>、<code>OBJECT_DELETED</code>、それにリネームとストレージ階層の移行です。エンドポイント側では認証を確認し、各イベントをキーのプレフィックスで振り分けて、10秒以内に2xxを返します。最初から織り込んでおくべきことが3つあります。配信は「少なくとも1回」なので、同じイベントがまた来ること。イベントはバッチで届くので、1回のリクエストに複数入っていること。そして順序どおりに届くとは限らないので、届いた順番から「最新」を読み取ってはいけないことです。</p>

<h2 class="relative group">バケットのイベントを受け取るDjangoのWebhook
    <div id="バケットのイベントを受け取るdjangoのwebhook" 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="#%e3%83%90%e3%82%b1%e3%83%83%e3%83%88%e3%81%ae%e3%82%a4%e3%83%99%e3%83%b3%e3%83%88%e3%82%92%e5%8f%97%e3%81%91%e5%8f%96%e3%82%8bdjango%e3%81%aewebhook" aria-label="アンカー">#</a>
    </span>
    
</h2>
<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">base64</span><span class="o">,</span> <span class="nn">hmac</span><span class="o">,</span> <span class="nn">json</span><span class="o">,</span> <span class="nn">logging</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.conf</span> <span class="kn">import</span> <span class="n">settings</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.http</span> <span class="kn">import</span> <span class="n">JsonResponse</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.views.decorators.csrf</span> <span class="kn">import</span> <span class="n">csrf_exempt</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.views.decorators.http</span> <span class="kn">import</span> <span class="n">require_POST</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">.bucket_events</span> <span class="kn">import</span> <span class="n">handle</span><span class="p">,</span> <span class="n">parse</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="o">.</span><span class="n">getLogger</span><span class="p">(</span><span class="vm">__name__</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">_authorised</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">secret</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">header</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">headers</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;Authorization&#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">header</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;Basic &#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">password</span> <span class="o">=</span> <span class="n">base64</span><span class="o">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">header</span><span class="p">[</span><span class="mi">6</span><span class="p">:])</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="o">.</span><span class="n">partition</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">except</span> <span class="p">(</span><span class="ne">ValueError</span><span class="p">,</span> <span class="ne">UnicodeDecodeError</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">user</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="sa">b</span><span class="s2">&#34;tigris&#34;</span><span class="p">)</span> <span class="ow">and</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">password</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">secret</span><span class="o">.</span><span class="n">encode</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></span><span class="line"><span class="cl"><span class="nd">@csrf_exempt</span>
</span></span><span class="line"><span class="cl"><span class="nd">@require_POST</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">bucket_webhook</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">secret</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">BUCKET_WEBHOOK_SECRET</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">secret</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;not configured&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">503</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">_authorised</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">secret</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;unauthorised&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">body</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">body</span> <span class="ow">or</span> <span class="sa">b</span><span class="s2">&#34;</span><span class="si">{}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">ValueError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;invalid json&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">400</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">event</span> <span class="ow">in</span> <span class="n">parse</span><span class="p">(</span><span class="n">body</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">handle</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">logger</span><span class="o">.</span><span class="n">exception</span><span class="p">(</span><span class="s2">&#34;bucket event failed: </span><span class="si">%s</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">})</span></span></span></code></pre></div></div>
<p><code>compare_digest</code>を使っているのは、何を送られても照合にかかる時間を同じにするためです（比較はバイト列で行っています。文字列だと非ASCIIの入力で例外を投げるので、細工したヘッダーが401ではなく500を受け取ることになるからです）。たとえば立ち上げたばかりのステージング環境などでシークレットが未設定なら、エンドポイントはすべてを受け入れるのではなく、すべてを拒否します。そしてハンドラーが例外を投げても、ログに残したうえで200を返します。Tigrisは2xx以外なら何でもリトライしますが、リトライしても同じバグに当たるだけですし、その頃にはイベントはすでに確保済み（2つ下のセクションで説明します）なので、リトライしても何も起きません。直すきっかけになるのは、ログの1行のほうです。ただしこれは、失敗が決定的なもの、つまり何度やっても同じ結果になる場合の話です。ストレージの一時的なタイムアウトだと、イベントはそれきり消費されてしまいます。写真なら気にしなくて構いません。保存処理が自分で処理できるからです。でもログファイルのアラートは失われます。それが困るなら、<code>except</code>の中で確保を解放して500を返し、Tigrisにリトライさせてください。その代わり、本物のバグは直るまでリトライされ続けることになります。</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="#%e3%83%97%e3%83%ac%e3%83%95%e3%82%a3%e3%83%83%e3%82%af%e3%82%b9%e3%81%a7%e6%8c%af%e3%82%8a%e5%88%86%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>バケットの中身はもともとプレフィックスで整理されているので、振り分けもそれでやるのが自然です。用途ごとに関数が1つ、テーブルに1行です。</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="n">HANDLERS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;uploads/raw/&#34;</span><span class="p">,</span>   <span class="n">on_raw_photo</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;logs/api/&#34;</span><span class="p">,</span>      <span class="n">on_api_log_file</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;media/&#34;</span><span class="p">,</span>         <span class="n">on_media_change</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></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">handle</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">prefix</span><span class="p">,</span> <span class="n">fn</span> <span class="ow">in</span> <span class="n">HANDLERS</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">prefix</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">claim</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>  <span class="c1"># 後述</span>
</span></span><span class="line"><span class="cl">                <span class="n">fn</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span></span></span></code></pre></div></div>
<p>あとで4つ目を足したくなっても、関数と行を1つずつ増やすだけです。これが思っていた以上に気に入っています。</p>

<h2 class="relative group">「少なくとも1回」は「2回かもしれない」
    <div id="少なくとも1回は2回かもしれない" 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%b0%91%e3%81%aa%e3%81%8f%e3%81%a8%e3%82%821%e5%9b%9e%e3%81%af2%e5%9b%9e%e3%81%8b%e3%82%82%e3%81%97%e3%82%8c%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>エンドポイントの応答が遅かったりエラーを返したりすると、Tigrisはリトライします。そして多くのWebhook送信側と同じく、イベントが1回しか届かないという保証はありません。そこでハンドラーを動かす前に、<code>(key, eTag, eventName)</code>でイベントを確保します。ポイントはeTagです。ファイルが上書きされれば新しいeTagを持つ新しいバージョンなので、もう一度処理すべきです。一方、同じバージョンの重複は処理すべきではありません。</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">from</span> <span class="nn">django.core.cache</span> <span class="kn">import</span> <span class="n">cache</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">claim</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">ttl</span><span class="o">=</span><span class="mi">7</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">k</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;bucket-event:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">etag</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">cache</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">k</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="n">ttl</span><span class="p">)</span>  <span class="c1"># 誰かが先に確保していればFalse</span></span></span></code></pre></div></div>
<p><code>cache.add</code>はキーがまだ存在しないときだけ値をセットします。Redisではこれがアトミックなので、同じリトライを受け取った2台のマシンが両方とも勝つことはありません。本番ではRedisがフラッシュされても生き残るようにデータベースのリースを使っていますが、考え方はまったく同じです。</p>

<h2 class="relative group">画像はS3に直接アップロード、パイプラインはそのまま
    <div id="画像は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="#%e7%94%bb%e5%83%8f%e3%81%afs3%e3%81%ab%e7%9b%b4%e6%8e%a5%e3%82%a2%e3%83%83%e3%83%97%e3%83%ad%e3%83%bc%e3%83%89%e3%83%91%e3%82%a4%e3%83%97%e3%83%a9%e3%82%a4%e3%83%b3%e3%81%af%e3%81%9d%e3%81%ae%e3%81%be%e3%81%be" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここまでの配管が報われるのは、この部分です。</p>
<p>以前は、出品用の写真を選ぶとアプリがそれを手元に保持し、投稿ボタンを押した時点で全部をbase64でAPIに送っていました。APIはリサイズし、サムネイルを作り、色とblurhashを計算して、それからようやく応答します。遅い回線で写真が10枚もあると、スピナーをずいぶん長く眺めることになります。それに、base64の写真を10枚抱えたリクエストは、APIサーバーがメモリに抱えるには大きくて遅いボディです。実はそれが、私が写真を小さく抑え続けていたひそかな理由でした。</p>
<p>今は4つのステップになり、そのすべてがバックエンドに伝わります。</p>

<h3 class="relative group">1. クライアントが写真を縮小し、アップロード枠を要求する
    <div id="1-クライアントが写真を縮小しアップロード枠を要求する" 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="#1-%e3%82%af%e3%83%a9%e3%82%a4%e3%82%a2%e3%83%b3%e3%83%88%e3%81%8c%e5%86%99%e7%9c%9f%e3%82%92%e7%b8%ae%e5%b0%8f%e3%81%97%e3%82%a2%e3%83%83%e3%83%97%e3%83%ad%e3%83%bc%e3%83%89%e6%9e%a0%e3%82%92%e8%a6%81%e6%b1%82%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>アプリは今でも端末上でリサイズします。サーバーが保持する長辺（1080px）に合わせ、品質0.7のJPEGにします。このリサイズはユーザーの通信量のためであって、それ以外の理由はありません。そのうえで、APIにアップロード枠を要求します。</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">uuid</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">timedelta</span>
</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">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</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 class="n">endpoint_url</span><span class="o">=</span><span class="s2">&#34;https://fly.storage.tigris.dev&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">BUCKET</span> <span class="o">=</span> <span class="s2">&#34;example-bucket&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">create_upload_slot</span><span class="p">(</span><span class="n">user</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">public_id</span> <span class="o">=</span> <span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;uploads/raw/</span><span class="si">{</span><span class="n">public_id</span><span class="si">}</span><span class="s2">.jpg&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span> <span class="o">=</span> <span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="o">=</span><span class="n">user</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">public_id</span><span class="o">=</span><span class="n">public_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">raw_key</span><span class="o">=</span><span class="n">key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">expires_at</span><span class="o">=</span><span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">+</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">2</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">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></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="n">key</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="n">ExpiresIn</span><span class="o">=</span><span class="mi">15</span> <span class="o">*</span> <span class="mi">60</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="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;upload_id&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;url&#34;</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;method&#34;</span><span class="p">:</span> <span class="s2">&#34;PUT&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;headers&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;Content-Type&#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="s2">&#34;expires_in&#34;</span><span class="p">:</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">60</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span></span></span></code></pre></div></div>
<p>枠は、ファイルになる前にまず1行のレコードです。だからサーバーは、誰が要求したのか、いつ期限が切れるのか、そして後になって実際に使われたのかを知ることができます。</p>

<h3 class="relative group">2. クライアントがバケットに直接PUTする
    <div id="2-クライアントがバケットに直接putする" 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="#2-%e3%82%af%e3%83%a9%e3%82%a4%e3%82%a2%e3%83%b3%e3%83%88%e3%81%8c%e3%83%90%e3%82%b1%e3%83%83%e3%83%88%e3%81%ab%e7%9b%b4%e6%8e%a5put%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">slot</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">api</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="s2">&#34;/photo-uploads/&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">slot</span><span class="p">.</span><span class="nx">url</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">method</span>: <span class="kt">slot.method</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">headers</span>: <span class="kt">slot.headers</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">body</span>: <span class="kt">blob</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="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">res</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">&#34;upload refused&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="c1">// 保存時のためにslot.upload_idを控えておく
</span></span></span></code></pre></div></div>
<p>APIはバイト列を一切見ません。ブラウザからでも、React Nativeからでも、ネイティブアプリからでも同じです。唯一の違いは、ブラウザの場合はバケットにCORSルールが必要なことで、これは後述の落とし穴で触れます。</p>

<h3 class="relative group">3. Tigrisがバックエンドに知らせ、パイプラインが動く
    <div id="3-tigrisがバックエンドに知らせパイプラインが動く" 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="#3-tigris%e3%81%8c%e3%83%90%e3%83%83%e3%82%af%e3%82%a8%e3%83%b3%e3%83%89%e3%81%ab%e7%9f%a5%e3%82%89%e3%81%9b%e3%83%91%e3%82%a4%e3%83%97%e3%83%a9%e3%82%a4%e3%83%b3%e3%81%8c%e5%8b%95%e3%81%8f" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>数秒のうちにTigrisが<code>uploads/raw/…</code>の<code>OBJECT_CREATED_PUT</code>をPOSTしてきて、ハンドラーがbase64経路でずっと使ってきたのと同じ画像パイプラインを回します。ユーザーがまだタイトルを入力しているあいだにです。</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">from</span> <span class="nn">io</span> <span class="kn">import</span> <span class="n">BytesIO</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">PIL</span> <span class="kn">import</span> <span class="n">Image</span><span class="p">,</span> <span class="n">ImageOps</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.core.files.base</span> <span class="kn">import</span> <span class="n">ContentFile</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">blurhash</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">MAX_BYTES</span> <span class="o">=</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">on_raw_photo</span><span class="p">(</span><span class="n">event</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">event</span><span class="o">.</span><span class="n">name</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;OBJECT_CREATED&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">size</span> <span class="o">&gt;</span> <span class="n">MAX_BYTES</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">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">bucket</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span> <span class="o">=</span> <span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">raw_key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">,</span> <span class="n">processed_at</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">upload</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span> <span class="o">=</span> <span class="n">s3</span><span class="o">.</span><span class="n">get_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">bucket</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)[</span><span class="s2">&#34;Body&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="n">longest</span><span class="p">,</span> <span class="n">quality</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span> <span class="o">=</span> <span class="n">im</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span><span class="o">.</span><span class="n">thumbnail</span><span class="p">((</span><span class="n">longest</span><span class="p">,</span> <span class="n">longest</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">buf</span> <span class="o">=</span> <span class="n">BytesIO</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="s2">&#34;WEBP&#34;</span><span class="p">,</span> <span class="n">quality</span><span class="o">=</span><span class="n">quality</span><span class="p">,</span> <span class="n">icc_profile</span><span class="o">=</span><span class="n">im</span><span class="o">.</span><span class="n">info</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;icc_profile&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">ContentFile</span><span class="p">(</span><span class="n">buf</span><span class="o">.</span><span class="n">getvalue</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">Image</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">BytesIO</span><span class="p">(</span><span class="n">data</span><span class="p">))</span> <span class="k">as</span> <span class="n">im</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">im</span> <span class="o">=</span> <span class="n">ImageOps</span><span class="o">.</span><span class="n">exif_transpose</span><span class="p">(</span><span class="n">im</span><span class="p">)</span><span class="o">.</span><span class="n">convert</span><span class="p">(</span><span class="s2">&#34;RGB&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">im</span><span class="o">.</span><span class="n">thumbnail</span><span class="p">((</span><span class="mi">1080</span><span class="p">,</span> <span class="mi">1080</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">width</span><span class="p">,</span> <span class="n">upload</span><span class="o">.</span><span class="n">height</span> <span class="o">=</span> <span class="n">im</span><span class="o">.</span><span class="n">size</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">dominant_color</span> <span class="o">=</span> <span class="s2">&#34;#</span><span class="si">%02x%02x%02x</span><span class="s2">&#34;</span> <span class="o">%</span> <span class="n">im</span><span class="o">.</span><span class="n">resize</span><span class="p">((</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">))</span><span class="o">.</span><span class="n">getpixel</span><span class="p">((</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">image</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="si">}</span><span class="s2">.webp&#34;</span><span class="p">,</span> <span class="n">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="mi">1080</span><span class="p">,</span> <span class="mi">80</span><span class="p">),</span> <span class="n">save</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">thumbnail</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="si">}</span><span class="s2">_t.webp&#34;</span><span class="p">,</span> <span class="n">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="mi">320</span><span class="p">,</span> <span class="mi">70</span><span class="p">),</span> <span class="n">save</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">blurhash</span> <span class="o">=</span> <span class="n">blurhash</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="n">im</span><span class="o">.</span><span class="n">resize</span><span class="p">((</span><span class="mi">32</span><span class="p">,</span> <span class="mi">32</span><span class="p">)),</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span><span class="o">.</span><span class="n">processed_at</span> <span class="o">=</span> <span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span><span class="o">.</span><span class="n">save</span><span class="p">()</span></span></span></code></pre></div></div>
<p><code>on_raw_photo</code>の最初の行は、見た目以上に大事です。ルーターは<code>uploads/raw/</code>配下のイベントをすべてここに送ってきます。孤立ファイルの掃除処理による削除も、サイズチェック自身による削除も含めてです。消えたキーに<code>get_object</code>しても、例外が返ってくるだけです。</p>
<p>つまりクライアント側の縮小は、あくまで回線のためだけのものでした。フォーマット（WebP）も、サイズも、サムネイルも、プレースホルダーも、保存する寸法も、決めるのは今でもサーバーです。Pillowで再エンコードするとEXIFブロックも落ちます。スマートフォンのカメラがGPS位置情報を書き込むのはそこなので、プライバシーのためのメタデータ除去はおまけで付いてきます。ICCプロファイルも同じ道をたどるところですが、そうなると広色域のスマートフォン写真の色が少し転ぶので、<code>to_webp</code>ではそのまま引き継いでいます。ピクセル単位で元画像を残したいなら、クライアントで縮小しなければいいだけです。流れは同じで、PUTが大きくなるだけ。そして大きなPUTこそ、この設計が安くしてくれるものです。ひとつ注意点があります。iPhoneの元画像はHEICで、Pillowがこれを開けるのは<code>pillow-heif</code>を入れたときだけです。</p>

<h3 class="relative group">4. 保存時に写真を紐づけ、Webhookは待たない
    <div id="4-保存時に写真を紐づけwebhookは待たない" 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="#4-%e4%bf%9d%e5%ad%98%e6%99%82%e3%81%ab%e5%86%99%e7%9c%9f%e3%82%92%e7%b4%90%e3%81%a5%e3%81%91webhook%e3%81%af%e5%be%85%e3%81%9f%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>投稿ボタンが押されたときのリクエストには、画像データの代わりに<code>upload_ids</code>が入っています。サーバーはそれぞれを調べ、結果は3通りに分かれます。</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="k">def</span> <span class="nf">take</span><span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="n">upload_ids</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">uploads</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="o">=</span><span class="n">user</span><span class="p">,</span> <span class="n">public_id__in</span><span class="o">=</span><span class="n">upload_ids</span><span class="p">,</span> <span class="n">expires_at__gt</span><span class="o">=</span><span class="n">timezone</span><span class="o">.</span><span class="n">now</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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">uploads</span><span class="p">)</span> <span class="o">!=</span> <span class="nb">len</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">upload_ids</span><span class="p">)):</span>     <span class="c1"># 期限切れか、この人のものではない</span>
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="n">UploadNotReadyError</span><span class="p">(</span><span class="n">upload_ids</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">upload</span> <span class="ow">in</span> <span class="n">uploads</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">upload</span><span class="o">.</span><span class="n">processed_at</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>          <span class="c1"># Webhookがまだ届いていない</span>
</span></span><span class="line"><span class="cl">            <span class="n">data</span> <span class="o">=</span> <span class="n">read_raw</span><span class="p">(</span><span class="n">upload</span><span class="p">)</span>              <span class="c1"># PUTが届いていなければNone</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">data</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">raise</span> <span class="n">UploadNotReadyError</span><span class="p">(</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">uploads</span></span></span></code></pre></div></div>
<p>通知がすでに処理を済ませていれば、処理済みのファイルをそのまま紐づけます。ファイルはバケットにあるのに通知がまだ届いていなければ、保存処理がその場でインラインで処理します。だから投稿がWebhookを待つことはありません。通知は先回りであって、依存先ではないのです。そしてファイルがそもそも存在しない場合や、IDが期限切れだったり他人のものだったりする場合は、保存はバリデーションエラーを返し、アプリが少し待ってリトライします。アプリは自分のPUTが終わるのを待ってから送信するので、実際にはめったに起きません。</p>
<p>これを安定させるために効いた細かい点がいくつかあります。枠はユーザーのものなので、サーバーは同じ人が作成したアップロードIDしか受け付けません。そうしないと、IDを見かけた誰でも、あなたの写真を自分の出品に付けられてしまいます。すべてにフォールバックがあります。枠の要求に失敗した、PUTが拒否された、APIが古いバージョンだった、といった場合、アプリは従来どおりbase64で送ります。アップロード経路を使うのは新しい写真のすべてに枠があるときだけなので、1つの投稿の写真は全部アップロードか全部base64かのどちらかになり、並び順も1つのリストのまま保たれます。そして孤立したアップロードは期限切れになります。写真を選んだものの途中でやめる人はいるので、どこにも紐づかなかったアップロードは2日後に期限切れになり、既存の孤立メディアの掃除処理がファイルを片づけます。</p>

<h2 class="relative group">署名付きURLアップロードの落とし穴：CORS、サイズ制限、有効期限、複数ファイル
    <div id="署名付きurlアップロードの落とし穴corsサイズ制限有効期限複数ファイル" 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%bd%b2%e5%90%8d%e4%bb%98%e3%81%8durl%e3%82%a2%e3%83%83%e3%83%97%e3%83%ad%e3%83%bc%e3%83%89%e3%81%ae%e8%90%bd%e3%81%a8%e3%81%97%e7%a9%b4cors%e3%82%b5%e3%82%a4%e3%82%ba%e5%88%b6%e9%99%90%e6%9c%89%e5%8a%b9%e6%9c%9f%e9%99%90%e8%a4%87%e6%95%b0%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>署名付きURLでのアップロードでつまずくことの大半は、通知が飛ぶより前の段階で起きます。1ページにまとめておきたいものを挙げます。</p>

<h3 class="relative group">CORSはブラウザだけの話
    <div id="corsはブラウザだけの話" 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="#cors%e3%81%af%e3%83%96%e3%83%a9%e3%82%a6%e3%82%b6%e3%81%a0%e3%81%91%e3%81%ae%e8%a9%b1" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ネイティブアプリは気にしません。ブラウザはPUTの前にプリフライトの<code>OPTIONS</code>をバケットに送り、バケットのCORSルールがあなたのオリジンからの<code>PUT</code>を<code>Content-Type</code>ヘッダー付きで許可していなければ、アップロードはCORSエラーで失敗します。Tigrisは標準のS3 CORS設定をそのまま受け付けるので、ルールの書き方はAWSと同じです。署名付きPUTが<code>curl</code>からは動くのにページからは動かないとき、最初に確認すべきはこれです。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Tigrisのバケット設定、CORS and headersタブ：2つのオリジンからのPUTとContent-Typeヘッダーを許可するルールが1つ"
    width="1204"
    height="671"
    src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_7f61261034a8a56.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_7f61261034a8a56.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_3aa47360232f6475.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_3aa47360232f6475.webp"></span><figcaption>ブラウザからのアップロードに必要なCORSルールはこれ1つです。自分のオリジン、PUT、そして署名が期待するContent-Typeヘッダー。</figcaption></figure>
</p>

<h3 class="relative group">Content-Typeは署名の一部
    <div id="content-typeは署名の一部" 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="#content-type%e3%81%af%e7%bd%b2%e5%90%8d%e3%81%ae%e4%b8%80%e9%83%a8" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>ContentType</code>を含めて署名すると、クライアントはそのヘッダーを寸分違わず送らなければならず、さもなければバケットは403の<code>SignatureDoesNotMatch</code>を返します。枠がURLと一緒にヘッダーも返しているのはそのためです。意図して署名に含めるのはいいのですが、それで何が手に入るのかは知っておいてください。強制されるのはヘッダーであって中身ではないので、<code>image/jpeg</code>と名乗ったPDFは通ります。本当のチェックはパイプラインです。Pillowは画像でないものを開こうとせず、レコードが処理済みになることもありません。</p>

<h3 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="#%e3%82%b5%e3%82%a4%e3%82%ba%e5%88%b6%e9%99%90" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>署名付きPUT URLにはサイズ制限がありません。バイト列が届く前に確実に止めたいなら、<code>content-length-range</code>のポリシー条件を付けた署名付きPOSTがそれをやってくれます。私はそこまでしませんでした。通知にオブジェクトの<code>size</code>が入っているからです。ハンドラーは上限を超えるものを読む前に削除し、保存時の経路は判別できるよう上限プラス1バイトまでしか読みません。アプリが送るのは数百キロバイトで、15メガバイト近いものはうちのものではありません。</p>

<h3 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="#%e6%9c%89%e5%8a%b9%e6%9c%9f%e9%99%90" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>時計は2つあります。署名付きURLの有効期間は15分です。PUT 1回には十分で、漏れたURLがすぐに使い物にならなくなる程度には短い長さです。枠のレコードは2日間有効で、写真を選んでから端末を置き、翌日に出品を仕上げる、という使い方をカバーします。クライアントは保留中のアップロードを1日経ったら古いものとみなし、祈るのではなく新しい枠を取り直します。</p>

<h3 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="#%e8%a4%87%e6%95%b0%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab%e3%81%a8%e3%82%82%e3%81%a3%e3%81%a8%e5%a4%a7%e3%81%8d%e3%81%aa%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ファイル1つに枠1つです。写真が10枚なら枠が10個、並列のPUTが10本で、それぞれに進捗があります（<code>XMLHttpRequest</code>なら<code>upload.onprogress</code>が使えます。<code>fetch</code>にはありません）。巨大なbase64のPOST 1本では手に入らないものです。数メガバイトを超えるならマルチパートを使います（パートは最低5 MiBなので、それをはるかに超えるファイル向けです）。サーバーでマルチパートアップロードを作成し、<code>upload_part</code>ごとに署名し、パートが揃ったら完了させます。アップロードが完了した時点でTigrisが<code>OBJECT_CREATED_MULTIPART</code>を送ってくるので、同じハンドラーが動き、ほかには何も変わりません。</p>

<h3 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="#%e9%a0%86%e5%ba%8f%e3%81%af%e4%bf%9d%e8%a8%bc%e3%81%95%e3%82%8c%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>イベントは順序どおりに届くとは限りません。オブジェクトが複数のリージョンで書き込まれていることがあるからです。アップロードでは問題になりません。キー1つにイベント1つです。上書きされるキーについては、Webhookが届いた順番ではなく、eTagとオブジェクトの<code>Last-Modified</code>を信じてください。</p>

<h2 class="relative group">同じ合図をログにも：Discordへのアラート
    <div id="同じ合図をログにもdiscordへのアラート" 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%90%8c%e3%81%98%e5%90%88%e5%9b%b3%e3%82%92%e3%83%ad%e3%82%b0%e3%81%ab%e3%82%82discord%e3%81%b8%e3%81%ae%e3%82%a2%e3%83%a9%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>バケットがバックエンドと話せるようになれば、そこに届くものには何でも同じやり方で反応できます。2つ目に向けた先はログでした。</p>
<p>アプリのログは、小さなgzipファイルとしてバケットに送られています。アプリごとにだいたい5分に1ファイルです。今年の大半は、15分おきのcronで動くGitHub Actionsのワークフローが、DuckDBでそのアーカイブの直近20分を読み、遅いリクエストの行を見つけて、Discordにまとめを投稿していました。理屈の上では問題ありません。ところが実際には、GitHubのスケジュール実行はプラットフォームが混んでいると時間どおりに動きません。「15分おき」はむしろ「数時間おき」で、やっと動いたと思えば処理する範囲が広すぎて、たまにメモリ不足で落ちていました。実行の合間に取りこぼさないよう重ねておいた時間範囲のせいで、同じ遅いリクエストが2回出てくることもありました。「遅いリクエストがあったら教えて」のためにしては、ずいぶん大がかりな仕掛けです。</p>
<p>今は各ログファイルを、届いた時点で1回だけ読みます。gzipで数キロバイトです。ハンドラーはそこから遅いリクエストの行を抜き出し、数が多いか、極端に遅いものが1件あれば、Discord webhook経由でチャンネルにまとめを投稿します。1つのファイルに5xxレスポンスが集中していた場合も同じです。PythonからDiscord webhookを叩くのは、呼び出し1回です。</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">requests</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.conf</span> <span class="kn">import</span> <span class="n">settings</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">discord_alert</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">requests</span><span class="o">.</span><span class="n">post</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">settings</span><span class="o">.</span><span class="n">DISCORD_WEBHOOK_URL</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="n">text</span><span class="p">[:</span><span class="mi">1900</span><span class="p">]},</span>  <span class="c1"># Discordのメッセージ上限は2000文字</span>
</span></span><span class="line"><span class="cl">        <span class="n">timeout</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span></span></span></code></pre></div></div>
<p>cronも、範囲の重なりも、重複もありません。アラートは、GitHubの手が空いたときではなく、リクエストから数分以内に届きます。あのActionはもうありません。</p>
<p>どうせファイルを開いているので、中身（エラー、警告、ヘルスチェック）を数えて、1時間ごとの集計テーブルにも書き込んでいます。以前は日次サマリーのたびにアーカイブを丸1日分スキャンしていました。今は数百行を読むだけで、集計に抜けがあるときだけアーカイブにフォールバックします。</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="#%e5%89%8a%e9%99%a4%e3%81%ae%e8%a6%8b%e5%bc%b5%e3%82%8a" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>これは計画していたわけではなく、<code>OBJECT_DELETED</code>イベントが手に入ったことで自然に生まれました。メディアファイルが削除されると、ハンドラーはそれを参照している行がまだ残っていないかを確認します。残っていたら、それはどこかのバグ（あるいはバケットのコンソールで指がすべった誰か）です。そこでそのカラムをクリアして、アプリが壊れた画像ではなくプレースホルダーを表示するようにし、報告を上げます。</p>
<p>1時間あたりの削除数も数えています。通常のハウスキーピングで消えるのはせいぜい数件です。もし1時間に数百件も消えるようなことがあれば、ユーザーから「写真が真っ白」と報告が来てからではなく、起きているその最中に知りたいのです。</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%9f%a5%e3%81%a3%e3%81%a6%e3%81%8a%e3%81%8f%e3%81%a8%e3%82%88%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>応答は速く。Tigrisが1回の配信に与える時間は10秒です。5KBのログファイルを読んだり、スマートフォンの写真を1枚処理したりするぶんには収まります。それより長くかかりそうなものはキューに渡してビューは200を返すようにしないと、Tigrisがタイムアウトしてリトライし、また重複排除の世界に逆戻りです。</p>
<p>フィルターはバケット側で。私のログ転送は、アプリごとに数分おきに永遠にファイルを書き続けます。関心があるのが1つのプレフィックスだけなら、通知のフィルターでそう宣言しておけば、それ以外のことでアプリが起こされずに済みます。</p>
<p>テストは偽のボディで。ペイロードは小さくて安定しているので、テストでは正しい<code>Authorization</code>ヘッダーを付けてビューにJSONをPOSTし、ハンドラーが何をしたかをアサートします。本物のストレージは関わりません。</p>
<p>初日から頼り切りにしない。ここで挙げた用途にはどれもフォールバックがあります。保存処理は自分で写真を処理できますし、日次サマリーは今でもアーカイブをスキャンできますし、アプリは今でもbase64で送れます。通知のおかげでどれも速く安くなりましたが、Webhookが1つ届かなかったくらいで倒れるものはありません。</p>
<p>今週いちばん気持ちよかったのは、あのワークフローのファイルを消した瞬間でした。40行のYAML、Pythonスクリプト、リポジトリのシークレット2つ、そして定期的にやってくる「なんでアラートが鳴らなかったの？」がまとめて消え、代わりに残ったのは、小さなファイルが届いたらそれを1つ読むだけのハンドラー1つです。とはいえ、本題はアップロードのほうでした。写真はネットワークが許す限りの速さで上がり、その1枚1枚についてサーバーが今でも最終決定権を持ち、バケットとバックエンドのあいだに橋を架ける必要は誰にもありませんでした。バケットが、何が起きたかを自分で言ってくれるようになったからです。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
