[{"content":"TL;DR 複数のインターフェースを持つマシンに、「自分のIP」という1つの答えはありません。役に立つ問いは、その宛先に行くとき、どのローカルアドレスを使うかです。 Linuxでは、UDPソケットに connect() してから getsockname() を呼べば答えが分かります。UDPにはハンドシェイクがないので、connect() は経路の検索を行って相手を覚えるだけで、僕のテストではIPv4もARPのフレームも外に出ませんでした。 Wi-Fi相当、VPN相当、コンテナのブリッジ相当の3つのインターフェースで、5つの宛先から経路表と一致する5つの答えが得られました。VPNがデフォルトルートを取ると、公開アドレスにはVPNのアドレスが返り、Wi-Fiのサブネット内のアドレスにはWi-Fiのアドレスが返りました。 デフォルトルートがないと、公開アドレスの宛先は ENETUNREACH(「Network is unreachable」)になります。経路表に外へ出る道がない、ということは分かります。逆に、このエラーが出ないからといって、インターネットに届くとは限りません。 マニュアルページに書かれているのはデータグラムソケットの connect() についてで、送信元アドレスがそのとき決まるとは書かれていません。そこはLinuxで観察した挙動で、試したのもLinuxだけです。 問題 「スマホでこのURLを開いてください」と表示したい、あるいはローカルネットワークにサービスを広告したい。誘惑されるワンライナーは、マシンのホスト名を解決してその結果を使うものです。Wi-Fi、VPN、コンテナのブリッジがあるノートPCでは、ホスト名の設定次第で、ループバック、コンテナのアドレス、VPNのアドレスのどれかが返りえます。問いは宛先について立てる必要があります。\n小技 import socket def source_ip_for(dest: str) -\u0026gt; str: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect((dest, 9)) # 経路の検索と相手の記憶だけ。何も送らない return s.getsockname()[0] finally: s.close() SOCK_DGRAM ソケットについて、connect(2) は、そのアドレスが「データグラムがデフォルトで送られる宛先であり、データグラムを受け取る唯一のアドレス」だと書いています(connect(2))。udp(7) も、デフォルトの宛先について同じことを書いています。そのために送る必要があるものは、UDPにはありません。例のポート(9)は一度も使われず、宛先が存在する必要もありません。マニュアルページが書いていないのは、このときカーネルが送信元アドレスも固定する、ということです。それがLinuxで観察したことであり、この小技が動く理由です。\nインターフェース3つのホスト 確かめるために、ネットワーク名前空間の中に、ノートPCのように見える3つのインターフェースを持つホストを作りました。wlan0(192.168.1.5/24、デフォルトルートは192.168.1.1経由)、br0(172.17.0.1/16、コンテナのブリッジ)、tun0(10.8.0.2/24、VPN)です。実在のものには何も送られません。インターフェースはvethのペアです。\nimport socket, sys def source_ip_for(dest: str, family=socket.AF_INET) -\u0026gt; str: \u0026#34;\u0026#34;\u0026#34;The local address the kernel would use to reach `dest`. Sends nothing.\u0026#34;\u0026#34;\u0026#34; s = socket.socket(family, socket.SOCK_DGRAM) try: s.connect((dest, 9)) # UDP connect = route lookup + remember the peer; no packet leaves return s.getsockname()[0] finally: s.close() if __name__ == \u0026#34;__main__\u0026#34;: for dest in sys.argv[1:]: try: print(f\u0026#34;{dest:\u0026gt;14} -\u0026gt; {source_ip_for(dest)}\u0026#34;) except OSError as e: print(f\u0026#34;{dest:\u0026gt;14} -\u0026gt; OSError: {e.strerror} (errno {e.errno})\u0026#34;) #!/bin/bash # Needs root (ip netns). Builds a host with a Wi-Fi-like interface, a VPN-like interface and a docker-like bridge. set -e HERE=$(cd \u0026#34;$(dirname \u0026#34;$0\u0026#34;)\u0026#34; \u0026amp;\u0026amp; pwd) ip netns del b1 2\u0026gt;/dev/null || true ip netns add b1 mk() { ip link add name $1 type veth peer name $1p; ip link set $1 netns b1; ip netns exec b1 ip addr add $2 dev $1; ip netns exec b1 ip link set $1 up; ip link set $1p up; } mk wlan0 192.168.1.5/24 mk br0 172.17.0.1/16 mk tun0 10.8.0.2/24 ip netns exec b1 ip link set lo up ip netns exec b1 ip route add default via 192.168.1.1 dev wlan0 tx() { ip netns exec b1 cat /sys/class/net/{wlan0,br0,tun0}/statistics/tx_packets | tr \u0026#39;\\n\u0026#39; \u0026#39; \u0026#39;; } echo \u0026#34;interfaces:\u0026#34;; ip netns exec b1 ip -br addr | grep -v \u0026#39;^lo\u0026#39; echo \u0026#34;routes:\u0026#34;; ip netns exec b1 ip route | sed \u0026#39;s/^/ /\u0026#39; echo echo \u0026#34;tx_packets before (wlan0 br0 tun0): $(tx)\u0026#34; echo \u0026#34;-- default route is the Wi-Fi gateway\u0026#34; ip netns exec b1 python3 \u0026#34;$HERE/lan_ip.py\u0026#34; 8.8.8.8 192.0.2.1 192.168.1.77 172.17.0.9 10.8.0.50 echo \u0026#34;tx_packets after: $(tx)\u0026#34; echo \u0026#34;-- control: a sniffer on the other end of each interface for 2 s, with NO lookups at all\u0026#34; sleep 3 # let the interfaces finish their own IPv6 housekeeping first python3 \u0026#34;$HERE/sniff.py\u0026#34; 2 wlan0p br0p tun0p echo \u0026#34;-- the same sniffer while the five lookups run\u0026#34; python3 \u0026#34;$HERE/sniff.py\u0026#34; 2 wlan0p br0p tun0p \u0026amp; SN=$!; sleep 0.5 ip netns exec b1 python3 \u0026#34;$HERE/lan_ip.py\u0026#34; 8.8.8.8 192.0.2.1 192.168.1.77 172.17.0.9 10.8.0.50 \u0026gt;/dev/null wait $SN echo echo \u0026#34;-- the VPN takes over the default route\u0026#34; ip netns exec b1 ip route replace default dev tun0 ip netns exec b1 python3 \u0026#34;$HERE/lan_ip.py\u0026#34; 8.8.8.8 192.168.1.77 echo echo \u0026#34;-- no default route at all\u0026#34; ip netns exec b1 ip route del default ip netns exec b1 python3 \u0026#34;$HERE/lan_ip.py\u0026#34; 8.8.8.8 192.168.1.77 echo echo \u0026#34;-- tx_packets at the end: $(tx)\u0026#34; ip netns del b1 for i in wlan0p br0p tun0p; do ip link del $i 2\u0026gt;/dev/null || true; done import socket, sys, time, collections, select # usage: sniff.py \u0026lt;seconds\u0026gt; \u0026lt;iface\u0026gt;... counts every frame arriving on the given (host-side) interfaces, by Ethernet type secs, ifaces = float(sys.argv[1]), sys.argv[2:] socks = {} for i in ifaces: s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(3)); s.bind((i, 0)); socks[s] = i seen = collections.Counter(); end = time.monotonic() + secs while time.monotonic() \u0026lt; end: r, _, _ = select.select(list(socks), [], [], 0.1) for s in r: f = s.recv(2048); et = int.from_bytes(f[12:14], \u0026#39;big\u0026#39;) what = f\u0026#34;0x{et:04x}\u0026#34; if et == 0x86dd: # IPv6: show the next-header byte, and the ICMPv6 type if it is ICMPv6 what += f\u0026#34; ipv6 next-header={f[20]}\u0026#34; + (f\u0026#34; icmpv6-type={f[54]}\u0026#34; if f[20] == 58 else \u0026#34;\u0026#34;) seen[(socks[s], what)] += 1 print(\u0026#34;frames seen:\u0026#34;, dict(seen) if seen else \u0026#34;none\u0026#34;) 1回の実行の出力です(rootが必要で、スクリプトが名前空間と3つのvethペアを作って消します)。\ninterfaces: wlan0@if68 UP 192.168.1.5/24 fe80::1d:81ff:feab:5f0e/64 br0@if70 UP 172.17.0.1/16 fe80::488:d7ff:fefa:fb7a/64 tun0@if72 UP 10.8.0.2/24 fe80::5480:f9ff:fe59:a0e7/64 routes: default via 192.168.1.1 dev wlan0 10.8.0.0/24 dev tun0 proto kernel scope link src 10.8.0.2 172.17.0.0/16 dev br0 proto kernel scope link src 172.17.0.1 192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.5 tx_packets before (wlan0 br0 tun0): 1 1 0 -- default route is the Wi-Fi gateway 8.8.8.8 -\u0026gt; 192.168.1.5 192.0.2.1 -\u0026gt; 192.168.1.5 192.168.1.77 -\u0026gt; 192.168.1.5 172.17.0.9 -\u0026gt; 172.17.0.1 10.8.0.50 -\u0026gt; 10.8.0.2 tx_packets after: 1 1 1 -- control: a sniffer on the other end of each interface for 2 s, with NO lookups at all frames seen: {(\u0026#39;wlan0p\u0026#39;, \u0026#39;0x86dd ipv6 next-header=58 icmpv6-type=133\u0026#39;): 1} -- the same sniffer while the five lookups run frames seen: {(\u0026#39;br0p\u0026#39;, \u0026#39;0x86dd ipv6 next-header=58 icmpv6-type=133\u0026#39;): 2, (\u0026#39;tun0p\u0026#39;, \u0026#39;0x86dd ipv6 next-header=58 icmpv6-type=133\u0026#39;): 2, (\u0026#39;wlan0p\u0026#39;, \u0026#39;0x86dd ipv6 next-header=58 icmpv6-type=133\u0026#39;): 1} -- the VPN takes over the default route 8.8.8.8 -\u0026gt; 10.8.0.2 192.168.1.77 -\u0026gt; 192.168.1.5 -- no default route at all 8.8.8.8 -\u0026gt; OSError: Network is unreachable (errno 101) 192.168.1.77 -\u0026gt; 192.168.1.5 -- tx_packets at the end: 7 7 7 分かることです。\n各宛先には、経路表が使うはずのインターフェースのアドレスが返ります。デフォルトルート経由の公開アドレスも、サブネット内のアドレスも同じです。 IPv4のものは、何もマシンの外に出ませんでした。各インターフェースの反対側で、5回の検索の最中に2秒間動かしたスニファー(rawパケットソケット)が見たのは、ICMPv6のタイプ133、つまりルーター要請だけでした。新しく作ったインターフェース上での、カーネル自身のIPv6の後始末です。検索をまったくしないで同じスニファーを2秒動かして確かめたところ、ルーター要請は同じように出ます。ラボをさらに3回動かしても、検索の有無によらず現れました。どの回でも、IPv4(0x0800)もARP(0x0806)のフレームも見ていません。(インターフェースの送信カウンターは、きれいな検証にはなりません。ある回では1つ増えましたが、それも同じルーター要請です。) VPNがデフォルトルートを置き換えると、公開アドレスの宛先にはVPNのアドレスが返りましたが、Wi-Fiのサブネットの宛先にはWi-Fiのアドレスが返りました。**どの宛先について尋ねるかが、答えを決めます。**到達したい相手を代表する宛先を選んでください。「LAN上の自分のアドレス」なら、そのLANのサブネット内のアドレス、「外に出るときのアドレス」なら公開アドレスです。 デフォルトルートがないと、公開アドレスの宛先は、connect(2) のエラーの1つとして挙げられている ENETUNREACH になりました。その宛先への経路が経路表にない、ということをプログラムに伝えますが、接続性のテストではありません。 宛先の選び方 知りたいこと connectする宛先 同じLAN上の他のマシンが到達できるアドレス そのLANのサブネット内のアドレス(たとえばゲートウェイ)。VPNがデフォルトルートを持っていると、公開アドレスにはVPNのアドレスが返る インターネットに出るときのアドレス 公開アドレス(実験では 8.8.8.8 を使いました。経路のあるアドレスなら何でもよく、実際には何も送られません) 経路表に外へ出る道があるか 公開アドレスにして、OSError を捕まえる(ここではerrno 101)。パケットが通るかどうかは分かりません 特定のインターフェース この小技ではありません。そのインターフェースのアドレスにbindするか、インターフェースを列挙します よくある失敗 gethostbyname(gethostname()) を使う。 症状: 127.0.1.1 やコンテナのアドレスが返ることがある。ホストの設定に依存し、経路とは無関係です。直し方: 宛先について尋ねる。名前空間では試していません。 「LANアドレス」を知りたいのに、公開アドレスを宛先にする。 症状: VPNがつながっていると、VPNのアドレスが返る(再現しました)。直し方: LANのサブネット内の宛先を選ぶ。 OSError を扱わない。 症状: オフラインのときにプログラムが落ちる(再現しました。errno 101)。直し方: 捕まえて、「オフライン」の意味を決める。 答えが安定していると思う。 ネットワークが変わると結果も変わります(Wi-Fiから有線、VPNの接続)。起動時に1回ではなく、必要な瞬間に聞き直します。 どこでも同じように動くと思う。 試したのはLinuxだけです。他のOSでは違うかもしれません。確認してください。 誰も到達できないアドレスを広告する。 得られるアドレスは、カーネルが送信元として使うものです。相手がそこへ到達できる(ファイアウォール、NAT、Wi-Fiのクライアント分離)ことは教えません。また、到達できるようにサーバーを全インターフェースにbindすると、すべてのネットワークに公開されます。既定はループバックにして、LANへの公開は明示的な選択にしてください。 試してみる lan_ip.py を保存し、自分のマシンで python3 lan_ip.py 8.8.8.8 192.168.1.1 を実行します。成功なら、各行が、その宛先への経路を持つインターフェースのアドレスを示します。ネットワークを切ってもう一度実行すると、エラーを見られます。 実験全体は、Linuxで ip が使える環境で、lan_ip.py と sniff.py を隣に置いて sudo bash lab.sh を実行します。名前空間 b1 とvethペア wlan0、br0、tun0(と対の …p)を作り、最後に消します。 VPNをつないで、手順1を、公開アドレスとLANのゲートウェイで実行し直します。先に予想してから、結果と比べてください。 確認の限界 確認できたこと: 1台のLinuxマシン(カーネル6.12、Python 3.13)で、vethペアを使ったネットワーク名前空間の中で、上の出力を1回の実行で、表示されたとおりに確認しました(スニファーの対照実験と検索は、さらに3回繰り返しました)。connect(2) と udp(7) のマニュアルページは、書く際にman7.orgで読みました。\n確認できていないこと: macOS、Windows、BSD、IPv6(スクリプトはIPv4専用です)、実際のWi-Fi、VPNソフトウェア、コンテナ(インターフェースはvethペアで作った模造品です)、ポリシールーティングや複数の経路表があるマシン、1つのインターフェースに複数のアドレスがあるホスト、そして「何も送られない」という主張のうち、上のスニファー以外の部分(スニファーが見たのは、検索の有無によらず出るカーネル自身のルーター要請だけです。システムコールのトレースは取っていません)。\n宛先について尋ねる マシンに「あなたは誰か」と聞かない。「あの相手と話すなら、どのアドレスを使うか」と聞いて、経路表に答えさせます。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/udp-connect-local-ip/","summary":"Wi-Fi、VPN、コンテナのブリッジがあるマシンにはアドレスが複数あり、ホスト名の解決では間違ったものが返ることがあります。UDPソケットを宛先にconnectすると、カーネルがその宛先に使う送信元アドレスを決め、getsockname()でパケットを1つも送らずに読み出せます。Linuxで3つのインターフェースを使って確かめ、5つの宛先に対して5つの答えが得られ、IPv4とARPのフレームは流れませんでした。","title":"UDPのconnect()は何も送らず、getsockname()で自分のIPが分かる"},{"content":"TL;DR デーモンの入れ替えには4つの別々の仕事があります。新しい接続を拒否しないこと、処理中のリクエストを終わらせること、悪いリリースを検出して戻すこと、そして状態を安全に引き継ぐことです。それぞれ道具が違います。 ハンマーテスト(1秒に約100本の新規接続を5秒間、2秒の時点で再起動。方式ごとに5回ずつを2セット)では、自分でソケットをbindするサーバーを systemctl restart すると、毎回エラーが出ました。1回あたり接続拒否が4〜37件、リセットが1〜3件です。v2をv1の隣に SO_REUSEPORT で起動してからv1を止めると、拒否は0件で、リセットは1セット目で5回中2回、2セット目で5回中3回出ました。ソケットアクティベーション(systemdが待ち受けソケットを持つ)は、10回すべてでエラーなしでした。 ソケットアクティベーションの代償は、新しいプロセスが起動する間、接続が待たされることです。この実験で最も遅かったリクエストは58〜285ms(典型的には約250ms)で、他の方式では1〜16msでした。 並走の実行で出たリセットは、閉じる待ち受けの受け付けキューで待っていた接続を、カーネルが中断したものです。net.ipv4.tcp_migrate_req=1(Linux 5.14以降)にすると、16回中0回でリセットが出ました。既定の 0 では、8回中5回で出ました(このマシンの使い捨てネットワーク名前空間で確認)。 ドレイン: 処理中の3秒のリクエストは、既定の停止タイムアウトなら再起動を生き延びました。TimeoutStopSec=1 では殺されました。ドレイン中のプロセスが EXTEND_TIMEOUT_USEC を送り続けると、そのタイムアウトを超えて生き延びました。 アトミックなシンボリックリンクの切り替えと、期待したバージョンを要求する健全性確認、自動巻き戻しの組み合わせは、正常なリリースを維持し、起動時にクラッシュするものと、起動はするが誤った答えを返すものの両方を巻き戻しました。 すべて、Linux上の使い捨ての名前空間の中で、本物のsystemd 257をrootで動かして確認しました。状態の引き継ぎは説明だけで、実行していません。 「入れ替え」が答えるべきこと サービスの再起動は1コマンドです。誰にも気づかれずに入れ替えるには、4つの問いに順に答える必要があります。\n古いプロセスが待ち受けをやめ、新しいプロセスがまだ起動していない間に、到着する接続はどこへ行くのか。 古いプロセスが処理中のリクエストはどうなるのか。 新しいバージョンが動いていて、健全だと、どうやって知るのか。 そうでなければ、古いものをどうやって戻すのか。 状態の引き継ぎ(新バージョンが古いバージョンから知るべきこと)は、サービスによっては5つ目の問いです。\n使ったサーバーは小さなものです。接続を受け、1行読み、設定した時間だけ「仕事」をして、バージョンとpidを返します。SIGTERM を受けると受け付けをやめ、ドレインして、終了します。自分でソケットをbind(必要なら SO_REUSEPORT つき)することも、systemdからもらうこともでき、準備完了を伝えたり停止時間の延長を頼んだりする程度の sd_notify も話せます。\n#!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34;A small line-based server that can be replaced while it serves. Env: VERSION (label in replies), WORK (seconds of \u0026#34;work\u0026#34; per request), REUSEPORT=1 (bind with SO_REUSEPORT), EXTEND=1 (ask the service manager for more stop time while requests are still running). Under socket activation (LISTEN_FDS) the listening socket is inherited and never re-created.\u0026#34;\u0026#34;\u0026#34; import os, signal, socket, sys, threading, time VERSION = os.environ.get(\u0026#34;VERSION\u0026#34;, \u0026#34;v1\u0026#34;) WORK = float(os.environ.get(\u0026#34;WORK\u0026#34;, \u0026#34;0\u0026#34;)) PORT = int(os.environ.get(\u0026#34;PORT\u0026#34;, \u0026#34;9100\u0026#34;)) def notify(msg): # minimal sd_notify(3) path = os.environ.get(\u0026#34;NOTIFY_SOCKET\u0026#34;) if not path: return s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) s.connect(\u0026#34;\\0\u0026#34; + path[1:] if path[0] == \u0026#34;@\u0026#34; else path); s.send(msg.encode()); s.close() def listener(): if os.environ.get(\u0026#34;LISTEN_PID\u0026#34;) == str(os.getpid()) and int(os.environ.get(\u0026#34;LISTEN_FDS\u0026#34;, \u0026#34;0\u0026#34;)) \u0026gt;= 1: return socket.socket(fileno=3), \u0026#34;inherited from the service manager\u0026#34; s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) if os.environ.get(\u0026#34;REUSEPORT\u0026#34;): s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) s.bind((\u0026#34;127.0.0.1\u0026#34;, PORT)); s.listen(128) return s, \u0026#34;bound by myself\u0026#34; lock = threading.Lock(); inflight = 0; stopping = False def handle(c): global inflight try: c.recv(100); time.sleep(WORK); c.sendall(f\u0026#34;{VERSION} {os.getpid()}\\n\u0026#34;.encode()) except OSError: pass finally: c.close() with lock: inflight -= 1 def on_term(*_): global stopping; stopping = True signal.signal(signal.SIGTERM, on_term) srv, how = listener(); srv.settimeout(0.05) print(f\u0026#34;{VERSION} pid={os.getpid()} listening ({how})\u0026#34;, flush=True) notify(\u0026#34;READY=1\u0026#34;) while not stopping: try: c, _ = srv.accept() except socket.timeout: continue with lock: inflight += 1 threading.Thread(target=handle, args=(c,), daemon=True).start() srv.close() # stop accepting, then drain print(f\u0026#34;{VERSION} pid={os.getpid()} SIGTERM: draining {inflight} request(s)\u0026#34;, flush=True) notify(\u0026#34;STOPPING=1\u0026#34;) while inflight: if os.environ.get(\u0026#34;EXTEND\u0026#34;): notify(\u0026#34;EXTEND_TIMEOUT_USEC=2000000\u0026#34;) time.sleep(0.2) print(f\u0026#34;{VERSION} pid={os.getpid()} drained, exiting\u0026#34;, flush=True) 負荷生成器は、4スレッドで1秒に約100回、実行時間の間ずっと新しい接続を開き、各接続が何を受け取ったかを数えます。\n#!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34;Open a new connection ~100x/s from 4 threads for DURATION seconds; count what happened.\u0026#34;\u0026#34;\u0026#34; import collections, socket, sys, threading, time DURATION = float(sys.argv[1]); PORT = 9100 res = collections.Counter(); first_err = []; slowest = [0.0]; t0 = time.monotonic(); lock = threading.Lock() def one(): c = socket.socket(); c.settimeout(8); t = time.monotonic() try: c.connect((\u0026#34;127.0.0.1\u0026#34;, PORT)); c.sendall(b\u0026#34;GET\\n\u0026#34;) data = c.recv(100) out = data.decode().split()[0] if data else \u0026#34;EOF-without-reply\u0026#34; except Exception as e: out = type(e).__name__ finally: c.close() with lock: slowest[0] = max(slowest[0], time.monotonic() - t) res[out] += 1 if out not in (\u0026#34;v1\u0026#34;, \u0026#34;v2\u0026#34;, \u0026#34;v3\u0026#34;): first_err.append(round(time.monotonic() - t0, 2)) def loop(): while time.monotonic() - t0 \u0026lt; DURATION: one(); time.sleep(0.03) ts = [threading.Thread(target=loop) for _ in range(4)]; [t.start() for t in ts]; [t.join() for t in ts] print(\u0026#34; results:\u0026#34;, dict(res), \u0026#34;| errors between\u0026#34;, (min(first_err), max(first_err)) if first_err else \u0026#34;-\u0026#34;, \u0026#34;s | slowest request\u0026#34;, round(slowest[0]*1000), \u0026#34;ms\u0026#34;) ステップ1: 停止の意味と、ドレイン systemdがサービスを止めるときは、メインプロセスに SIGTERM を送って終了を待ち、TimeoutStopSec までに終了しなければ、「SIGKILL で強制終了される」とあります(systemd.service(5))。処理中の仕事を終わらせたいサーバーは、(a)受け付けをやめ、(b)持っている仕事を終わらせ、(c)その時間内に収めるか、延長を頼む必要があります。\n上のサーバーは(a)と(b)をやります。待ち受けを閉じ、処理中のハンドラーを待ちます。(c)については、マニュアルによれば、Type=notify のサービスが EXTEND_TIMEOUT_USEC=... を送ると、停止時間を TimeoutStopSec より延ばせます。最初のメッセージはタイムアウトを超える前に届く必要があり、指定された間隔のうちに繰り返すか、サービス自身が終了する必要があります(systemd.service(5)、sd_notify(3))。このサーバーは EXTEND=1 のとき、ドレイン中に0.2秒ごとに送ります。\n3秒のリクエストを1件処理中にして、1秒後にサービスを再起動します。\n#!/bin/bash # usage: drain.sh \u0026lt;label\u0026gt; \u0026lt;TimeoutStopSec\u0026gt; \u0026lt;EXTEND 0|1\u0026gt; : one 3-second request is in flight when the service is restarted echo \u0026#34;=== $1 (TimeoutStopSec=$2, EXTEND_TIMEOUT_USEC sent: $( [ \u0026#34;$3\u0026#34; = 1 ] \u0026amp;\u0026amp; echo yes || echo no ))\u0026#34; systemctl stop web.socket web.service web-b.service 2\u0026gt;/dev/null cat \u0026gt; /run/systemd/system/web.service \u0026lt;\u0026lt;EOT [Unit] Description=swapdemo drain [Service] Type=notify Environment=VERSION=v1 WORK=3 $( [ \u0026#34;$3\u0026#34; = 1 ] \u0026amp;\u0026amp; echo EXTEND=1 ) TimeoutStopSec=$2 ExecStart=/usr/bin/python3 /run/swapdemo/srv.py EOT systemctl daemon-reload; systemctl start web.service; sleep 0.5 python3 - \u0026lt;\u0026lt;\u0026#39;PY\u0026#39; \u0026amp; import socket, time t = time.monotonic(); c = socket.socket(); c.connect((\u0026#34;127.0.0.1\u0026#34;, 9100)); c.sendall(b\u0026#34;GET\\n\u0026#34;) try: d = c.recv(100); print(f\u0026#34; client: {\u0026#39;reply \u0026#39; + d.decode().strip() if d else \u0026#39;connection closed with no reply\u0026#39;} after {time.monotonic()-t:.1f}s\u0026#34;) except Exception as e: print(f\u0026#34; client: {type(e).__name__} after {time.monotonic()-t:.1f}s\u0026#34;) PY P=$! sleep 1; t=$(date +%s.%N); systemctl restart web.service; echo \u0026#34; restart returned after $(python3 -c \u0026#34;import time;print(f\u0026#39;{time.time()-$t:.1f}\u0026#39;)\u0026#34;)s\u0026#34; wait $P journalctl -u web --no-pager -o cat --since \u0026#34;-8s\u0026#34; | grep -E \u0026#39;SIGTERM|drained|killed|timed out|Failed|SIGKILL|signal\u0026#39; | sed \u0026#39;s/^/ journal: /\u0026#39; === default timeout (TimeoutStopSec=90, EXTEND_TIMEOUT_USEC sent: no) client: reply v1 47537 after 3.0s restart returned after 2.1s === short timeout, no extension (TimeoutStopSec=1, EXTEND_TIMEOUT_USEC sent: no) client: connection closed with no reply after 2.1s restart returned after 1.2s === short timeout, extended while draining (TimeoutStopSec=1, EXTEND_TIMEOUT_USEC sent: yes) client: reply v1 47611 after 3.0s restart returned after 2.1s (スクリプトの末尾にある journal: の行は省きました。時間の範囲に以前の実行の行が混ざったためです。)各1回の実行です。既定のときは、応答に3.0秒かかり、再起動はドレインを約2.1秒待ちました。1秒の上限で延長なしのときは、2.1秒後に返信なしで接続が閉じられました。終わる前にプロセスが殺されたのです。同じ1秒の上限で延長ありのときは、3.0秒後に応答が届きました。長いリクエストを持つサービスにとってはこれが調整点です。大きな固定の TimeoutStopSec は、通常の停止やハングしたプロセスの停止まで遅くしますが、延長なら本当にドレイン中の間だけ待つ代償で済みます。\nステップ2: 旧と新の間のすき間 ドレインが守るのは、すでに到着したリクエストです。古いプロセスが待ち受けを閉じてから、新しいプロセスが自分の待ち受けを開くまでの間に到着する接続には、何もしません。これを埋める3つの方法を、ハンマー5回ずつ、2秒の時点で入れ替えて測りました。\n#!/bin/bash # plain service, bind itself cat \u0026gt; /run/systemd/system/web.service \u0026lt;\u0026lt;EOT [Unit] Description=swapdemo app [Service] Type=notify Environment=VERSION=v1 REUSEPORT=1 ExecStart=/usr/bin/python3 /run/swapdemo/srv.py EOT cat \u0026gt; /run/systemd/system/web-b.service \u0026lt;\u0026lt;EOT [Unit] Description=swapdemo app (new version, same port) [Service] Type=notify Environment=VERSION=v2 REUSEPORT=1 ExecStart=/usr/bin/python3 /run/swapdemo/srv.py EOT cat \u0026gt; /run/systemd/system/web.socket \u0026lt;\u0026lt;EOT [Socket] ListenStream=127.0.0.1:9100 EOT systemctl daemon-reload #!/bin/bash # usage: run.sh \u0026lt;label\u0026gt; \u0026#39;\u0026lt;start command\u0026gt;\u0026#39; \u0026#39;\u0026lt;what to do at t=2s\u0026gt;\u0026#39; echo \u0026#34;=== $1\u0026#34; systemctl stop web.socket web.service web-b.service 2\u0026gt;/dev/null; sleep 0.5 eval \u0026#34;$2\u0026#34;; sleep 1 python3 /run/swapdemo/hammer.py 5 \u0026amp; H=$! sleep 2 eval \u0026#34;$3\u0026#34; wait $H echo \u0026#34; journal:\u0026#34;; journalctl -u web -u web-b --no-pager -o cat --since \u0026#34;-12s\u0026#34; 2\u0026gt;/dev/null | grep -v -E \u0026#39;Started|Stopped|Starting|Stopping|Deactivated|Consumed|Succeeded|Finished\u0026#39; | sed \u0026#39;s/^/ /\u0026#39; #!/bin/bash # usage: bench.sh plain|overlap|socket [rounds] (units.sh must have been run once; run.sh and hammer.py are in the same directory) D=$(dirname \u0026#34;$0\u0026#34;) for i in $(seq 1 \u0026#34;${2:-5}\u0026#34;); do case $1 in plain) $D/run.sh \u0026#34;plain restart, run $i\u0026#34; \u0026#39;systemctl start web.service\u0026#39; \u0026#39;systemctl restart web.service\u0026#39; ;; overlap) $D/run.sh \u0026#34;start v2 beside v1, run $i\u0026#34; \u0026#39;systemctl start web.service\u0026#39; \u0026#39;systemctl start web-b.service; sleep 0.3; systemctl stop web.service\u0026#39; ;; socket) $D/run.sh \u0026#34;socket activation, run $i\u0026#34; \u0026#39;systemctl start web.socket\u0026#39; \u0026#39;systemctl restart web.service\u0026#39; ;; esac done ハンマーは実行ごとに結果別の件数を表示します(毎回約650件)。各方式の5回分を、出力順にまとめます。\n方式 接続拒否(各回) リセット(各回) その他 最も遅いリクエスト 自分でbindするサーバーの systemctl restart 5, 10, 34, 30, 4 1, 1, 1, 2, 3 返信なしで閉じられた接続が1件(1回目) 1〜16ms v1の隣にv2を起動(SO_REUSEPORT)し、0.3秒後にv1を停止 0, 0, 0, 0, 0 1, 0, 1, 0, 0 なし 1〜10ms ソケットアクティベーション、サービスを systemctl restart 0, 0, 0, 0, 0 0, 0, 0, 0, 0 なし 248〜262ms 単純な再起動の実行では、エラーはすべて再起動の開始から約4分の1秒以内に出ました(ハンマーは2.0〜2.24秒の間と報告し、再起動は2秒の時点です)。ソケットアクティベーションは、5回のどれでもエラーがありませんでした。\n後日、同じスクリプトで方式ごとに5回ずつをもう1セット動かした結果です。単純な再起動は、接続拒否が33、4、37、9、9件で、リセットは2、3、2、2、2件でした。並走は拒否ゼロで、リセットは5回中3回(0、2、1、0、1件)。ソケットアクティベーションはエラーなしで、最も遅いリクエストは267、248、255、285、58msでした。順序は同じでした。件数と、並走のどの回でリセットが出るかは、同じではありませんでした。\nそれぞれについてです。\n単純な再起動。 古いプロセスがソケットを閉じてから新しいプロセスがbindするまでの間は、そのポートに待ち受けがなく、カーネルが接続を拒否しました。閉じたポートの普通の挙動で、数字が示すのは、このリクエスト率で数十ミリ秒の窓に何本の接続が入るか、ということだけです。 SO_REUSEPORT での並走。 両バージョンが同時に同じポートで待ち受けるので、待ち受けがない瞬間はありません。接続拒否は出ませんでした。ただし、一部の回で、v1が待ち受けを閉じたあたりの瞬間(開始から2.3秒の時点。新バージョンが2秒で起動し、0.3秒後に旧バージョンが止まります)に、接続が1本リセットされました。原因は次の小節で確かめます。 ソケットアクティベーション。 systemdが待ち受けソケットを持ち、ファイルディスクリプタ3として、LISTEN_FDS と LISTEN_PID つきでサービスに渡します(sd_listen_fds(3))。Accept=no(既定)では、「すべての待ち受けソケット自体が、起動されたサービスユニットに渡され、全接続に対してサービスユニットは1つだけ起動される」とあります(systemd.socket(5))。古いプロセスが終了しても待ち受けはsystemdの中で開いたままなので、到着した接続は拒否されず、キューに並びます。248〜262msの最遅リクエストの理由がこれで、新しいプロセスが起動する間、接続が待っていたのです。 並走でなぜ接続が1本落ちたのか カーネルのドキュメントが、まさにこの現象を説明しています。接続は、SYNが届いた時点で1つの待ち受けソケットに結びつきます。その待ち受けが閉じられると、ハンドシェイクの途中の接続と、受け付けキューで待っている確立済みの接続は、同じ SO_REUSEPORT グループの別の待ち受けが受けられたはずでも、中断されます。net.ipv4.tcp_migrate_req を有効にすると、カーネルがそれらを別の待ち受けへ移します。既定は 0 で、この設定はLinux 5.14で入りました(ip-sysctl)。reuseportのグループを作る前に有効にしておく必要があります。\nもっともらしい話のままにしたくなかったので、確かめました。systemdなしで、同じサーバーとハンマーを、新しいネットワーク名前空間の中で動かしています(この設定は名前空間ごとなので、ホストには触れません)。スクリプトは、設定ごとにv1をv2へ8回入れ替えます。タイミングは上と同じです。\n#!/bin/bash # usage: migrate.sh \u0026lt;0|1\u0026gt; Run as root inside a fresh network namespace, e.g. # ip netns add mig; ip netns exec mig bash migrate.sh 0 # Sets net.ipv4.tcp_migrate_req (per network namespace), then replaces v1 by v2 eight times under load, # with both listening on one port via SO_REUSEPORT. srv.py and hammer.py must be in the same directory. HERE=$(cd \u0026#34;$(dirname \u0026#34;$0\u0026#34;)\u0026#34; \u0026amp;\u0026amp; pwd) ip link set lo up sysctl -qw net.ipv4.tcp_migrate_req=$1 echo \u0026#34;tcp_migrate_req=$(cat /proc/sys/net/ipv4/tcp_migrate_req)\u0026#34; for i in 1 2 3 4 5 6 7 8; do VERSION=v1 REUSEPORT=1 python3 $HERE/srv.py \u0026gt;/dev/null \u0026amp; P1=$! sleep 0.7 python3 $HERE/hammer.py 4 \u0026amp; H=$! sleep 2 VERSION=v2 REUSEPORT=1 python3 $HERE/srv.py \u0026gt;/dev/null \u0026amp; P2=$! sleep 0.3 kill -TERM $P1 # v1 closes its listener; v2 keeps listening wait $H kill -TERM $P2; wait $P1 $P2 2\u0026gt;/dev/null done ハンマーがリセットを1回以上見た回を数えた結果です。\ntcp_migrate_req リセットが出た回 リセットの合計 0(既定) 8回中5回 7 1 8回中0回 0 同じ種類の名前空間で、より長く動かした最初のパスでも、0 では16回中11回、1 では16回中0回でした。つまりこのカーネル(Linux 6.12)では、並走の実行で出たリセットは受け付けキューで中断された接続で、このsysctlでそれは消えます。\n注意が2つあります。同じカーネルのページに、設定の違う待ち受けの間で移すとアプリケーションが壊れうる、という警告があります。読まずに多数のマシンで有効にしないでください。またこれはカーネル全体(名前空間ごと)の設定で、デプロイのスクリプトが持つものではないのが普通です。もう1つの逃げ道は、この記事の最後の節にあるソケットアクティベーションです。待ち受けが一度も閉じないからです。\nステップ3: リリースを切り替え、確認し、巻き戻す 新しいバージョンへの再起動は、危険な半分です。僕が使う型は、わざと退屈にしてあります。\nリリースは別々のディレクトリに置き、current というシンボリックリンクがその1つを指す。 切り替えはアトミックにする。一時的な名前で新しいシンボリックリンクを作り、古いものの上に名前を変更して重ねる(mv -T)。 再起動し、サービスにどのリリースかを尋ねる。期限つきで。「答える」だけでは足りません。 確認に失敗したら、シンボリックリンクを戻して、もう一度再起動する。 #!/bin/bash # usage: swap.sh \u0026lt;release-name\u0026gt; Releases live in /run/swapdemo/rel/\u0026lt;name\u0026gt;/srv.py ; /run/swapdemo/current is a symlink. set -u new=$1; base=/run/swapdemo; unit=web.service old=$(basename \u0026#34;$(readlink $base/current)\u0026#34;) echo \u0026#34;swap: $old -\u0026gt; $new\u0026#34; healthy() { # healthy = the service answers one request AND says it is the release we expect for i in $(seq 1 20); do out=$(python3 - \u0026lt;\u0026lt;PY 2\u0026gt;/dev/null import socket c = socket.socket(); c.settimeout(1); c.connect((\u0026#34;127.0.0.1\u0026#34;, 9100)); c.sendall(b\u0026#34;GET\\n\u0026#34;); print(c.recv(100).decode().split()[0]) PY ) [ \u0026#34;$out\u0026#34; = \u0026#34;$1\u0026#34; ] \u0026amp;\u0026amp; return 0 sleep 0.25 done return 1 } ln -sfn \u0026#34;$base/rel/$new\u0026#34; \u0026#34;$base/current.tmp\u0026#34; \u0026amp;\u0026amp; mv -T \u0026#34;$base/current.tmp\u0026#34; \u0026#34;$base/current\u0026#34; # atomic switch systemctl restart $unit 2\u0026gt;/dev/null if healthy \u0026#34;$new\u0026#34;; then echo \u0026#34;swap: $new is healthy, keeping it\u0026#34;; exit 0; fi echo \u0026#34;swap: $new did not become healthy within 5 s, rolling back to $old\u0026#34; ln -sfn \u0026#34;$base/rel/$old\u0026#34; \u0026#34;$base/current.tmp\u0026#34; \u0026amp;\u0026amp; mv -T \u0026#34;$base/current.tmp\u0026#34; \u0026#34;$base/current\u0026#34; systemctl reset-failed $unit 2\u0026gt;/dev/null; systemctl restart $unit healthy \u0026#34;$old\u0026#34; \u0026amp;\u0026amp; echo \u0026#34;swap: $old is serving again\u0026#34; || echo \u0026#34;swap: ROLLBACK FAILED\u0026#34; exit 1 #!/bin/bash b=/run/swapdemo; mkdir -p $b/rel/{v1,v2,v3,v4} for v in v1 v2 v3 v4; do cp $b/srv.py $b/rel/$v/srv.py; sed -i \u0026#34;s/^VERSION = .*/VERSION = \\\u0026#34;$v\\\u0026#34;/\u0026#34; $b/rel/$v/srv.py; done # v3 crashes at start; v4 starts, but answers with the wrong label sed -i \u0026#39;s/^srv, how = listener().*/import sys; sys.exit(\u0026#34;v3 cannot start: bad config\u0026#34;)/\u0026#39; $b/rel/v3/srv.py sed -i \u0026#39;s/^VERSION = .*/VERSION = \u0026#34;v1-but-actually-broken\u0026#34;/\u0026#39; $b/rel/v4/srv.py ln -sfn $b/rel/v1 $b/current cat \u0026gt; /run/systemd/system/web.service \u0026lt;\u0026lt;EOT [Unit] Description=swapdemo release swap [Service] Type=notify Environment=REUSEPORT=1 ExecStart=/usr/bin/python3 /run/swapdemo/current/srv.py TimeoutStartSec=3 EOT systemctl daemon-reload; systemctl stop web.socket web-b.service 2\u0026gt;/dev/null; systemctl restart web.service 同じ出発点(v1が稼働中)からの3回の切り替えです。v3は起動時にクラッシュし、v4は起動しますが誤ったラベルで答えます。\nswap: v1 -\u0026gt; v2 swap: v2 is healthy, keeping it swap: v2 -\u0026gt; v3 swap: v3 did not become healthy within 5 s, rolling back to v2 swap: v2 is serving again swap: v2 -\u0026gt; v4 swap: v4 did not become healthy within 5 s, rolling back to v2 swap: v2 is serving again 確認がバージョンのラベルを比べ、「返信が来た」で止まらないのは、v4のためです。ポートが応答するかだけを見る健全性確認なら、v4を残してしまいます。この実験での「ラベル」は、サービスが自分について正直に報告できるもの(ビルドid、スキーマのバージョンなど)の代わりです。\nステップ4: 状態の引き継ぎ(ルールだけ) これは実行していません。僕が従うルールは次のとおりです。古いバージョンは、状態をファイルかデータベースにアトミックな置換で書く(一時的な名前に書いてflushし、対象の上に名前を変更して重ねる)。新しいバージョンは起動時にそれを読み、ファイルがない、または読めない場合は、失敗にせず「新規に始める」とみなす。メモリにしかないものは、プロセスが終了すれば消え、上のどの再起動方式もそれを変えません。\nどれを使うか 状況 選択 短いリクエストで、短時間の接続拒否は許される(再試行するクライアント、社内ツール) 単純な再起動に、ドレインと適切な TimeoutStopSec 新しい接続を決して拒否したくなく、1回の再起動で数百msの余分な遅延は許される ソケットアクティベーション systemdのソケットユニットが使えない、または両バージョンをしばらく並走させたい SO_REUSEPORT の並走。閉じる側の待ち受けのキューにある接続の一部がリセットされうること(ここでは5回中2回と3回)を受け入れる。net.ipv4.tcp_migrate_req=1(Linux 5.14以降。先にカーネルの警告を読むこと)にすれば防げる 長いリクエスト ドレインし、巨大な TimeoutStopSec の代わりに EXTEND_TIMEOUT_USEC を使う 悪いリリースの被害が大きい 上に関係なく、バージョンを確認する健全性確認と巻き戻しを伴う切り替え 入れ替えが失敗するところ ドレインしない。 症状: デプロイのたびに、クライアントが返信なしの閉じた接続を見る。再現しました(1秒のタイムアウトの実行)。直し方: 受け付けをやめ、処理中の仕事を終わらせ、タイムアウトに収めるか延長する。 延長を1回しか送らない。 症状: 延長が尽きると結局殺される。ドキュメントでは、メッセージは間隔内に繰り返す必要があります。このサーバーは0.2秒ごとに繰り返します。メッセージを止める場合は試していません。 バージョンを区別できない健全性確認。 症状: 壊れたリリースが残る。再現しました(v4)。直し方: 期待する識別情報を確認に含める。 存在しなくなったものへ巻き戻す。 症状: 巻き戻しも失敗する。この切り替えは古いリリースをそのまま残し、シンボリックリンクだけを切り替えます。古いリリースの掃除は、新しいものが信頼できると分かってからにします。 SO_REUSEPORT の並走が損失なしだと期待する。 症状: 引き渡しの瞬間に、まれにリセットが出る。再現し、名前空間の実験では tcp_migrate_req=1 で消えました。直し方: (カーネルの注意書きを読んだうえで)移送を有効にするか、ソケットアクティベーションを使います。 持続的なクライアント接続。 ドレインするサーバーは、アイドルのkeep-alive接続も閉じなければ、クライアントが何か送るまでドレインが待ち続けます。このサーバーは1回の返信ごとに接続を閉じるので、これは試していません。 実際のリクエスト率で試さずにデプロイする。 上の数字は、ループバックでの1秒約100本の接続のものです。皆さんの窓には、もっと多く、または少なく入ります。 自分で動かす systemdがPID 1のマシン、root、Python 3が必要です。使い捨てのVMが最適です。\nsudo mkdir -p /run/swapdemo \u0026amp;\u0026amp; sudo cp srv.py hammer.py units.sh run.sh bench.sh drain.sh swap.sh setup_rel.sh /run/swapdemo/ \u0026amp;\u0026amp; sudo chmod +x /run/swapdemo/* sudo /run/swapdemo/units.sh \u0026amp;\u0026amp; sudo /run/swapdemo/bench.sh plain 5 を実行します。成功なら、すべての回で ConnectionRefusedError の件数が出ます。 sudo /run/swapdemo/bench.sh overlap 5 と sudo /run/swapdemo/bench.sh socket 5 を実行します。成功なら、拒否はなく、socketでは最遅リクエストが数百msでエラーなしです。 sudo /run/swapdemo/drain.sh \u0026quot;a\u0026quot; 90 0、次に ... \u0026quot;b\u0026quot; 1 0、... \u0026quot;c\u0026quot; 1 1 を実行します。成功なら、応答あり、応答なし、応答ありです。 sudo /run/swapdemo/setup_rel.sh のあと、sudo /run/swapdemo/swap.sh v2、v3、v4 を実行します。 任意: srv.py と hammer.py を隣に置き、ip netns add mig; sudo ip netns exec mig bash migrate.sh 0、続けて ... migrate.sh 1 を実行すると、リセット数が変わるのを確かめられます。終わったら ip netns del mig です。 片付けは、web.service、web-b.service、web.socket を止め、/run/systemd/system のユニットファイルと /run/swapdemo を消して、systemctl daemon-reload を実行します。 このマシンで分かったこと、分からないこと 確認できたこと: 上のすべての出力を、Linux 6.12のマシンで、使い捨てのPID・マウント・cgroupの名前空間のPID 1として動くsystemd 257のもと、rootとPython 3.13で確認しました。3方式を各5回(別の実行と重なってしまった以前の1組は捨て、順番に走らせたきれいな1組を使いました)に、後日の5回をもう1セット、ドレインは各1回(同じ結果をもう一度再現)、切り替えは各1回(これも再現)、そして tcp_migrate_req の比較は、systemdなしで別のネットワーク名前空間で行いました。systemdのマニュアル、カーネルの ip-sysctl、ソケットアクティベーションのページからの引用は、書く際に元のページで読みました。\n確認できていないこと: 負荷がもっと高いときにハンドシェイク途中の接続にも tcp_migrate_req が効くか、カーネルのページにあるBPFによる移送ポリシー、持続的な接続、TLS、実際のクライアント、Accept=yes のソケットアクティベーション、延長メッセージが止まる場合の挙動、状態の引き継ぎ、他のバージョンのsystemd、本番の負荷。件数はハンマーの速度と、このマシンのプロセス起動の速さに依存するので、期待すべき数字ではなく、順序(エラーは単純な再起動 \u0026gt; 並走 \u0026gt; ソケットアクティベーション)の例として読んでください。\n接続が待つ場所 「再起動はどれだけ速いか」ではなく、「再起動の間、接続はどこで待つのか」を問います。プロセスより長生きする待ち場所を用意し、処理中のものはドレインし、新しいバージョンには、安心する前に自分の正体を証明させます。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/replace-running-daemon-without-downtime/","summary":"稼働中のデーモンをリクエストを落とさずに入れ替える手順書です。小さなTCPサーバーを本物のsystemdで動かして試しました。単純なrestartは毎回、接続の拒否かリセットを起こしました。SO_REUSEPORTで新旧を並べる方法は拒否ゼロでしたが、一部の回でリセットが出て、カーネルの設定でそれが消えました。ソケットアクティベーションはエラーなしでした。処理中リクエストのドレインとTimeoutStopSecの関係、新バージョンを確認して巻き戻すリリース切り替えも扱います。","title":"systemdの再起動中、ソケットアクティベーションなら接続は拒否されず、待たされる"},{"content":"アプリストアの審査なしにモバイルアプリの JavaScript だけの修正を配れることが、OTA アップデートの魅力です。ただし、その JavaScript は、以前にビルドされて変更できないバイナリの中で動きます。新しい JavaScript が、古いバイナリに含まれないネイティブモジュールを呼ぶと、アプリは壊れます。\nシステムプログラミングの世界では、これは別の名前で知られています。バージョン1の共有ライブラリに対してコンパイルされたプログラムを、構造体のレイアウトが変わったバージョンに対してロードすると、思わぬ場所でクラッシュします。そこでの対策が ABI バージョンです。同じ番号の実行ファイルとライブラリは組み合わせてよい、という番号です。アップデートのランタイムバージョンが、その番号にあたります。\nExpo が fingerprint ポリシーでこれを自動計算する仕組みを調べ、番号が何に反応するかを確かめました。\nTL;DR Expo のランタイムバージョンは、「ビルドのネイティブコードとアップデートの互換性を保証する属性」です。アップデートは、ランタイムバージョンの文字列が等しいビルドにだけ提供されます。仕組みはそれだけです。 手で管理する文字列は、誰かが変更を忘れるまでは機能します。文書自身の例は、ビルドにないネイティブモジュールを使うアップデートです。expo-updates は「エラーを検出して、ロールバックを試みることがある」とされています。 fingerprint ポリシーは、@expo/fingerprint で依存関係、ネイティブのファイル、設定をハッシュして文字列を導きます。バージョン 0.20.13 での僕のテストでは、JavaScript の編集と純 JS の依存は無視され、新しいネイティブモジュールは意図どおり変化を起こしました。 JavaScript に近いと思われる編集でも変わりました。extra、version、ios.buildNumber、android.versionCode です。ハッシュが変わると、新しいバイナリが出るまで、既存のインストールはアップデートを受け取れなくなります。 sourceSkips を設定すると、既定値は置き換えられます。僕のテストでは、version 系のフィールドと extra だけをスキップする設定にしたところ、package.json のスクリプトの編集でハッシュが再び変わりました。そのスクリプトに対する既定のスキップが外れたためです。 これらはすべて、1つのパッケージバージョンの動作をローカルで測ったものです。EAS、実機、アップデートサーバーは動かしていません。 モデル アップデートのプロトコルも、自分の言葉で同じことを述べています。クライアントは expo-runtime-version を送り、それは「クライアントが動かしているネイティブコードの構成を定める」ものです。マニフェストの runtimeVersion は、そのアップデートを動かすのに必要なネイティブの構成を示します。サーバーは、制約を満たす最新のアップデートを選びます。\nこのやりとりのどこでもコードは検査されません。文字列は、設定した人が立てる約束です。下のシミュレーションは、Expo も実機も使っていないので模擬と明記しますが、重要な3つの結果を示します。\n== hand-maintained runtime string, native module added, string not bumped served: u2 device result: FAILS: native module(s) missing: expo-camera == derived runtime string: same change runtime strings: installed=fp-727073d0 u2=fp-47831473 served: u1 (u2 is held back until a new binary exists) device result: ok == a JS-only value hashed into the runtime string before=fp-39a097af after=fp-28bb4ee8 equal=false -\u0026gt; every existing install stops receiving updates, though nothing native changed 導出した文字列は1つ目の失敗を防ぎ、3つ目の可能性を生みます。3つ目がどの程度起きるかは、ハッシュに何が入るかで決まります。\nfingerprint は何をハッシュするか 最小のプロジェクト(Expo 57.0.26、React Native 0.87.1、@expo/fingerprint 0.20.13)を作り、runtimeVersion: { policy: \u0026quot;fingerprint\u0026quot; }、extra.apiUrl、version、ビルド番号、バージョンコードを設定しました。fingerprint を生成し、1か所を変えてもう一度生成し、比べました。コマンドはパッケージ自身の CLI(npx @expo/fingerprint fingerprint:generate)です。Expo の文書では fingerprint ポリシーがこのパッケージを使うと説明されていますが、僕は CLI を直接呼んだだけで、ポリシー側が同じ既定の設定を適用するかは確認していません。表は、パッケージの挙動として読んでください。\n変更 ハッシュ JavaScript ファイルを編集 同じ 純 JS の依存(lodash)を追加 同じ ネイティブモジュール(expo-camera)を追加 変化 どちらかをまた削除 元のハッシュに戻る extra.apiUrl 変化 version 1.0.0 → 1.0.1 変化 ios.buildNumber 1 → 2 変化 android.versionCode 1 → 2 変化 アプリの name 変化 ios.infoPlist に利用目的の文字列を追加 変化 package.json の android スクリプトを編集(\u0026ldquo;run\u0026rdquo; を含まない) 同じ ネイティブの互換性のためのハッシュとして、パターンは理にかなっています。ネイティブプロジェクトに入るもの(表示名、権限の文言、ビルド番号)は、ハッシュを変えます。新しいネイティブモジュールは、autolinking の入力に追加するファイルを通じてハッシュを変えます。extra は違います。JavaScript が読む値の入れ物ですが、アプリ設定の一部であり、既定の構成ではハッシュに含まれます。文書の SourceSkips の表には、これを外すための別の項目 ExpoConfigExtraSection があります。\nハッシュ対象を変える方法として、文書は .fingerprintignore、fileHookTransform、extraSources、sourceSkips を挙げています。知っておく価値のある制約も書かれています。生の関数として書かれた config plugin では、関数の名前だけが fingerprint の対象になるので、無名のプラグインの中身を編集してもハッシュは変わりません。\n既定値を落とすスキップリスト sourceSkips はビットマスクか名前の配列を受け取り、文書の SourceSkips の表には ExpoConfigVersions と ExpoConfigExtraSection があります。extra の変更とバージョンの更新でハッシュが変わらないようにしたかったので、次のように書きました。\n// fingerprint.config.js module.exports = { sourceSkips: [\u0026#39;ExpoConfigVersions\u0026#39;, \u0026#39;ExpoConfigExtraSection\u0026#39;] }; 望んだ通りになりました。version、ios.buildNumber、android.versionCode、extra.apiUrl は、ハッシュを変えなくなりました。アプリの name と infoPlist の文字列は、これまでどおり変えました。それで正しいのです。ところが別の編集でもハッシュが変わりました。\npackage.json scripts.android edited (no \u0026#34;run\u0026#34;) default config: same with my sourceSkips: CHANGED 既定の構成は、この種のスクリプトをスキップします。sourceSkips を渡すと、既定のリストに足されるのではなく、置き換えられました。既定のスキップ('PackageJsonAndroidAndIosScriptsIfNotContainRun')を明示的に書き戻すと、編集はまた無視されるようになりました。文書の fingerprint.config.js の例も、この名前を ExpoConfigVersions の隣に挙げていて、今はそれがヒントだったと読めます。\n教訓は一般的です。リストを上書きする設定は、知らずに入っていた項目を消すことがあります。スキップリストを変えたら、上の表をもう一度確かめてください。\n実用的な運用 どの編集が新しいネイティブビルドを始めるべきで、どれがそうでないかを決め、書き出します。fingerprint はそれを決める手段ではなく、強制する手段です。 CI で、プルリクエストごとに fingerprint を計算し、最後に出荷したバイナリに記録されたものと比べます。fingerprint:diff は異なるソースを列挙するので、レビュー担当者に理由が伝わります。ハッシュが変わったことは「この変更には新しいストア向けビルドが要る」という意味で、レビューでそう言えます。 extra は意識して扱います。頻繁に変える環境値が入っているなら、変更のたびに新しいバイナリが要ると受け入れるか、アプリ設定の外へ出す(起動時に取得するリモート設定など)か、ExpoConfigExtraSection をスキップに加えます。スキップする前に、アップデート後にアプリがそれらの値をどう読むかを確かめてください。僕は試していません。 sourceSkips を上書きするときは、既定値を意図して書き写します。 文字列を自分で決めたい場合(たとえば appVersion を上げる方式)は、そのままで構いません。それでも CI の確認は足してください。前回のリリースと fingerprint が違うのにランタイムバージョンの文字列が変わっていない状態は、ユーザーを壊す状況そのものです。 自分のプロジェクトの fingerprint を取る mkdir fp-lab \u0026amp;\u0026amp; cd fp-lab # ラボの package.json、app.json、index.js、experiment.mjs をここに置いてから: npm install --ignore-scripts --legacy-peer-deps node experiment.mjs # 3つの設定 × 上のシナリオ、続いて依存のシナリオ node ../update-compat/sim.mjs # モデルの3つの結果(シミュレーション) 実際のプロジェクトでは、npx @expo/fingerprint fingerprint:generate \u0026gt; a.json を実行し、変更を加えて b.json を生成し、npx @expo/fingerprint fingerprint:diff a.json b.json を実行します。\nハッシュに驚かされる場面 誰も上げないランタイムバージョン。 症状: ネイティブモジュールを足すアップデートのあと、古いビルドのユーザーがクラッシュするかフォールバックします。対処: fingerprint ポリシーか、CI の確認を使います。 無害に見える設定の編集。 症状: extra.apiUrl やアプリのバージョンを変えるとランタイムバージョンが変わり、既存のインストールはエラーなしでアップデートを受け取れなくなります。対処: CI の diff と、それらの値をハッシュに入れるかどうかの判断が要ります。 既定値を置き換えるスキップリスト。 症状: スキップを足したあと、無関係な編集でハッシュが変わり始めます。対処: 既定値を明示的に含め、実験をやり直します。 無名の config plugin。 症状: プラグインの挙動を変えても、fingerprint が変わりません。文書がこの制約を説明しています。対処: 生のプラグイン関数に名前を付けるか、追加のソースでそのソースをハッシュします。 追跡対象のファイルの外でのネイティブの変更。 症状: ハッシュには見えない形でバイナリが違います。試しておらず、具体例は挙げられません。導出されたバージョン全般の弱点です。対処: 自動の仕組みと並べて、人が行う手順(ネイティブ変更のチェックリスト)も残します。 プラットフォームの違い。 症状: Android だけのネイティブ変更で iOS のユーザーも新しいビルドが必要になる、またはその逆が起きます。文書では、プラットフォーム別の runtimeVersion が最上位のものを上書きするとされています。対処: 両プラットフォームが実際に分かれるなら、プラットフォーム別の値を使います。これは試していません。 導出か、手書きか 依存や設定を何人かが変えるなら、導出したバージョンを使います。忘れることが支配的な失敗だからです。ネイティブの変更が少なく、いつ境界を越えるかを自分で制御したいなら、手で管理する方式を使い、安全網として CI の確認を足します。\nアプリが JavaScript と Expo 自身のモジュールだけで、依存を変えるたびに必ず新しいビルドを出すなら、ランタイムバージョンはほとんど形式的なものです。どちらにしてもコストは同じです。\nCLI で分かったことと、動かしていないこと 確認したことです。Node.js 22.23.3、Expo 57.0.26、React Native 0.87.1、@expo/fingerprint 0.20.13 で、上の表のすべての行(最小のプロジェクトで CLI を使い、編集ごとに前後の fingerprint を生成)、2つの形での sourceSkips の効果、既定のスキップを書き戻すと動作が戻ること。シミュレーションのモデルは僕自身のもので、Expo のコードは含みません。Expo の文書のランタイムバージョンのページ、アップデートプロトコルのページ、fingerprint のページを執筆当日に読みました。\n確認していないことです。本物のアップデートサーバー、EAS、実機、そして expo-updates のポリシー自体(それが使うパッケージだけを確認しました)。アップデートとビルドが食い違ったときに実機で何が起きるかは確認していません。「ロールバックすることがある」は文書の表現です。ほかのバージョンの fingerprint パッケージは動かしていません。確認した時点で next タグのプレリリースが公開されており、挙動が違うかもしれません。.fingerprintignore、fileHookTransform、プラットフォーム別の runtimeVersion の上書きも試していません。\nハッシュに頼る前に読む ランタイムバージョンを手で設定すれば、2つの成果物を組み合わせてよいかどうかの判断は人が持ちます。導出すれば、その判断はハッシュに移りますが、ハッシュは緩すぎるのと同じくらい簡単に厳しすぎることがあります。fingerprint を生成し、1か所ずつ変えて、何に反応するかを確かめてから頼ってください。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/fingerprint-runtime-version-changes-when-you-edit-extra/","summary":"OTA アップデートは、変更できないネイティブのバイナリを呼び出す JavaScript です。ランタイムバージョンは、そのバイナリの ABI バージョンだと考えられます。@expo/fingerprint 0.20.13 を使った実験で、どの編集がハッシュを変え(extra、version、ビルド番号、ネイティブモジュール)、どれが変えないか(JavaScript、純 JS の依存)を確かめ、既定値を黙って落とすスキップリストの落とし穴も見ます。","title":"Expo の fingerprint ランタイムバージョンは、JavaScript ではなく extra の編集で変わる"},{"content":"TL;DR 更新用のヘルパーを起動してから、systemdにサービスの再起動を頼むサービスは、そのヘルパーを失います。実験では、ヘルパーを setsid(Pythonの start_new_session=True)で起動しても、最後のログ行が出ませんでした。ヘルパーのcgroupは、再起動しようとしているサービスと同じ /system.slice/demo-app.service でした。 setsid が変えるのは、プロセスグループとセッションです。cgroupは変えません。既定の KillMode=control-group のサービスでは、停止時にsystemdが終了させるのはこのcgroupです。 KillMode=process にするとヘルパーは生き残りましたが、ユニットのcgroupに残ったままでした。まだ動いている間は、新しいインスタンスの一部として見えました。systemdのドキュメントはこの設定を「not recommended!」としています。 ヘルパーを systemd-run で起動すると、独立したユニット(demo-updater.service)に入り、普通に完了しました。サービス自身が単に終了して、Restart= に新しいバージョンを起動させる方法も動きました。 すべて、Linux上の使い捨ての名前空間の中で、本物のsystemd 257を使って動かしました。systemd --user、macOSの launchd、コンテナ、root以外で動くサービスは試していません。 症状 デーモンの自己更新は、一見まともな形で書けます。新しいバージョンをダウンロードし、小さなヘルパーを起動し、ヘルパーがサービスを再起動して、新しいプロセスが健全かを確認する。健全でなければ、ヘルパーが巻き戻す。最後の手順がヘルパーを置く理由のすべてで、ログで見たい行も「restart finished; verification runs now」です。\n失敗はこう見えます。新しいプロセスは問題なく起動しているのに、ヘルパーのログには最初の行(「start」)しかなく、その後がない。エラーも、クラッシュも、途中までの確認もありません。\n疑う順に並べると、こうです。\nヘルパーがクラッシュした。 エラー出力はなく、シェルの手順も単純です。 親が終了したときにSIGHUPを受けた。 それを避けるのが setsid で、コードにはすでに入っていました。 サービスの再起動時に、何かがシグナルを送った。 ありそうで、次の節でその根拠を探します。 証拠 こういう症状は、原因を取り違えやすいので、できるだけ小さな再現を作りました。サービスマネージャーとして本物のsystemdが要ります。Linuxのマシン上で、使い捨てのPID・マウント・cgroupの名前空間の中に、systemd をPID 1として起動しました(rootで unshare を使いました。この設定は僕のマシン固有なので載せません。使い捨てのsystemd入りVMのほうが楽です)。そして、1ファイルのサービスで、4つの変種を動かしました。\n#!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34;A tiny daemon that can update itself. SIGUSR1 = \u0026#34;update requested\u0026#34;. usage: app.py \u0026lt;how\u0026gt; how = popen | systemd-run | exit \u0026#34;\u0026#34;\u0026#34; import os, signal, subprocess, sys, time HOW = sys.argv[1] LOG = \u0026#34;/run/demo/log\u0026#34; def log(msg): with open(LOG, \u0026#34;a\u0026#34;) as f: f.write(f\u0026#34;{time.strftime(\u0026#39;%H:%M:%S\u0026#39;)} [app pid={os.getpid()}] {msg}\\n\u0026#34;) def on_usr1(*_): log(f\u0026#34;update requested, starting the updater via {HOW}\u0026#34;) if HOW == \u0026#34;popen\u0026#34;: # the obvious way: detach into a new session so nothing can touch it subprocess.Popen([\u0026#34;/run/demo/updater.sh\u0026#34;], start_new_session=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) elif HOW == \u0026#34;systemd-run\u0026#34;: # hand the updater to the service manager as its own unit subprocess.run([\u0026#34;systemd-run\u0026#34;, \u0026#34;--unit=demo-updater\u0026#34;, \u0026#34;--collect\u0026#34;, \u0026#34;/run/demo/updater.sh\u0026#34;], check=True) else: # no helper at all: leave, and let the supervisor (Restart=) start the new version log(\u0026#34;exiting; the supervisor restarts me\u0026#34;); os._exit(0) signal.signal(signal.SIGUSR1, on_usr1) signal.signal(signal.SIGTERM, lambda *_: (log(\u0026#34;SIGTERM received, exiting\u0026#34;), sys.exit(0))) log(\u0026#34;started\u0026#34;) while True: time.sleep(1) SIGUSR1 は「更新が要求された」の意味です。ヘルパーはサービスを再起動し、僕が見たい行を書きます。\n#!/bin/bash log() { echo \u0026#34;$(date +%H:%M:%S) [updater pid=$$] $*\u0026#34; \u0026gt;\u0026gt; /run/demo/log; } log \u0026#34;start; cgroup=$(cut -d: -f3 /proc/self/cgroup)\u0026#34; systemctl restart demo-app.service sleep \u0026#34;${UPDATER_SLEEP:-1}\u0026#34; log \u0026#34;restart finished; the post-restart verification runs now\u0026#34; ユニットファイルは試行ごとに生成するので、同じサービスを違うオプションで動かせます。\n#!/bin/bash # usage: unit.sh \u0026lt;popen|systemd-run|exit\u0026gt; [extra [Service] lines...] HOW=$1; shift cat \u0026gt; /run/systemd/system/demo-app.service \u0026lt;\u0026lt;EOT [Unit] Description=demo app [Service] ExecStart=/usr/bin/python3 /run/demo/app.py $HOW $(printf \u0026#39;%s\\n\u0026#39; \u0026#34;$@\u0026#34;) EOT systemctl daemon-reload 試行スクリプトは、リセットして実行し、ログと残ったプロセスを表示します。\n#!/bin/bash # usage: trial.sh \u0026lt;label\u0026gt; \u0026lt;popen|systemd-run|exit\u0026gt; [extra [Service] lines] label=$1; shift echo \u0026#34;=== $label\u0026#34; systemctl stop demo-app.service demo-updater.service 2\u0026gt;/dev/null; : \u0026gt; /run/demo/log /run/demo/unit.sh \u0026#34;$@\u0026#34; systemctl start demo-app.service; sleep 1 systemctl kill -s SIGUSR1 --kill-whom=main demo-app.service sleep 4 cat /run/demo/log echo \u0026#34;--- processes still around:\u0026#34;; ps -eo pid,cgroup:40,cmd | grep -E \u0026#39;updater.sh|app.py\u0026#39; | grep -v grep 4つの試行の出力です。\n=== A: Popen(start_new_session=True), default KillMode 03:08:37 [app pid=92] started 03:08:38 [app pid=92] update requested, starting the updater via popen 03:08:38 [updater pid=95] start; cgroup=/system.slice/demo-app.service 03:08:38 [app pid=92] SIGTERM received, exiting 03:08:38 [app pid=101] started --- processes still around: 101 0::/system.slice/demo-app.service /usr/bin/python3 /run/demo/app.py popen === B: same, KillMode=process 03:08:42 [app pid=130] started 03:08:43 [app pid=130] update requested, starting the updater via popen 03:08:43 [updater pid=133] start; cgroup=/system.slice/demo-app.service 03:08:43 [app pid=130] SIGTERM received, exiting 03:08:43 [app pid=139] started 03:08:44 [updater pid=133] restart finished; the post-restart verification runs now === C: systemd-run --unit=demo-updater 03:08:47 [app pid=170] started 03:08:48 [app pid=170] update requested, starting the updater via systemd-run 03:08:48 [updater pid=175] start; cgroup=/system.slice/demo-updater.service 03:08:48 [app pid=170] SIGTERM received, exiting 03:08:48 [app pid=180] started 03:08:49 [updater pid=175] restart finished; the post-restart verification runs now === D: app exits, Restart=always 03:08:52 [app pid=212] started 03:08:53 [app pid=212] update requested, starting the updater via exit 03:08:53 [app pid=212] exiting; the supervisor restarts me 03:08:53 [app pid=217] started (スクリプトは --- processes still around: の一覧も表示します。試行Aでは新しいアプリのプロセスだけが出て、更新プロセスはいなくなっていました。B、C、Dでも、ヘルパーが4秒の待機の間に完了していたため新しいアプリのプロセスしか出なかったので、それらの一覧は省略しました。これは2回目のセッションの行です。最初のセッションも同じ形でした。)\n読み方です。\nAがバグです。更新プロセスは開始を記録し、ログの行にはcgroupがサービス自身のものだと出ています。旧アプリは SIGTERM で終了し、新しいアプリが起動しますが、更新プロセスは最後の行を出さず、プロセス一覧にもいません。サービスの再起動は成功したのに、更新プロセスに頼っていた確認や巻き戻しは行われませんでした。 Bは更新プロセスを生かしました。ただし、これを助けた KillMode=process は、サービスが生んだものが停止後も生き残りうることも意味します。 Cは更新プロセスに独自のcgroup(/system.slice/demo-updater.service)を与え、最後まで動きました。 Dはヘルパー自体をなくしました。アプリは終了することをログに書き、Restart=always が新しいインスタンスを起動しました。 Bの代償を見るために、ヘルパーが30秒眠る形でもう一度動かし、ヘルパーが動いている間にサービスの状態をsystemdに聞きました。\n● demo-app.service - demo app Loaded: loaded (/run/systemd/system/demo-app.service; static) Active: active (running) since Mon 2026-10-05 03:09:11 JST; 3s ago Main PID: 302 (python3) CGroup: /system.slice/demo-app.service ├─296 /bin/bash /run/demo/updater.sh ├─302 /usr/bin/python3 /run/demo/app.py popen └─303 sleep 30 前のインスタンスの更新プロセスが、新しいインスタンスのcgroupの中にいます。このあと systemctl stop してもメインプロセスにしかシグナルは送られず、僕の実行では、サービスを止めたあとも sleep 30 は生き残っていました。ヘルパーが、偶然、次のインスタンスのライフサイクルの一部になっています。\nAで更新プロセスを殺したシグナルは、最初は捕まえていませんでした。そこで updater.sh に1行足して、試行Aをもう一度動かしました。\ntrap \u0026#39;log \u0026#34;got SIGTERM\u0026#34;; exit 1\u0026#39; TERM # log()の定義のあとに追加 03:09:06 [app pid=250] update requested, starting the updater via popen 03:09:06 [updater pid=253] start; cgroup=/system.slice/demo-app.service 03:09:06 [app pid=250] SIGTERM received, exiting 03:09:06 [updater pid=253] got SIGTERM 03:09:06 [app pid=261] started 更新プロセスは、メインプロセスと同じ瞬間に、同じ SIGTERM を受け取っています。下のドキュメントとも合います。KillMode=control-group では、ユニットのcgroupにいる全プロセスに、まず SIGTERM が送られます。ここでの更新プロセスはシェルスクリプトなので捕まえられますが、trapがなければ SIGTERM でそのまま終了します。\nなぜ: ドキュメントがそう書いている systemd.kill(5) には、こうあります。「control-group に設定すると、そのユニットのcontrol groupに残るすべてのプロセスが、ユニットの停止時に終了される」。既定値は control-group です。process については、「メインプロセスだけが終了される(not recommended!)」とあり、さらに、これはプロセスが「サービスマネージャーのライフサイクルとリソース管理から逃れ」、「サービスが停止とみなされ、リソースを消費しないと想定されている間も動き続ける」ことを許す、という注意があります(systemd.kill(5))。\nだから、この目的に setsid() は適切な道具ではありません。守ってくれるのは、端末のハングアップとプロセスグループ宛のシグナルです。上のドキュメントに従う限り、systemdはサービスに属するものをcontrol groupで決めており、試行Aのログの行は、更新プロセスがまさにそこに残っていたことを示しています。\nまた、systemd-run は「一時的な .service または .scope ユニットを作って開始し、指定したCOMMANDをその中で実行する」ものです(systemd-run(1))。試行Cが動いたのはそのためで、ヘルパーが別のユニットになり、一方のユニットの再起動はもう一方を止めません。\n直し方と、代替案 選択肢 何が起きるか 代償 実行したか ヘルパーを systemd-run --unit=… --collect で起動 ヘルパーが独自のユニットとcgroupを持つ 呼び出す権限が要る(僕はrootで実行)。ユニット名は一意でなければならない 実行(C) サービスが終了し、監督役が再起動する(Restart=) ヘルパーなし。新しいインスタンスが起動後の確認をする 確認と巻き戻しを、新しいインスタンスか別の場所に置く必要がある 実行(D) KillMode=process ヘルパーは生き残る 次のインスタンスに紛れ込む。ドキュメントは非推奨 実行(B) サービスではなく、タイマーかパスユニットで起動する別の更新ユニット 更新プロセスがサービスのcgroupと無関係になる ユニットが増える 未実行 macOSの launchd launchd.plist(5) には、ジョブが死ぬと、AbandonProcessGroup がtrueでない限り、launchdが同じプロセスグループIDの残りのプロセスを終了させるとある 仕組みも、逃れる条件も違う 未実行。この環境にmacOSはありません launchd については、launchd.plist(5) のページを読みました。そこでの規則はプロセスグループに関するものなので、setsid した子は独自のグループを持ち、systemdとは挙動が違うかもしれません。これは文章からの推測で、動かしてはいません。\n僕の選択は、サービスが起動後に自分で検証できるならD、外部の観察者が検証と巻き戻しをする必要があるならCです。巻き戻すかを決めるヘルパーが、自分が止めるかもしれないものの中にいてはいけません。\nこの直し方が当てはまる場面 状況 選択 systemd配下の自己更新デーモンで、再起動をまたいで生き残る監視役が要る 監視役を systemd-run で起動 新しいバージョンが起動時に自分の健全性を確認でき、壊れていれば非ゼロで終了できる 終了して、Restart= に任せる(適切な回数制限つき) 更新をパッケージマネージャーや、サービスの外のデプロイツールが行う サービスを巻き込まない。サービス自身に再起動させない ユーザーサービス(systemd --user)、systemdのないコンテナ、macOS ここでは未検証。そのプラットフォームの同等の規則を確認してください バグを呼び戻す習慣 setsid、nohup、disown、ダブルフォークに頼る。 症状: サービスを手で動かすとヘルパーは動くのに、systemd配下では消える。setsid は上(A)で再現しました。他は試していません。これらが変えるのはプロセスや端末の関係であって、cgroupではありません。 KillMode=process で「直す」。 症状: 停止後に孤児の子プロセスが残る。次のインスタンスの状態にヘルパーが現れる。再現しました(B)。 更新のたびに同じ固定のユニット名を使う。 症状: 1回目のヘルパーが残っている間、2回目のヘルパーの起動に失敗する。衝突は試していません。--collect は終わったユニットをsystemdの記憶から消しますが、名前がまだアクティブなときの挙動は、お使いのsystemdのバージョンで確認してください。 死ぬcgroupの外にログを出さない。 症状: 最初の行は見えるのに、最後の行がない。ジャーナルや、ヘルパーが自分で開くファイルなど、生き残る場所に、危険な手順の前に書きます。 「サービスが再起動した」を「更新が成功した」とみなす。 症状: 壊れたバージョンが動いているのに、誰も気づかない。Aで失われたのは、まさにこの確認です。生き残る場所に置いてください。 更新プロセスを手でしか試さない。 症状: 端末では動くのに、systemd配下では動かない。端末から動かすと、片付けられるユニットのcgroupがありません。実際のサービスマネージャーのもとで、更新を試してください。 4つの試行を動かす systemdがPID 1で、rootが使えるマシンが要ります。使い捨てのVMが最適です。スクリプトは /run/systemd/system にユニットを書きますが、そこは揮発性です。\nsudo mkdir -p /run/demo \u0026amp;\u0026amp; sudo cp app.py updater.sh unit.sh trial.sh /run/demo/ \u0026amp;\u0026amp; sudo chmod +x /run/demo/*.sh sudo /run/demo/trial.sh \u0026quot;A\u0026quot; popen を実行します。成功なら、試行Aと同じく「restart finished」の行がなく、「start」の行に出るcgroupは demo-app.service です。 sudo /run/demo/trial.sh \u0026quot;B\u0026quot; popen KillMode=process では、最後の行が出ます。 sudo /run/demo/trial.sh \u0026quot;C\u0026quot; systemd-run では、cgroupが demo-updater.service で、最後の行が出ます。 sudo /run/demo/trial.sh \u0026quot;D\u0026quot; exit Restart=always では、アプリが自力で再起動します。 popen KillMode=process の試行に追加の引数として Environment=UPDATER_SLEEP=30 を渡し、最初の数秒のうちに systemctl status demo-app.service を実行すると、紛れ込んだヘルパーが見えます。 片付けは sudo systemctl stop demo-app.service demo-updater.service; sudo rm -rf /run/demo /run/systemd/system/demo-app.service; sudo systemctl daemon-reload です。 使い捨てのsystemdで分かったこと、分からないこと 確認できたこと: 試行A、B、C、D、そしてヘルパーを眠らせた拡張版のBを、上に示したとおりに、Linux 6.12のマシンで、使い捨てのPID・マウント・cgroupの名前空間のPID 1として動くsystemd 257のもと、rootで動かしました。それぞれ1回、拡張版のBは1回です。systemd.kill(5)、systemd-run(1)、launchd.plist(5) からの引用は、書く際に元のページで読みました。\n確認できていないこと: SIGTERM を無視するハンドラーを置いた更新プロセスが、あとから来る SIGKILL までどうなるか(trapして終了するところまでしか見ていません)、systemd --user、root以外のユーザーで動くサービス(その場合に systemd-run が要る権限も)、コンテナ、他のディストリビューションやsystemdのバージョン、macOSの launchd で AbandonProcessGroup が setsid した子に効くか、ヘルパーのユニット名の衝突、更新自体が失敗して巻き戻しが走るとき。ログのタイムスタンプは僕のマシンの時計のもので、1秒のsleepは実験用の値です。\n同じcgroupなら、運命も同じ 同じcgroupにいるプロセスは、セッションやプロセスグループが何と言おうと、同じサービスの一部です。サービスの再起動をまたいで生きるべきものには、独自のユニットを与えるか、その必要自体をなくします。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/systemd-killmode-self-update/","summary":"自分自身を更新するデーモンが、更新用のヘルパーを起動してから自分のユニットを再起動すると、落とし穴があります。ヘルパーはサービスのcgroupの中で起動し、再起動時にsystemdがそのcgroupの中身を全部終了させるからです。使い捨てのsystemdで試すと、setsidしたヘルパーは最後のログ行を出す前に消え、KillMode=processでは生き残るものの次のインスタンスに紛れ込み、systemd-runなら独立したユニットとして最後まで動きました。証拠、直し方、試していないことを書きます。","title":"サービスが起動した更新プロセスは、setsidしてもsystemdに殺される"},{"content":"ブラウザでは、new URL('../x', 'https://a.example/b/c/d') は https://a.example/b/x になります。React Native 0.87.1 の組み込みの URL クラスでは https://a.example/b/c/d/../x です。ウェブサイトと React Native アプリで共有しているクライアントライブラリは、ウェブ側のテストをすべて通しても、スマートフォンでは違うパスにリクエストを送ってしまうことがあります。\nURL は、共有コードが違うプラットフォームに触れる3か所のうちの1つです。あとの2つは、Expo が fetch を置き換えない限り undefined の response.body と、React Native のセットアップのファイルが定義しない TextDecoder です。これは、React Native と Expo が実際に何を提供しているかの短い監査で、文書にはあまり書かれていないのでソースから書きました。必要なものを確認し、信頼できないものを避ける小さなクライアントも作りました。\nTL;DR 共有クライアントから見えるのは、最大3層です。JavaScript エンジン、React Native 自身のポリフィル、そして Expo のアプリでは、その上にある Expo の「winter」ランタイムです。手に入る Web API は、どの層があるかで変わります。 React Native 0.87.1 の組み込み URL は、正規表現ベースの小さなクラスです。Node の WHATWG URL と並べて動かすと、new URL('../x', 'https://a.example/b/c/d') に対して、ウェブなら https://a.example/b/x のところ、https://a.example/b/c/d/../x を返しました。//c.example/x も解決されず、ホストの大文字小文字と明示した :443 はそのままで、スペースはパーセントエンコードされませんでした。Expo のランタイムを読み込むプロジェクトでは、グローバルの URL が whatwg-url-minimum の実装に置き換わり、これらのケースはウェブと同じに動きます。 React Native 0.87.1 のソースでは、fetch は whatwg-fetch 3.6.20 から来ており、その中にレスポンスの body ストリームを提供するものは見つかりませんでした。Expo が置き換える fetch はストリームを作ります。ストリーミングのコードには、フォールバックか、要件の明記が必要です。 AbortSignal.timeout と AbortSignal.any は 0.87.1 に存在します。古い助言は逆のことを言うので、覚えるのではなく確認する点です。 実用的な規則は3つです。起動時に1回、足りないものを列挙する機能確認を走らせます。URL は自分で制御できる文字列結合で作ります。共有コードは、出荷するランタイムの組み合わせでテストします。 Hermes も実機も動かしていません。テストの「ランタイム」は模擬です。 文書が述べていること、述べていないこと React Native のネットワークのページは、React Native が Fetch API、XMLHttpRequest API、WebSocket のサポートを提供することと、「ネイティブアプリには CORS という概念がない」ことを述べています。そのほかにどのウェブのグローバルがあるか、標準にどれだけ従っているかは書かれていません。その情報はソースにあるので、react-native 0.87.1 と expo 57.0.26 をインストールして、グローバルがどう定義されるかを読みました。\nReact Native の setUpXHR.js は、次のものを遅延評価でグローバルに定義します。XMLHttpRequest、FormData、Headers・Request・Response つきの fetch、WebSocket、Blob、File、FileReader、URL と URLSearchParams、AbortController と AbortSignal です。TextDecoder は定義しません。\nExpo のランタイムのファイル(src/winter/runtime.native.ts)が、さらに次を入れます。TextDecoder(UTF-8 のフォールバックで、提供しないランタイム向けとのコメントがあります)、TextDecoderStream、TextEncoderStream、whatwg-url-minimum パッケージの URL と URLSearchParams、DOMException、structuredClone、FormData と AbortSignal へのパッチ、そして EXPO_PUBLIC_USE_RN_FETCH が設定されていなければ置き換えの fetch です。ReadableStream は、Metro が注入するものとされています。\nグローバル React Native 0.87.1 Expo のランタイムによる追加・置き換え fetch whatwg-fetch 3.6.20。レスポンスの body ストリームは見つかりませんでした Expo の fetch(ストリームあり。EXPO_PUBLIC_USE_RN_FETCH がなければ) URL、URLSearchParams RN 自身の Libraries/Blob/URL.js whatwg-url-minimum AbortController、AbortSignal RN 自身のもの。abort、timeout、any があります timeout/any がなければパッチで追加 TextDecoder setUpXHR.js は定義しません(エンジンが追加するかは確認していません) UTF-8 のフォールバック ReadableStream ここでは調べていません Metro が注入(コメントによる) そのランタイムを読み込まない Expo アプリや、素の React Native アプリには、中央の列しか見えません。共有ライブラリは自分がどちらにいるかを知り得ないので、尋ねるべきなのです。\nURL の違いを測る React Native の URL.js は、Flow の型つきの素の JavaScript です。flow-remove-types で型を取り除き、Node.js 22.23.3 上で、Node 自身の URL と whatwg-url-minimum と並べて動かしました。React Native のパッケージのコードを Node で動かしたもので、Hermes ではありません。Hermes で違う可能性は、僕のテストの範囲外です。\nケース Node の URL React Native 0.87.1 の URL.js whatwg-url-minimum new URL('../x', 'https://a.example/b/c/d') https://a.example/b/x https://a.example/b/c/d/../x https://a.example/b/x new URL('//c.example/x', 'https://a.example') https://c.example/x //c.example/x https://c.example/x HTTPS://A.EXAMPLE/x の href 小文字化 そのまま https://a.example/x https://a.example:443/x の port \u0026quot;\u0026quot; \u0026quot;443\u0026quot; \u0026quot;\u0026quot; https://a.example/a b の pathname /a%20b /a b /a%20b u.pathname = '/y' 動く 効果なし 動く URL.canParse 関数 undefined 関数 searchParams.append のあとの href ?k=v ?k=v 未テスト search のセッター 動く 動く 未テスト Node と React Native の列はすべての行で、whatwg-url-minimum の列は最後の2行を除いて動かしました。僕のスクリプトは非 strict モードで動いたので、pathname への代入は黙って無視されました。strict モードのコードなら TypeError になると思いますが、試していません。\nこれらの動作は URL 仕様に定められています(ベースに対するドットセグメントの解決を含みます)。ウェブ向けに書かれたコードは、それを前提にします。\n共有クライアントに入れるもの 3つの判断になります。\n必要なものを宣言し、1回確認する。\nexport const REQUIRED = [\u0026#34;fetch\u0026#34;, \u0026#34;AbortController\u0026#34;, \u0026#34;TextDecoder\u0026#34;]; export function missingCapabilities(g = globalThis) { return REQUIRED.filter((name) =\u0026gt; typeof g[name] !== \u0026#34;function\u0026#34;); } export function assertRuntime(g = globalThis) { const missing = missingCapabilities(g); if (missing.length) throw new Error(`shared client needs globals this runtime lacks: ${missing.join(\u0026#34;, \u0026#34;)}`); } 起動時に、足りないものすべてを挙げる1つのエラーの方が、あとで出る3つの別々の失敗より対処しやすくなります。\nURL は文字列で作る。 クライアントがするのは、設定されたベースとパスとクエリの結合だけです。解決を要するパスは、ランタイムごとに違う形で解決せず、拒否します。\nconst enc = (s) =\u0026gt; encodeURIComponent(s).replace(/[!\u0026#39;()*]/g, (c) =\u0026gt; \u0026#34;%\u0026#34; + c.charCodeAt(0).toString(16).toUpperCase()); export function joinUrl(base, path, query) { if (/(^|\\/)\\.\\.?(\\/|$)/.test(path) || /^\\/\\//.test(path)) throw new Error(`refusing a path that needs URL resolution: ${path}`); const qs = query ? Object.entries(query).filter(([, v]) =\u0026gt; v !== undefined) .map(([k, v]) =\u0026gt; `${enc(k)}=${enc(String(v))}`).join(\u0026#34;\u0026amp;\u0026#34;) : \u0026#34;\u0026#34;; return `${base.replace(/\\/+$/, \u0026#34;\u0026#34;)}/${path.replace(/^\\/+/, \u0026#34;\u0026#34;)}${qs ? \u0026#34;?\u0026#34; + qs : \u0026#34;\u0026#34;}`; } AbortSignal.timeout や .any に依存しない。 タイマーと AbortController で、テストしたどのプロファイルでも用が足ります。AbortSignal.timeout のないプロファイルも含みます。\nexport function timeoutSignal(ms, outer) { const ctl = new AbortController(); const timer = setTimeout(() =\u0026gt; ctl.abort(new Error(`timeout after ${ms}ms`)), ms); const onOuter = () =\u0026gt; ctl.abort(outer.reason); if (outer) outer.aborted ? onOuter() : outer.addEventListener(\u0026#34;abort\u0026#34;, onOuter, { once: true }); return { signal: ctl.signal, cancel() { clearTimeout(timer); outer?.removeEventListener(\u0026#34;abort\u0026#34;, onOuter); } }; } できるときはストリームで読み、できないときはそう伝える。 行区切りのレスポンスでは、リーダーはストリームがあればそれを使い、なければ本文全体を読みます。後者は行を早く渡さないので、気にする呼び出し元のために canStream を公開しています。\nexport async function* readLines(response) { if (!canStream(response)) { for (const line of (await response.text()).split(\u0026#34;\\n\u0026#34;)) if (line) yield line; return; } const decoder = new TextDecoder(); const reader = response.body.getReader(); let buf = \u0026#34;\u0026#34;; for (;;) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); // 受信途中のマルチバイト文字を保持する let i; while ((i = buf.indexOf(\u0026#34;\\n\u0026#34;)) \u0026gt;= 0) { yield buf.slice(0, i); buf = buf.slice(i + 1); } } buf += decoder.decode(); if (buf) yield buf; } { stream: true } は重要です。これがあれば、2つのネットワークチャンクにまたがる日本語の文字が正しくデコードされます。ラボでは、同じバイト列をこれなしでもデコードしていて、結果は文字化けします。\n出荷するランタイムでテストする テストは、クライアントのソースを、組み合わせごとに合わせてグローバルを用意した別々の node:vm の実行環境で動かします。層の模擬であり、エンジンそのものではありません。\nprofile missing for the shared client naive new URL(\u0026#39;../x\u0026#39;, \u0026#39;https://a.example/b/c/d\u0026#39;) joinUrl(\u0026#39;https://a.example/b/c/d\u0026#39;,\u0026#39;x\u0026#39;) web-like - https://a.example/b/x https://a.example/b/c/d/x rn-0.87.1 TextDecoder https://a.example/b/c/d/../x https://a.example/b/c/d/x rn+expo - https://a.example/b/x https://a.example/b/c/d/x bare fetch, TextDecoder throws: URL is not defined https://a.example/b/c/d/x rn-0.87.1 のプロファイルは、React Native 自身の URL.js を使い、TextDecoder はありません。rn+expo は whatwg-url-minimum を使い、TextDecoder を足します。bare には URL、fetch、TextDecoder がありません。契約テスト(13件、すべて成功)は次を確かめます。joinUrl が4つすべてで同じ文字列を返すこと、timeoutSignal が4つすべてで動くこと、assertRuntime が足りないグローバルだけを挙げること、readLines がストリームの有無にかかわらず動くこと、素朴な new URL の結果が上のようにランタイム間で違うことです。\n共有したい Web API のチェックリスト 対象とするランタイムごとに、そのグローバルがどこで定義されるかを調べます(エンジン、フレームワークのポリフィル、自分たちのランタイム層)。見つからなければ、ないものとして扱います。 名前ではなく、コードが依存する動作を確認します。解決、エンコード、大文字小文字、セッターです。 ライブラリの境界では、素の文字列や配列を優先します。URL、Request、ストリームのような豊かなオブジェクトは、プラットフォームのアダプターの中に置きます。 起動時に機能確認を入れ、ランタイムのプロファイルごとに1つテストを置きます。 React Native や Expo を上げたら、確認し直します。ランタイムのファイルはバージョン間で変わります。 プロファイルを動かす mkdir shared-client-lab \u0026amp;\u0026amp; cd shared-client-lab # ラボの client.js、profiles.mjs、rn-url.cjs、contract.test.mjs、run-lab.mjs をここに置く npm init -y \u0026amp;\u0026amp; npm i -D react-native@0.87.1 flow-remove-types@2.300.0 whatwg-url-minimum@0.1.2 --ignore-scripts --legacy-peer-deps node run-lab.mjs node --test contract.test.mjs 共有コードがスマートフォンで壊れる場面 共有コードで new URL(relative, base) を使うこと。 症状: ../ やプロトコル相対の参照が、スマートフォンで違う URL に解決され、意図しないパスにリクエストが飛びます。対処: 文字列を結合し、解決が必要な入力は拒否します。 フォールバックなしで response.body を読むこと。 症状: 1つのランタイムでだけ Cannot read property 'getReader' of undefined になります。対処: ストリームの有無を確認し、フォールバックは本文全体をバッファすることを書いておきます。 確認せずに TextDecoder を使うこと。 症状: ないエンジンや構成で ReferenceError になります。対処: 起動時に assertRuntime() を呼びます。 チャンクを1つずつデコードすること。 症状: チャンクの境界で文字化けします。マルチバイトのテキストのときだけです。対処: ストリームごとに1つのデコーダーを使い、{ stream: true } を付けます。 古いバージョンの豆知識を覚えていること。 症状: もう要らない AbortSignal.timeout 欠如の回避策を残しているか、存在しない機能に頼っています。対処: 出荷するバージョンのソースを読みます。0.87.1 ではどちらも存在すると分かりましたが、どのリリースで初めて入ったかはたどっていません。 Node かブラウザでしかテストしないこと。 症状: CI ではすべて通り、アプリで失敗します。対処: プロファイルごとに契約テストを走らせ、リリース前に少なくとも実際のランタイムでスモークテストをします。 クライアントを共有する場面 ロジックが主役のときに、クライアントを共有します。リクエストの署名、リトライの方針、ページネーション、パースなどです。プラットフォームとの境界は薄く、取り替えられる形に保ちます。\nランタイムを固定できないなら、豊かな Web オブジェクトに寄りかかるコード(Request の clone、URL の変更、ReadableStream のパイプ)は共有しないでください。アプリとサイトが別々に進化するなら、小さなアダプターを2つ持つ方が、凝った抽象を1つ持つより安く済みます。\nNode で確かめたこと、Hermes では確かめていないこと 確認したことです。インストールされた react-native 0.87.1 と expo 57.0.26 の中身(セットアップのファイルがどのグローバルを定義するか、whatwg-fetch 3.6.20 にレスポンスの body がないこと、AbortSignal の実装に timeout と any があること)。URL の表は、型を取り除いた React Native の URL.js を、Node 22.23.3 の URL および whatwg-url-minimum 0.1.2 と並べて動かして作りました。4つの模擬プロファイル上の13件の契約テストも確認しました。\n確認していないことです。Hermes。その中ではコードを動かしていないので、エンジンが TextDecoder を提供するか、URL.js の動き方が違うかは言えません。スマートフォンでアプリは動かしておらず、Expo のランタイム自体も動かしていません。動かしたのは、それが入れる whatwg-url-minimum パッケージだけです。各グローバルがどのバージョンで React Native に入ったかは調べていません。node:vm のプロファイルは、上のソースから作った近似です。\nランタイムに尋ねる 共有クライアントがうまく動くのは、自分が動いているランタイムを知っていて、起動時にそれを伝えるときです。Web プラットフォームは、各ランタイムが異なる程度に実装している標準の集まりです。そして最初に表に出るのは、地味すぎてテストしないと思っていた部分です。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/react-native-builtin-url-does-not-resolve-dot-dot/","summary":"ウェブサイトと React Native アプリで共有するクライアントライブラリが壊れるのは、fetch ではなく、Web プラットフォームの地味な部分であることが多いです。React Native 0.87.1 と Expo 57 のソースを読むと、ランタイムが3層あること、ウェブなら \u0026lsquo;/b/x\u0026rsquo; になるところを \u0026lsquo;/b/c/d/../x\u0026rsquo; と返す URL クラスがあること、Expo が置き換えない限り fetch にレスポンスのストリームがないことが分かります。必要なものを確認する小さなクライアントを、4つの模擬ランタイムでテストします。","title":"React Native 標準の URL は '../x' を解決しない"},{"content":"Durable Object のアラームのハンドラーを、ローカルのランタイムで7回続けて失敗させました。実行されたのは +0、2、6、14.5、32、68、145 秒です。その後、オブジェクトを再び起こすものは何もなく、失効させるはずだったリースは期限切れのまま表に残りました。\nこれはアラームの文書が3つの文で述べているとおりの動作です。オブジェクトのアラームは1つ。アラームは少なくとも1回実行される。失敗するハンドラーの再試行には上限がある。オブジェクトがリース、セッション、予約のような時間制限つきのものを持ち、期限が来たら片付けなければならないとき、アラームはそのためにあるように見えますが、この3つがハンドラーの書き方を変えます。以下では、3つに耐えるよう作った小さな失効台帳に注釈をつけ、各部分が実際にどう動いたかを示します。\nTL;DR オブジェクトごとにアラームは1つ。 setAlarm は、設定済みのアラームを置き換えます。1つのオブジェクトが多数の失効を持つなら、アラームは最も早いものへの呼び出しにすぎません。失効の一覧は表に持ちます。 少なくとも1回。 外部の作業をしたあとでハンドラーが例外を投げると、最初からもう一度実行されます。作業は繰り返しに耐える必要があります。僕のテストでは、予想どおり2回実行されました。 再試行は有限。 文書では、2秒の遅延から始まる指数バックオフで、最大6回の再試行です。ローカルの workerd では7回の試行(最初の1回と再試行6回)を観測し、間隔はおよそ2、4、8、18、36、77秒でした。その後アラームは消え、getAlarm() は null を返し、期限を過ぎたリースは表に残ったままでした。 起きたときの再設定。 アラームが未設定で、保留中の行があるときだけアラームを設定するコンストラクターは、ランタイムの再起動のあとで失効処理を復活させました。無条件にアラームを設定するコンストラクターは、すでに設定されていたアラームに干渉することがあると、文書が警告しています。 これらはローカルでの結果です。Cloudflare のネットワーク上では動かしていません。 文書が約束していること アラームのページから(執筆当日に確認)。\nDurable Object が同時に持てるアラームは1つです。すでに設定されているときに setAlarm() を呼ぶと上書きされます。 アラームは「少なくとも1回の実行が保証」され、alarm() が例外を投げると自動的に再試行されます。2秒の遅延から始まる指数バックオフで、最大6回です。これは直近の setAlarm() にだけ適用されます。 ハンドラーは retryCount と isRetry を受け取ります。同時に動く alarm() はオブジェクトごとに1つです。オブジェクトが予期せず終了した場合、alarm() は別のマシンで最初から再実行されることがあります。 alarm() の中では、ハンドラーが始まってから setAlarm() を呼んでいない限り、getAlarm() は null を返します。 オブジェクトが起きるとき、コンストラクターが alarm() より先に実行されます。コンストラクターでの setAlarm() は、すでに設定されているアラームに干渉しうるので、先に確認するよう文書は警告しています。 組み込みの再試行以上が必要なら、alarm() で例外を捕まえて再設定するよう、文書は勧めています。 ストレージ API のページからは次のとおりです。現在以前の時刻で setAlarm() を呼ぶと、アラームは直近に実行されるよう予定されます。通常は数ミリ秒で始まりますが、「メンテナンスやフェイルオーバー中の障害で、最大1分遅れることがあります」。\nストレージ API(ctx.storage.sql)は SQLite ベースで、間に await を挟まない書き込みはまとめてコミットされます。下のコードでは、その点にはあまり頼っていません。\n台帳に注釈をつける オブジェクト全体は約35行です。実行される順に見ていきます。\nexport class LeaseLedger extends DurableObject { constructor(ctx, env) { super(ctx, env); this.sql = ctx.storage.sql; this.sql.exec(`CREATE TABLE IF NOT EXISTS leases (id TEXT PRIMARY KEY, expire_at INTEGER NOT NULL)`); this.sql.exec(`CREATE INDEX IF NOT EXISTS leases_by_expiry ON leases (expire_at)`); ctx.blockConcurrencyWhile(() =\u0026gt; this.ensureAlarm()); // (1) } (1) オブジェクトが作られるたび(最初のリクエスト、退避のあと、再起動のあと)に、起こすための仕掛けが設定されているかを確かめます。blockConcurrencyWhile は、終わるまで入ってくるイベントを保留するので、初期化が途中のオブジェクトをリクエストが見ることはありません。\nasync ensureAlarm() { const next = this.sql.exec(`SELECT MIN(expire_at) AS t FROM leases`).one().t; // (2) if (next === null) return; const current = await this.ctx.storage.getAlarm(); if (current === null || next \u0026lt; current) await this.ctx.storage.setAlarm(next); // (3) } (2) 真実は表にあり、アラームはそこから導きます。(3) アラームは早い方向にだけ動き、遅い方向には動きません。すでに正しい時刻なら、触りません。文書が求めている確認はこれです。関数は冪等なので、コンストラクター、grant、alarm のどこから呼んでも安全です。\nasync grant(id, ttlMs) { this.sql.exec(`INSERT OR REPLACE INTO leases (id, expire_at) VALUES (?, ?)`, id, Date.now() + ttlMs); await this.ensureAlarm(); } 新しいリースは、1回の挿入と1回の ensureAlarm です。新しいリースが保留中のアラームより早く切れるなら、アラームが前に動きます。\nasync alarm() { const due = this.sql.exec(`SELECT id FROM leases WHERE expire_at \u0026lt;= ? ORDER BY expire_at LIMIT 100`, Date.now()).toArray(); // (4) for (const { id } of due) { await this.revoke(id); // (5) this.sql.exec(`DELETE FROM leases WHERE id = ?`, id); // (6) } await this.ensureAlarm(); // (7) } async revoke(_id) { /* 外部への副作用 */ } } (4) 呼ばれた回数を信用せず、今期限が来ているものを読みます。toArray() は最初の await の前にカーソルを読み切ります。(5) と (6) は順序が重要です。先に副作用、次に行の削除です。その間にプロセスが落ちれば、行は残り、次の実行が副作用を繰り返します。逆順では、クラッシュで副作用が失われます。「少なくとも1回」とは、損失より重複を選び、重複を無害にするということです。(7) 次の失効、または LIMIT を超えた行へつなぎます。ハンドラーの中では、設定しない限り getAlarm() が null なので、ensureAlarm は単に設定します。\nworkerd での動き wrangler dev 4.147.0(ローカルの workerd、compatibility date 2026-09-01)と、revoke を上書きして呼び出し回数を数え、要求に応じて例外を投げるラボ用のサブクラスを使いました。以下の数値はすべてそのローカルのランタイムのものです。\n通常の経路。 寿命が300ミリ秒と1500ミリ秒の2つのリース。アラームはそれぞれに1回ずつ発火し、2回目は1回目の1.2秒後でした。表は空になり、getAlarm() は null でした。\n副作用の後のクラッシュ。 revoke が、呼び出しを記録した後で1回だけ例外を投げるようにしました。最初の試行は副作用を実行し、DELETE の前に失敗しました。プラットフォームは約2秒後に retryCount=1 で再試行し、副作用をもう一度実行して、行を削除しました。1つのリースに対して、副作用が2回実行されました。\nアラームは早い方向にだけ動く。 5秒のリースを付与し、次に60秒のリース(アラームは動かない)、次に1秒のリース(アラームが前に動く)を付与しました。\n再試行を使い切る。 ハンドラーが7回例外を投げるようにしました。ジャーナルには、+0、2.0、6.1、14.5、32.1、67.9、144.6秒の試行が残り、retryCount は0から6でした。その後、100秒ほど様子を見ても試行は起きず、getAlarm() は null を返し、リースは期限切れのまま表に残っていました。文書のとおりで、再試行を使い切ると、次の setAlarm まで何もアラームを実行しません。\n復活。 失敗の注入を解除し、同じ永続化状態で wrangler dev を止めて再起動し、リクエストを1回だけ送りました。オブジェクトのコンストラクターが ensureAlarm を実行し、期限切れの行があってアラームがないのを見つけて、設定しました。アラームは約1秒以内に発火し、リースは削除されました。\nラボのオブジェクトには、再試行を使い切るテストの間、コンストラクターによる再設定を止める切り替えがあります。これがなければ、状態を問い合わせるリクエストだけでもオブジェクトが復活してしまいます。上の結果は、切り替えが効いた状態で得たものです。\n副作用を繰り返しに耐える形にする 台帳は、繰り返しをまれにしますが、不可能にはできません。副作用は、繰り返しに耐える形である必要があります。2つの形が使えます。\nもともと冪等: 「資格情報 X を失効させる」や「状態を expired にする」は、2回実行しても同じ状態になります。僕のテストは呼び出しを数えていますが、観測できる状態は1回でも2回でも同じです。 キーつき: 副作用が別のものへの呼び出し(送信、課金、作成)なら、リースの id をキーとして渡し、相手が繰り返しを認識できるようにします。 ラボでは副作用をスタブのままにしたので、この節は形の説明で、端から端まで試したものではありません。\nworkerd で動かす mkdir alarm-lab \u0026amp;\u0026amp; cd alarm-lab # ラボの wrangler.jsonc、src/ledger.js、src/index.js、lab.mjs、probe.mjs をここに置く npm i -D wrangler@4.147.0 # Node.js 22 が必要 npx wrangler dev --port 8799 --persist-to ./state \u0026amp; node lab.mjs main # 通常の経路、副作用後のクラッシュ、アラームは早い方向にだけ動く node probe.mjs # 7回の失敗。約4分かかる probe は、リースがまだ一覧にある間、attempts=7 と alarm=null を出力します。復活を見るには、wrangler を止める前にそのオブジェクトで /noboot?on=0 を呼んでラボの切り替えを解除し、止めて、同じ --persist-to で再起動し、node lab.mjs revive \u0026lt;object\u0026gt; を実行します。\nアラームのハンドラーが黙って止まる場面 アラームをデータとして扱うこと。 症状: 失効が2つあってアラームは1つです。あとの setAlarm が前のものを置き換え、片方が失われます。対処: 失効は表に持ち、アラームは最小値に設定します。 失敗するハンドラーが永遠に再試行されると思い込むこと。 症状: 一時的な障害や数分続くバグのあとに、期限切れの行が処理されないまま残り、あとからエラーも出ません。対処: 上のように起きたときに再設定し、外からの定期的な確認(期限切れの作業があるオブジェクトに触れる cron 型のジョブ)も足します。例外を捕まえて自分で再設定する方法は文書が挙げるもう1つの選択肢ですが、僕は試しておらず、行ごとの試行回数のカウンターがないと、不良な行が残りを止めます。 コンストラクターで無条件にアラームを設定すること。 症状: オブジェクトが起きるたびにアラームが後ろに押され、一部の失効が遅れます。対処: getAlarm() を読み、早い方向にだけ動かします。ここは文書の警告に従った形で、害のある版を再現してはいません。 副作用の前に削除すること。 症状: クラッシュで副作用が永久に失われます。対処: 副作用が先、行の削除が後にします。副作用は繰り返しに耐える形にします。 表ではなく時計を読むこと。 症状: アラームが早く、または遅く発火し、ハンドラーが何もしない、あるいは違う行を処理します。対処: 毎回 expire_at \u0026lt;= now で期限の来た行を検索します。文書によればアラームは遅れることがありますが、問い合わせで決めていれば、早い実行や重複した実行は無害です。 alarm() の中で setAlarm を呼び、getAlarm() に古いアラームが見えると期待すること。 症状: 再設定の判断が null に基づいてしまいます。対処: 文書のとおり、ハンドラーの中では、新しく設定しない限り getAlarm() が null だと覚えておきます。 アラームが合う場面 1つのオブジェクトが多数の時間依存の義務を持ち、「いずれ、少なくとも1回、1分程度の幅で」で足りるなら、この形を使います。台帳とアラームの組は小さく、すべてがオブジェクト自身のストレージに入ります。\n正確な時刻に起きなければならない仕事には使いません。見逃しても1日は誰も気づかない後片付けにも、外からの確認を足さない限り使いません。副作用を繰り返しに耐える形にできないなら、それができるまで、アラームは適切な道具ではありません。\nworkerd で分かったことと、Cloudflare にしか分からないこと 確認したことです。ローカルの wrangler dev 4.147.0(workerd)と Node.js 22.23.3 で、通常の経路、クラッシュ後の2回実行、アラームが早い方向にだけ動くこと、上の間隔の7回の試行のあとに null と期限切れの行が残ること、再起動後のコンストラクターによる復活を確認しました。文書の記述は、上に挙げた Cloudflare のページで読みました。\n確認していないことです。Cloudflare の本番ネットワーク。上の再試行の間隔はローカルのランタイムのもので、本番では違うかもしれません。「最大1分」の遅延、オブジェクトが別のマシンに移るときの挙動、ハンドラー内の deleteAlarm() の効果は、僕ではなく文書の記述です。例外を捕まえて再設定する方法、無条件にアラームを設定するコンストラクター、外部のポーラーも試していません。\nアラームが約束するもの アラームは、呼び出しの約束であって、仕事を終わらせる約束ではありません。仕事は表に持ち、起きるたびに再確認し、2回呼ばれても何も失わない形で副作用を書きます。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/durable-object-alarm-retries-six-times-then-stops/","summary":"Durable Object のアラームは1つだけで、少なくとも1回実行され、失敗するハンドラーはバックオフ付きで最大6回再試行されます。それが尽きると、オブジェクトを再び起こすものは何もありません。この3つに耐えるリース失効の台帳を、コードに注釈をつけて見ていきます。真実を持つ表、アラームを再設定するコンストラクター、2回実行されても困らない副作用です。ローカルの workerd で動かし、制限は Cloudflare の文書から引用しています。","title":"Durable Object のアラームは、失敗しても6回再試行して終わる"},{"content":"TL;DR Hibernation APIを使うDurable Objectは、アイドルになるとメモリから取り除かれ、WebSocket接続は維持されます。次のメッセージが届くと、新しいインスタンスでコンストラクターがもう一度実行されます。ローカルの実行では、メッセージ数を数えるフィールドは5から1に戻り、経過時間のカウンターは412msから4msに戻り、ニックネームを持つフィールドは値から null に戻りました。 残る状態は、ストレージにあるものと、serializeAttachment() で保存したものです。deserializeAttachment() が返したオブジェクトを書き換えても、もう一度 serializeAttachment() を呼ばなければ何も保存されませんでした。 アタッチメントの上限は16,384バイトです。20KBの値は、後からではなく、呼び出し時点で例外になりました。 アプリケーション層の \u0026quot;ping\u0026quot; を setWebSocketAutoResponse() で返しても、オブジェクトは起きませんでした(コンストラクターの回数は1のまま)。WebSocketのプロトコルのpingフレームを3回送っても同じでした。 ハイバネーションは自動ではありません。保留中の setInterval があると、15秒のテストの間ずっとメモリに残り、標準の accept() APIを使った場合も同じでした。 すべて wrangler dev --local(ローカルのworkerd)で動かしました。Cloudflareの本番ランタイム、課金、アラーム、70〜140秒の退避は試していません。 1つの文、2通りの読み方 Durable Objectsのドキュメントのライフサイクルのページは、ハイバネーション状態を1行で説明しています。「Durable Objectはメモリから取り除かれる。ハイバネーションされたWebSocket接続は接続されたままになる」。数行あとには注意書きがあります。「ハイバネーション中はメモリ上の状態が破棄されるので、重要な情報はすべてDurable Objectのストレージに保存すること」(Lifecycle of a Durable Object)。\nさっと読むと、これはいい契約に見えます。接続は維持され、状態は自分で保存する。しかし、「このWebSocketはまだつながっている」ことと「このオブジェクトは、相手が誰かをまだ覚えている」ことの違いが、バグの出どころです。両方を実際に見たくて、それを見せられる最小のオブジェクトを作りました。\nドキュメントには、ハイバネーションの条件が並んでいます。setTimeout や setInterval のコールバックが予約されていないこと。未完了のI/Oや waitUntil() のPromiseがなく、開いたままの外向きの接続がないこと。標準のWebSocket APIを使っていないこと。処理中のリクエストやイベントがないこと。これらがすべて成り立ち、10秒間イベントがなければ、ハイバネーションします。1つでも偽なら、アイドル状態のままメモリに残り、70〜140秒の無活動のあと完全に退避されます。「10秒」は現時点の挙動で、ランタイムが決めるものとされています。\n違いを見せる最小のオブジェクト 下のDurable Objectは、状態が置かれうる場所にそれぞれ1つずつ値を持たせてあり、1つのJSON応答でどれが生き残るかが分かります。\nmessagesSeenInMemory、nickInMemory、bornAt: 普通のクラスのフィールド(メモリだけ) constructions: オブジェクトのストレージ上のカウンターで、コンストラクターで増やします。オブジェクトが何回作られたかを数えます。 各ソケットのアタッチメント: joinedAt と nick getWebSockets().length: ランタイムがまだ知っているソケット import { DurableObject } from \u0026#34;cloudflare:workers\u0026#34;; export class Room extends DurableObject { constructor(ctx, env) { super(ctx, env); this.bornAt = Date.now(); // in memory only this.nickInMemory = null; // in memory only this.messagesSeenInMemory = 0; // in memory only this.constructions = (ctx.storage.kv.get(\u0026#34;constructions\u0026#34;) ?? 0) + 1; // in storage: survives ctx.storage.kv.put(\u0026#34;constructions\u0026#34;, this.constructions); console.log(`[room ${ctx.id.name}] constructor ran (#${this.constructions})`); // answer the application-level \u0026#34;ping\u0026#34; without running our code ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(\u0026#34;ping\u0026#34;, \u0026#34;pong\u0026#34;)); } async fetch(request) { const [client, server] = Object.values(new WebSocketPair()); if (new URL(request.url).searchParams.has(\u0026#34;std\u0026#34;)) { // the standard API instead: server.accept() server.accept(); server.addEventListener(\u0026#34;message\u0026#34;, () =\u0026gt; server.send(JSON.stringify({ objectAgeMs: Date.now() - this.bornAt, constructions: this.constructions }))); return new Response(null, { status: 101, webSocket: client }); } this.ctx.acceptWebSocket(server); // the hibernation API server.serializeAttachment({ joinedAt: Date.now(), nick: \u0026#34;anon\u0026#34; }); // per-connection state that survives if (new URL(request.url).searchParams.has(\u0026#34;timer\u0026#34;)) setInterval(() =\u0026gt; {}, 1000); // a pending timer return new Response(null, { status: 101, webSocket: client }); } async webSocketMessage(ws, message) { this.messagesSeenInMemory++; const att = ws.deserializeAttachment(); if (message === \u0026#34;mutate\u0026#34;) att.nick = \u0026#34;changed-but-not-saved\u0026#34;; // no serializeAttachment() call if (message === \u0026#34;save\u0026#34;) { att.nick = \u0026#34;changed-and-saved\u0026#34;; ws.serializeAttachment(att); } if (message === \u0026#34;inmem\u0026#34;) this.nickInMemory = \u0026#34;kept-in-a-field\u0026#34;; if (message === \u0026#34;big\u0026#34;) { try { ws.serializeAttachment({ blob: \u0026#34;x\u0026#34;.repeat(20000) }); ws.send(\u0026#34;big: accepted\u0026#34;); } catch (e) { ws.send(`big: ${e.name}: ${e.message}`); } return; } ws.send(JSON.stringify({ messagesSeenInMemory: this.messagesSeenInMemory, // resets after hibernation objectAgeMs: Date.now() - this.bornAt, // resets after hibernation nickInMemory: this.nickInMemory, // resets after hibernation constructions: this.constructions, // from storage: counts re-creations nick: ws.deserializeAttachment().nick, // survives, if it was saved joinedAt: att.joinedAt, // survives sockets: this.ctx.getWebSockets().length, // the connections themselves survive })); } async webSocketClose(ws, code) { console.log(`[room ${this.ctx.id.name}] close ${code}`); } } export default { async fetch(request, env) { const name = new URL(request.url).searchParams.get(\u0026#34;room\u0026#34;) ?? \u0026#34;lab\u0026#34;; return env.ROOM.getByName(name).fetch(request); }, }; { \u0026#34;name\u0026#34;: \u0026#34;hib-lab\u0026#34;, \u0026#34;main\u0026#34;: \u0026#34;src/index.js\u0026#34;, \u0026#34;compatibility_date\u0026#34;: \u0026#34;2026-04-07\u0026#34;, \u0026#34;durable_objects\u0026#34;: { \u0026#34;bindings\u0026#34;: [{ \u0026#34;name\u0026#34;: \u0026#34;ROOM\u0026#34;, \u0026#34;class_name\u0026#34;: \u0026#34;Room\u0026#34; }] }, \u0026#34;migrations\u0026#34;: [{ \u0026#34;tag\u0026#34;: \u0026#34;v1\u0026#34;, \u0026#34;new_sqlite_classes\u0026#34;: [\u0026#34;Room\u0026#34;] }] } クライアントは、いくつか質問し、15秒(10秒のアイドル規則より長い)待ち、もう一度質問します。コンストラクターが何回走ったかは、開発サーバーのログから読みます。\n// usage: node client.mjs \u0026lt;idle-seconds\u0026gt; \u0026#34;\u0026lt;query\u0026gt;\u0026#34; e.g. node client.mjs 15 \u0026#34;?room=a\u0026#34; import fs from \u0026#34;node:fs\u0026#34;; import WebSocket from \u0026#34;ws\u0026#34;; const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); const idle = Number(process.argv[2] ?? 15), query = process.argv[3] ?? \u0026#34;?room=lab\u0026#34;; const room = new URLSearchParams(query).get(\u0026#34;room\u0026#34;); const constructors = () =\u0026gt; (fs.readFileSync(\u0026#34;wrangler.log\u0026#34;, \u0026#34;utf8\u0026#34;).match(new RegExp(`\\\\[room ${room}\\\\] constructor`, \u0026#34;g\u0026#34;)) || []).length; const ws = new WebSocket(`ws://localhost:8787/${query}`); const inbox = []; ws.on(\u0026#34;message\u0026#34;, (m) =\u0026gt; inbox.push(m.toString())); await new Promise((r) =\u0026gt; ws.on(\u0026#34;open\u0026#34;, r)); const ask = async (text) =\u0026gt; { inbox.length = 0; ws.send(text); for (let i = 0; i \u0026lt; 50 \u0026amp;\u0026amp; !inbox.length; i++) await sleep(100); return inbox[0]; }; const t0 = Date.now(); const at = () =\u0026gt; `[${((Date.now() - t0) / 1000).toFixed(1).padStart(4)}s]`; for (const m of [\u0026#34;hi\u0026#34;, \u0026#34;mutate\u0026#34;, \u0026#34;hi\u0026#34;, \u0026#34;save\u0026#34;, \u0026#34;inmem\u0026#34;, \u0026#34;big\u0026#34;, \u0026#34;ping\u0026#34;]) console.log(at(), m.padEnd(7), \u0026#34;-\u0026gt;\u0026#34;, await ask(m)); console.log(at(), `idle for ${idle} s; the socket stays open`); await sleep(idle * 1000); console.log(at(), \u0026#34;readyState\u0026#34;, ws.readyState, \u0026#34;(1 = OPEN); constructor runs so far:\u0026#34;, constructors()); console.log(at(), \u0026#34;ping -\u0026gt;\u0026#34;, await ask(\u0026#34;ping\u0026#34;), \u0026#34;| constructor runs now:\u0026#34;, constructors()); console.log(at(), \u0026#34;hi -\u0026gt;\u0026#34;, await ask(\u0026#34;hi\u0026#34;), \u0026#34;| constructor runs now:\u0026#34;, constructors()); console.log(at(), \u0026#34;big -\u0026gt;\u0026#34;, await ask(\u0026#34;big\u0026#34;)); ws.close(); セットアップです。僕の環境ではWrangler 4.147.0にNode 22が必要で(22.23.3を使いました)、作業ディレクトリにwranglerと ws を入れ、開発サーバーの出力を wrangler.log に書かせました。\nmkdir do \u0026amp;\u0026amp; cd do \u0026amp;\u0026amp; npm init -y npm i wrangler ws # wrangler 4.147.0、Node 22.23.3 で使用 # src/index.js と wrangler.jsonc を上のとおり保存し、client.mjs は package.json の隣に置く npx wrangler dev --local --port 8787 \u0026gt; wrangler.log 2\u0026gt;\u0026amp;1 \u0026amp; node client.mjs 15 \u0026#34;?room=a\u0026#34; 実行のたびに新しい room 名を使ってください。オブジェクトのストレージが、実行をまたいで constructions を保持するためです。\n見えたこと 1回の実行の出力です(出力されたとおりの行を、重要なフィールドだけに切り詰めています)。\n[ 0.0s] hi -\u0026gt; messagesSeenInMemory 1, objectAgeMs 9, nickInMemory null, constructions 1, nick \u0026#34;anon\u0026#34;, sockets 1 [ 0.1s] mutate -\u0026gt; messagesSeenInMemory 2, ... [ 0.2s] hi -\u0026gt; messagesSeenInMemory 3, ... nick \u0026#34;anon\u0026#34; (mutateは保存されていない) [ 0.3s] save -\u0026gt; messagesSeenInMemory 4, ... nick \u0026#34;changed-and-saved\u0026#34; [ 0.4s] inmem -\u0026gt; messagesSeenInMemory 5, objectAgeMs 412, nickInMemory \u0026#34;kept-in-a-field\u0026#34;, constructions 1, nick \u0026#34;changed-and-saved\u0026#34;, sockets 1 [ 0.5s] big -\u0026gt; big: Error: A WebSocket \u0026#39;attachment\u0026#39; cannot be larger than 16384 bytes.\u0026#39;attachment\u0026#39; was 20015 bytes. [ 0.6s] ping -\u0026gt; pong [ 0.7s] idle for 15 s; the socket stays open [15.7s] readyState 1 (1 = OPEN); constructor runs so far: 1 [15.7s] ping -\u0026gt; pong | constructor runs now: 1 [15.8s] hi -\u0026gt; messagesSeenInMemory 1, objectAgeMs 4, nickInMemory null, constructions 2, nick \u0026#34;changed-and-saved\u0026#34;, sockets 1 | constructor runs now: 2 15秒のアイドルの前後を表にします。\n項目 アイドル前 アイドル後 置かれていた場所 クライアント側の readyState 1 1 接続 getWebSockets().length 1 1 ランタイム messagesSeenInMemory 5 1 クラスのフィールド、失われた objectAgeMs 412ms 4ms クラスのフィールド、失われた nickInMemory \u0026quot;kept-in-a-field\u0026quot; null クラスのフィールド、失われた constructions 1 2 ストレージ、残った。オブジェクトが作り直されたことを示す serializeAttachment() で保存した nick \u0026quot;changed-and-saved\u0026quot; \u0026quot;changed-and-saved\u0026quot; アタッチメント、残った ソケット受け入れ時に保存した joinedAt 同じ値 同じ値 アタッチメント、残った serializeAttachment() を呼ばない mutate メッセージ後の nick 次のメッセージで \u0026quot;anon\u0026quot; のまま 見直していない(あとの save で上書きした) 変更は deserializeAttachment() が返したオブジェクトの中にしかなかった 自動応答が返した \u0026quot;ping\u0026quot; pong pong、コンストラクターの回数は1のまま ランタイムが応答 この表で、あえて口に出したい点が4つあります。\nクライアントは何にも気づきませんでした。 ソケットは OPEN のままで、pingには pong が返りました。再接続もイベントもありません。ハイバネーションの唯一の痕跡は、サーバーのログにある、コンストラクターの回数の増加だけです。 自動応答はオブジェクトを起こしませんでした。 setWebSocketAutoResponse(new WebSocketRequestResponsePair(\u0026quot;ping\u0026quot;, \u0026quot;pong\u0026quot;)) により、ランタイムが僕らのコードを動かさずに応答しました。このAPIでアプリ層のキープアライブを実装する、安上がりな方法です。ドキュメントでは、リクエストとレスポンスはそれぞれ2,048文字までです(DurableObjectState)。それより長いものは試していません。 変更は書き戻す必要があります。 deserializeAttachment() で値を得て、書き換えても、何も保存されませんでした。ドキュメントも同じことを書いています。「このメソッドを呼んだあとの値の変更は、もう一度呼ばない限り保持されない」。 アタッチメントには硬い上限があり、すぐ失敗します。 20KBの値は、呼び出し時に A WebSocket 'attachment' cannot be larger than 16384 bytes を投げました。大きな値には、ストレージに入れ、そのキーをアタッチメントに持たせるのがドキュメントの助言です(WebSocketsのベストプラクティス)。 プロトコルのpingと、アプリ層のping 2つ目の小さなクライアントは、同じ時間アイドルにした別の新しいルームに、WebSocketのプロトコルのpingフレームを3回送りました。\nimport fs from \u0026#34;node:fs\u0026#34;; import WebSocket from \u0026#34;ws\u0026#34;; const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); const room = process.argv[2] ?? \u0026#34;d\u0026#34;; // use a room name you have not used before const count = () =\u0026gt; (fs.readFileSync(\u0026#34;wrangler.log\u0026#34;, \u0026#34;utf8\u0026#34;).match(new RegExp(`\\\\[room ${room}\\\\] constructor`, \u0026#34;g\u0026#34;)) || []).length; const ws = new WebSocket(`ws://localhost:8787/?room=${room}`); await new Promise((r) =\u0026gt; ws.on(\u0026#34;open\u0026#34;, r)); await sleep(15000); // longer than the 10 s idle rule console.log(\u0026#34;constructor runs after 15 s idle:\u0026#34;, count()); let pongs = 0; ws.on(\u0026#34;pong\u0026#34;, () =\u0026gt; pongs++); for (let i = 0; i \u0026lt; 3; i++) { ws.ping(); await sleep(300); } console.log(\u0026#34;protocol pong frames received:\u0026#34;, pongs, \u0026#34;| constructor runs now:\u0026#34;, count()); ws.send(\u0026#34;hi\u0026#34;); await sleep(500); console.log(\u0026#34;after one real message, constructor runs:\u0026#34;, count()); ws.close(); constructor runs after 15 s idle: 1 protocol pong frames received: 3 | constructor runs now: 1 after one real message, constructor runs: 2 フレームには応答があり、コンストラクターの回数は1のままで、2回目の生成は本物のメッセージのときだけでした。つまり、このローカルのランタイムでは、どちらのキープアライブでもオブジェクトは起きませんでした。プロトコルレベルのフレームについては、ドキュメントにも同じことが書かれています(「Ping/pong handling does not interrupt hibernation」。WebSockets best practices)。この実験はそれと一致しました。本番では試していません。設計がこれに依存するなら、デプロイ先で試してください。\nオブジェクトを起こしたままにする2つの要因(望まないかもしれません) さらに2つのルームを、同じ15秒のアイドルで試しました。\n変種 アイドル後 ソケット受け入れ時に保留中の setInterval(() =\u0026gt; {}, 1000) をセット ハイバネーションせず: objectAgeMs 15824、messagesSeenInMemory 7、nickInMemory 保持、コンストラクターの回数は1のまま acceptWebSocket() ではなく標準API(server.accept() と addEventListener) ハイバネーションせず: objectAgeMs 15843、コンストラクターの回数は1のまま これはドキュメントの条件と合っています。忘れたタイマーや、もう1つのWebSocketの流儀があると、オブジェクトは常にメモリにいることになります。ドキュメントによれば、メモリ上でアイドルかつハイバネーション不可の状態は期間に対して課金されます(Lifecycle)。費用は測っていません。また、ドキュメントにある、保留中のI/Oと開いた外向きの接続が退避を妨げるという点は、試していません。\nハイバネーションはデプロイではない ハイバネーションは接続を保ちます。シャットダウンは保ちません。同じライフサイクルのページは、シャットダウン(新しいデプロイ、ランタイムの更新、ホスティングの判断)では「WebSocketのリクエストは自動的に終了される」と書き、Durable Objectは「いつでもシャットダウンしうる」し、シャットダウンフックはないとしています。だからクライアントには再接続の仕組みが引き続き必要で、サーバーは重要な状態を少しずつ書き込む必要があります。デプロイは試していません。\nここから得られるルール コンストラクターは軽くし、初めての起動だと決めつけない。 ハイバネーションのたびに再実行されます。「ルームを作った」の通知に使ってはいけません。必要なものはストレージから読みます。 ソケットごとの状態はアタッチメント、ルームごとの状態はストレージ。 クラスのフィールドはキャッシュで、どのメッセージでも空かもしれません。 変更のたびに再シリアライズする。 誰も変更だけして忘れないよう、1つのヘルパーにまとめます。 アタッチメントは小さく。大きなものはストレージに入れ、そのキーをアタッチメントに持たせる。 アプリ層のキープアライブは自動応答にする。 オブジェクトがそれらの間も眠れます。 ハイバネーションさせたいオブジェクトでは、タイマーを使わず、外向きの接続も持たない。 代わりにプラットフォームのスケジューリング機能を使います(アラームは試していません)。 ハイバネーションが向く場面 状況 選択 ほとんどアイドルの接続が多数(チャットルーム、在席、通知)で、接続ごとの状態が小さい Hibernation API ソケットがつながっている間、オブジェクト自身が定期的な処理をする必要がある(ゲームのティック、上流のポーリング) タイマーがあるとメモリに残ります。意図して決め、期間課金を受け入れるか、定期処理を別のオブジェクトに分けます。 このオブジェクトが別のサービスへの長寿命の外向きWebSocketを持つ その接続が開いている間はハイバネーションできません。 接続ごとの状態が大きい ストレージに入れ、キーをアタッチメントに 静かな時間のあとにだけ出るバグ クラスのフィールドに状態を置く。 症状: 静かな時間のあとにカウンターが戻る、「誰がルームにいるか」が間違うか空になる。上で再現しました。直し方: ストレージかアタッチメントを使い、誰がつながっているかは getWebSockets() を正とします。 デシリアライズしたアタッチメントを書き換える。 症状: ニックネームやカーソルの変更が、動くのに静かな時間のあとに消える。再現しました。直し方: もう一度 serializeAttachment() を呼びます。 16KiBを超えるアタッチメント。 症状: 呼び出し時の例外。再現しました。直し方: ストレージとキーです。 残ったタイマーか標準API。 症状: オブジェクトがハイバネーションせず、期間の課金が増える。「ハイバネーションしない」部分だけ再現しました。直し方: 取り除くか、受け入れるかです。 コンストラクターを「ルーム作成」イベントとして使う。 症状: アイドルのあとにウェルカムメッセージが繰り返される。上のコンストラクターの回数が理由を示しています。直し方: 1回きりの処理は、ストレージのフラグの後ろに置きます。 賑やかなルームだけでテストする。 症状: デモでは動くのに、昼休みのあとで壊れる。直し方: アイドルの規則より長く眠るテストを必ず入れます。 ローカルで動かす Node 22の作業ディレクトリを作り、npm i wrangler ws して、上の4つのファイルを保存します。 示したとおり開発サーバーを起動し、出力を wrangler.log に書かせます。 node client.mjs 15 \u0026quot;?room=a1\u0026quot; を実行します。成功なら、「後」の行が messagesSeenInMemory 1、nickInMemory null、constructions 2、nick はそのままです。 node client.mjs 15 \u0026quot;?room=a2\u0026amp;timer=1\u0026quot; を実行します。成功なら、「後」の行が messagesSeenInMemory 7で、コンストラクターの回数は1のままです。 node client.mjs 15 \u0026quot;?room=a3\u0026amp;std=1\u0026quot; を実行します。成功なら、ハイバネーションしません。 node frame_ping.mjs a4 を実行します。成功なら、pongが3つで、回数は1のままです。 15 を 5 に変え、ハイバネーションするかを見ます。先に予想してから、ドキュメントの10秒と比べてください。 ローカルのworkerdと本番の違い 確認できたこと: wrangler dev --local(wrangler 4.147.0、ローカルのworkerd、Node 22.23.3、互換性日付2026-04-07、Linux 6.12)で、「見えたこと」「プロトコルのpingと、アプリ層のping」「オブジェクトを起こしたままにする要因」のすべてを、新しいルーム名で1回ずつ、出力されたとおりに確認しました。16KiBのアタッチメント上限、2,048文字の自動応答の上限、ライフサイクルの条件、10秒と70〜140秒の数字、シャットダウンの記述は、書く際にCloudflareのドキュメントで読みました。\n確認できていないこと: Cloudflareの本番ランタイム(ローカルのworkerdは本番と挙動が違うかもしれません。ドキュメントによれば、wrangler 3.13.2より前のローカル開発ではそもそもハイバネーションしなかったので、バージョンにも依存します)、課金、アラーム、タグ、70〜140秒の退避、ハイバネーション可能なソケット数の上限、シャットダウンとデプロイの挙動、1ルームに2接続以上の場合。15秒のアイドルは、ドキュメントの10秒を超えるように選んだ実験用の値です。ローカルのタイミングは、本番の約束ではありません。\nフィールドはキャッシュ ハイバネーションするDurable Objectのクラスのフィールドは、すべて「次のメッセージでは空かもしれないキャッシュ」として扱います。ソケットは残り、書き留めた状態は残り、それ以外は残りません。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/durable-objects-websocket-hibernation/","summary":"CloudflareのドキュメントはWebSocket Hibernationについて、接続は維持され、メモリ上の状態は破棄される、と書いています。この記事では小さなDurable Objectをローカルのworkerdで動かし、それが具体的に何を意味するかを観察します。ソケットはOPENのまま、次のメッセージでコンストラクターが再実行され、クラスのフィールドは初期化され、アタッチメントはserializeしたものだけが残り、保留中のタイマーや標準のWebSocket APIを使うとハイバネーションがひそかに無効になります。本番の挙動や課金は試していません。","title":"Durable ObjectがハイバネーションするとWebSocketは残り、クラスのフィールドは消える"},{"content":"Apple の APNs の文書には、「APNs は bundle ID ごとに1件の通知しか保持しない」と書かれています。Google の FCM の文書には、オフラインの Android 端末が保持する折りたたみ不可のメッセージは最大100件で、上限に達するとすべて破棄される、と書かれています。どちらの文も、プッシュをスマートフォンへのメッセージキューのように扱い、変更を1件ずつ運ばせる設計とは合いません。\nその設計は、机の上ではうまくいきます。そのあと誰かが1時間機内モードにしたり、アプリを強制終了したり、サーバーが1分に20件の更新を送ったりします。以下では、2つのサービスが文書化している内容を一覧にし、クライアントが何をしなければならないかをシミュレーションで示します。\nTL;DR Apple は、APNs がベストエフォートで、通知の順序を入れ替えることがあり、オフラインの端末については 1つの bundle ID につき1件しか保持しないと文書化しています。Google は、FCM が順序を保証せず、オフラインの Android 端末に対して折りたたみ不可のメッセージを最大100件までしか保持せず、上限を超えるとすべて破棄すると文書化しています。 iOS のバックグラウンド(サイレント)通知は、優先度が低く、配信は保証されず、頻繁だと間引かれ、ユーザーがアプリを強制終了すると破棄されると文書化されています。 つまりプッシュは、失われ、新しいものに置き換えられ、自分の再送で重複し、遅れて届きます。ペイロードをデータとして扱うクライアントは、そのどれからも回復できません。 プッシュはヒントとして設計します。「何かが変わった。カーソルはこれ」と伝えるだけです。クライアントは自分のカーソルから取得します。アプリを開いたときと同じコードを使います。取得のきっかけには、プッシュに頼らないものを少なくとも1つ足します。アプリのフォアグラウンド化や定期的な同期などです。 損失のあるチャネルのシミュレーションでは、ペイロードを適用する方式が収束したのは1000回中77回、ヒントのあとに取得する方式は855回、それにフォアグラウンドの同期を1回足すと1000回中1000回でした。数値は僕が作ったチャネルのものです。APNs や FCM の配信率ではありません。 文書が「起こりうる」と言っていること 状況 文書の記述 クライアントにとっての意味 端末がオフライン APNs は通知を保持することがあり、apns-expiration に応じて最長30日で、端末が次にオンラインになったときに配信します。bundle ID につき1件だけ保持し、多くは最新のものですが、「常に保証されるわけではありません」。 5件の変更に対してプッシュが1件しか届かないことがあります。 端末がオフライン(Android) FCM は端末が接続するまでメッセージを保持します。折りたたみ不可のメッセージは、Android では100件まで。超えると保持中のものはすべて破棄され、あとでアプリは全同期を促す特別な通知を受け取ります。 100件の変更で何も届かず、その後「取りこぼした」という呼び出しが来ます。 折りたたみ 折りたたみ可能なメッセージは、未配信のものを置き換えます。FCM は登録トークンごとに最大4つの collapse key しか保持しません。通知メッセージは常に折りたたみ可能です。 古いペイロードは仕様として消えます。 順序 APNs は「同じデバイストークンに送った通知の順序を入れ替えることがある」とし、FCM は「配信の順序を保証しない」としています。 到着順にペイロードを適用すると、逆順に適用することがあります。 有効期限 apns-expiration が 0 なら1回だけ試して保持しません。FCM の ttl が 0 なら、すぐ配信できなければ破棄します。FCM の既定の保持期間は4週間で、それより長くオフラインだと破棄されます。 短い有効期限は、意図的な破棄です。 iOS のバックグラウンド更新 バックグラウンド通知は優先度が低く、「システムは配信を保証しません」。間引かれることがあり、Apple は1時間に2、3件を超えないよう求めています。新しいものは保持中のものを置き換えます。アプリが強制終了されると、保持中のものは破棄されます。 サイレントプッシュは合図にすぎず、強制終了で破棄されうるものです。 優先度と Doze(Android) 通常優先度のメッセージは Doze 中に遅れることがあります。目に見える通知にならない高優先度のものは優先度を下げられることがあります。ハンドラーに与えられる時間は数秒です。 タイミングは予測できず、長い処理はジョブに回します。 アプリの削除 FCM はメッセージを破棄し、トークンを無効にします(HTTP v1 API では UNREGISTERED、旧 API では NotRegistered)。 死んだトークンは取り除きます。 Apple と Google の現在の文書から集めました。重要なのは個々の行ではありません。どの行も、「送ったペイロード」と「アプリが見たペイロード」が食い違う経路だということです。\nプッシュをヒントとして扱う プッシュが失われ、置き換えられ、遅れ、順序が入れ替わるなら、正しさをそれに依存させることはできません。プッシュに残る仕事は一番安いものです。アプリを起こして「サーバーに聞いて」と伝えること。真実はサーバーが持ち、順序のある変更ログを持ちます。クライアントはカーソルを持ちます。\n// サーバー:真実がある唯一の場所 changes(since) { if (log.length \u0026amp;\u0026amp; since \u0026lt; log[0].seq - 1) return { reset: true, ...this.snapshot() }; // ログがそこまで遡れない return { cursor: seq, events: log.filter((e) =\u0026gt; e.seq \u0026gt; since) }; } // クライアント:プッシュは取得する理由にすぎない const hintClient = (server) =\u0026gt; { const s = { items: {}, cursor: 0 }; const pull = () =\u0026gt; { const r = server.changes(s.cursor); if (r.reset) { s.items = { ...r.items }; s.cursor = r.cursor; return; } for (const e of r.events) if (e.seq === s.cursor + 1) { applyOp(s, e); s.cursor = e.seq; } // 連続するものだけ }; return { s, onPush: (msg) =\u0026gt; { if (msg.hintCursor \u0026gt; s.cursor) pull(); }, sync: pull }; }; プッシュのペイロードは { hintCursor } だけです。クライアントは自分のカーソルと比べ、遅れていれば取得します。重複したプッシュは何もせず、古いプッシュも何もせず、別のプッシュを追い越したプッシュは、両方をまとめて取得する1回の取得になります。イベントは次の連番のときだけ適用するので、欠落を黙って飛び越えることはありません。サーバーがログを、クライアントのカーソルより先まで切り詰めていたら、リセットとスナップショットで答えます。\nシミュレーション 今回は APNs や FCM にアクセスできなかったので、チャネルはモデルです。上の表で文書から引用した動作だけを実装しました。損失、重複、ランダムな遅延。オフライン端末に対する「最新のものだけ保持」。「上限を超えるとすべて破棄」です。サーバーは30件の変更を適用し、ログには8件だけ残します。チャネルごとに1000通りのシードで、2種類のクライアントを比べました。\nchannel payload-applied hint + pull hint + pull + 1 foreground sync lossy 77/1000 converged 855/1000 1000/1000 keepLatest 4/1000 converged 1000/1000 1000/1000 overflow 0/1000 converged 0/1000 1000/1000 ここでのペイロード方式は、わざと素朴に作ってあります。届いたものを、連番の確認も取得もなしにそのまま適用します。連番を足せば欠落には気づけますが、足りない分を取りに行く手段は結局必要で、それが取得です。結果は次のように読んでください。\nペイロードを適用する方式は、ほとんどサーバーの状態に届きません。損失、重複、順序の入れ替えが、それぞれ別の形でこれを壊します。 ヒント + 取得は、損失のあるチャネルではほぼ正しく、keepLatest では完全に正しくなります。残った1件のプッシュだけで全体の取得が始まるからです。最後のプッシュが失われた場合(損失のあるチャネルで145回)と、何も届かない場合(overflow)は失敗します。取得が一度も始まらないからです。 フォアグラウンドの同期1回で、すべての欠落が埋まります。これが実用上の結論です。プッシュは動いているとき、アプリをすばやく新しくします。独立したきっかけが、動かなかったときにも正しさを保ちます。 正確な件数は、僕が選んだ損失率と遅延(損失30%、重複10%、最大4ステップの遅延)に左右されます。push-hint.mjs で変えれば、ヒントだけの列は動きます。パターンは変わらないはずです。\nプッシュがヒントの場合と、プロダクトそのものの場合 アプリの状態がサーバーにあり、画面が古いことがバグになるなら、プッシュをヒントにします。メッセージング、共同編集、タスク一覧など、あらゆる同期です。まとめて送れるので、プッシュの量も抑えられます。\nプッシュそのものがプロダクトの場合、たとえばワンタイムコードや、文面がすべての時間依存のアラートでは、ペイロードに内容を載せ、有効期限と優先度を意図して設定します。その場合でも、アプリの状態をそれに依存させないでください。目に見える通知と状態の変更は、別の仕事です。\nシミュレーションを動かす mkdir push-sim \u0026amp;\u0026amp; cd push-sim # 実験の push-hint.mjs と push-hint.test.mjs をここに置く node push-hint.mjs node --test push-hint.test.mjs ペイロード方式がなぜ失敗するかを見るには、payloadClient の onPush に受信内容を出力する行を足して、run({ channel: 'lossy', seed: 7 }) を1回実行してください。\nプッシュの設計が崩れる場面 状態をペイロードに載せること。 症状: 2台の端末が違うデータを表示しますが、どちらも通常の意味で古いわけではありません。別々の部分集合を適用したためです。対処: 新しい値ではなくカーソルかバージョンを送り、値は取得で返します。 プッシュに依存しない取得のきっかけがないこと。 症状: シミュレーションの overflow の行です。オフライン中のバーストのあと何も届かず、ほかの何かが促すまでアプリが古いままになります。対処: フォアグラウンド化、再接続、理由を説明できる間隔のタイマーで同期します。 次ではないイベントを適用すること。 症状: 順序が入れ替わった2件のあと、状態が微妙に狂い、直りません。対処: cursor + 1 だけを適用し、それ以外なら取得し直します。 遡れないログで、スナップショットの経路がないこと。 症状: しばらくオフラインだったクライアントが、サーバーがすでに切り詰めたイベントを求めて、エラーや空の応答を受け取ります。対処: リセットとスナップショットを返し、テストで保持期間を縮めて確かめます。 変更ごとにサイレントプッシュを送ること。 症状: iOS が間引きます。Apple は1時間に2、3件を超えないよう求めています。対処: サーバーでまとめます。バーストごとに1件のヒントで、10件分と同じ効果があります。 onMessageReceived で長い処理をすること。 症状: ハンドラーが途中で打ち切られます。Google は、使える時間は数秒で、それ以上は WorkManager を使うよう勧めています。対処: ハンドラーでは同期ジョブを予約し、取得はそこで行います。 死んだトークンを持ち続けること。 症状: 送信失敗が増え、無駄な処理が増えます。対処: FCM が未登録と報告したトークンを削除し、FCM が提供する配信データで破棄されたメッセージを確認します。 計測したことと、モデルにすぎないこと 確認したことです。シミュレーションを Node.js 22.23.3 で動かしました(node push-hint.mjs と、node --test による3つのテスト。全チャネル・全シードでヒント + 同期が収束すること、ペイロード適用が収束しないこと、取得のきっかけがなければ stale のままになることを確かめます)。上の表については、Apple と Firebase のページを読みました。apns-expiration、bundle ID につき1件の保持、100件の上限、強制終了の記述を含みます。\n確認していないことです。実際の APNs や FCM の挙動すべて。チャネルは文書の記述のモデルで、配信の計測ではありません。iOS や Android のアプリは動かしていません。自分のアプリの実数が欲しい場合、FCM は照会できる配信データを提供しており、APNs では apns-id と Apple が案内する指標が手がかりです。僕はそれらを使っていません。\n届かないプッシュを前提にアプリを作る 両サービスの文書が描くのは、ベストエフォートの合図です。合図だけで足りるようにアプリを作ります。サーバーからのカーソル、どのきっかけでも走らせられる取得、ログが先に進んでしまった場合のスナップショットです。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/push-notifications-are-hints-apns-stores-one/","summary":"端末がオフラインのとき、アプリが終了されたとき、送信が多すぎるときにプッシュがどうなるかを、AppleとGoogleは文書で説明しています。通知は置き換えられ、捨てられ、順序が入れ替わり、遅れます。文書に書かれた失敗モードの表と、ペイロードを適用するクライアントが収束せず、カーソルから取得するクライアントが収束することを示す小さなシミュレーションを紹介します。","title":"APNs が保持する通知は1アプリ1件だけなので、プッシュはヒントとして設計する"},{"content":"API が、複数の種類のクライアントにアクセストークンを発行するとします。対話型のアプリ、無人のバッチ、キオスク端末です。全部が同じことをできてはいけません。バッチは削除できてはならず、キオスクは読み取りだけです。この制限はどこで強制すべきでしょうか。あとから、何も壊さずに厳しくするにはどうすればよいでしょうか。\n4つの設計を、小さな発行者と3つのバージョンの検証側に対して試しました。もっとも自然に見える設計には、JWT の標準そのものが生む穴がありました。\nTL;DR RFC 7519 の4節は、検証側が理解できないクレーム名は「無視しなければならない(MUST be ignored)」と定めています(他の規則がない場合)。拡張性のためには正しい既定です。同時に、新しい制限のクレームは、古い検証側からは見えません。 実験では、scope: \u0026quot;notes:read notes:write\u0026quot; に新しいクレーム ro: true を付けたトークンは、ro を知っている検証側では読み取り専用、知らない検証側では読み書き可能でした。失敗は何も起きません。 安全な規則は、無視されたクレームが与えるのはより少ない権限だけ、というものです。制限は、すべての検証側がすでに強制している値(ここでは scope)を発行者側で絞って表現し、新しいクレームは、なくても無害なものに使います。 発行者で絞ります。scope = requested ∩ ceiling[kind] です。クライアントが求めた内容を信用せず、ボタンを隠すだけの UI にも頼りません。 クライアントの種類を読む検証側では、欠けている場合と未知の場合は拒否側に倒します。kind のないトークンは最も低い上限、未知の kind は拒否します。 crit ヘッダーは「理解できなければ拒否」の仕組みを与えますが、対象は JOSE のヘッダーのパラメーターだけで、ペイロードのクレームには使えません。 前提 発行者は各クライアントの種類を知っていて、上限の表を持っています。\nconst CEILING = { interactive: [\u0026#39;notes:read\u0026#39;, \u0026#39;notes:write\u0026#39;, \u0026#39;notes:delete\u0026#39;], kiosk: [\u0026#39;notes:read\u0026#39;], batch: [\u0026#39;notes:read\u0026#39;, \u0026#39;notes:write\u0026#39;], }; トークンは HS256 で署名した JWT(実験専用のデモ鍵)で、typ: at+jwt です。「検証側」は、時間とともに変わるリソースサーバーのバージョンを表す3つの小さな関数です。ライブラリは jose の 6.2.12 で、以下はすべて Node.js 22.23.3 で動かしました。\n案1:クライアントが求めたものを信用する クライアントが欲しいスコープを送り、発行者がそのまま署名します。バッチのクライアントが notes:delete を求めた時点で破綻します。それを妨げるものは、プロトコルのどこにもありません。\n修正は最終設計の第一の規則で、コードは1行です。\nexport function grant(requested, kind) { const ceiling = CEILING[kind]; if (!ceiling) throw new Error(`unknown client kind: ${kind}`); return requested.filter((s) =\u0026gt; ceiling.includes(s)); } これがあれば、3つのスコープを求めた batch のクライアントには2つが渡され、未知の種類は既定値ではなくエラーになります。単独では不採用。基礎の層として残す。\n案2:制限を表す新しいクレームを足す あとから、一部のトークンを読み取り専用にしたくなったとします。きれいな方法は新しいクレーム ro: true です。それを理解する検証側は、読み取り以外のスコープをすべて落とします。\n// v2: 新しいクレームを知っている if (payload.ro === true) scopes = new Set([...scopes].filter((s) =\u0026gt; s.endsWith(\u0026#39;:read\u0026#39;))); 問題は、それを知らない検証側です。リソースサーバーのバージョン1は、標準に従って scope だけを読み、ほかは読みません。両方のスコープと ro: true を持つトークンを発行して、2つの検証側で確かめました。\nv2(ro を知っている): {notes:read} v1(ro を無視する): {notes:read, notes:write} 何も壊れず、何も記録されず、「読み取り専用」のトークンが書き込みできました。リソースサーバーの更新が発行者より遅れる構成や、同じトークンを別のライブラリ、別のサービスが検証する構成では、この窓が開きます。不採用。失敗したときに開く側に倒れる。\n案3:crit で古い検証側に拒否させる JOSE には、まさにこの種の変更のための仕組みがあります。crit ヘッダーパラメーター(RFC 7515 の4.1.11節)は、受信側が理解しなければならない拡張を列挙します。理解できなければ、トークンを拒否しなければなりません。crit に ceil ヘッダーパラメーターを挙げたトークンに署名して試しました。\n古い検証側: Extension Header Parameter \u0026#34;ceil\u0026#34; is not recognized (拒否) 対応した検証側。{crit: {ceil: true}} を指定: 受理 望んだ動作で、双方で1行を足すだけです。ただし、2つの制約があるため、部分的な答えにとどまります。\n対象はヘッダーのパラメーターです。crit はクレームには適用できないので、ペイロードのクレームに入れた制限を critical と印付けることはできません。実験でも、制限をヘッダーに置く必要がありました。 双方が crit を正しく実装したライブラリを使う必要があります。jose は実装しています。ほかのライブラリは調べていません。検査を省くライブラリを使うと、案2に戻ります。 まれで、無視されては困る変更には有用。ただし、既定にはしない。\n案4:検証側にすべての制限を満たさせる Macaroons(Birgisson ら、NDSS 2014)の設計は、既定を逆にします。トークンは caveat と呼ぶ条件の一覧を持ち、検証では、リクエストの文脈ですべての caveat が成り立つ必要があります。評価できない caveat があれば、検証は失敗します。これなら、いつでも安全に制限を足せます。一方で、トークン形式、ライブラリ、トークンの考え方がすべて変わるので、作らず、macaroons のライブラリも動かしていません。委譲の連鎖には正しい設計。ここの問題には変更が大きすぎる。\n僕が選ぶ設計 テストでうまくいったものを組み合わせます。\n発行者で絞ります。 grant() を使います。トークンの scope は、クライアントの種類の上限を超えることがありません。 すべての検証側がすでに強制しているクレームを絞って制限を表します。 「読み取り専用」が必要なら、scope を notes:read にしたトークンを発行します。古い検証側は、理由を知らなくても強制します。3つの検証側がどれも {notes:read} を返すことを確かめました。 種類を読む検証側のために、クレームで種類を持たせ、その検証側は拒否側に倒します。 strict の検証側は、kind のないトークンを最も制限された種類として扱い、未知の種類を拒否し、読んだスコープに上限を再適用します。 export async function verifyStrict(jwt) { const { payload } = await jwtVerify(jwt, key, { algorithms: [\u0026#39;HS256\u0026#39;], typ: \u0026#39;at+jwt\u0026#39; }); const kind = payload.kind ?? MOST_RESTRICTED; // kind のない過去のトークン if (!CEILING[kind]) throw new Error(`unknown kind: ${kind}`); // 推測しない const scopes = String(payload.scope ?? \u0026#39;\u0026#39;).split(\u0026#39; \u0026#39;).filter(Boolean); return new Set(scopes.filter((s) =\u0026gt; CEILING[kind].includes(s))); // 念のための再適用 } 明示的な型付けを使います。 検証側は typ: at+jwt(RFC 9068 がアクセストークンの JWT に使う値)を要求するので、同じ鍵で署名された別の目的のトークンは拒否されます。RFC 8725 の3.11節です。別の typ のトークンが失敗することを確かめました。 最後のテストが、規則をもっとも端的に示します。トークンに、未知の権限を与えるクレームを足しました。V1 はそれを無視し、結果はそのクレームがない場合とまったく同じスコープでした。加算するクレームを無視しても無害です。減算するクレームを無視すると無害ではありません。\n発行者の上限で足りる場面と、足りない場面 複数の種類のクライアントが1つの認可サーバーを共有し、持てる範囲が違うとき、または制限をあとから厳しくしたいときは、発行者側の上限を使います。安く済み、強制する場所は1つの関数です。\nトークンの保持者が、自分のトークンを受け渡す前に自分で絞る(リクエストごとの権限の縮小)ことは、解決しません。それは macaroon 型のトークンの役目です。データ自体の検査の代わりにもなりません。スコープが示すのは許される操作の種類で、どのレコードかではありません。\n上限のテストを動かす mkdir ceiling \u0026amp;\u0026amp; cd ceiling \u0026amp;\u0026amp; npm init -y \u0026gt;/dev/null \u0026amp;\u0026amp; npm i jose@6.2.12 # 実験の issuer.mjs と issuer.test.mjs をここに置く node --test issuer.test.mjs 7つのテストは次のとおりです。ro の穴、scope を絞ること、欲張りなクライアントの制限、crit による拒否とヘッダー限定の制約、明示的な型付け、過去のトークンと未知の種類、無害な未知の権限付与クレームです。穴を直接体験するには、verifyV2 から ro の判定を消して、2つの検証側が読み書きで一致するのを見てください。\n上限の仕組みが壊れる場面 制限を新しいクレームで表すこと。 症状: 更新されていないサービスでは、制限したはずのトークンで書き込めて、エラーも出ません。対処: 代わりに scope を絞り、新しいクレームはなくても安全なものとして扱います。 クライアント側だけで絞ること。 症状: 改造したクライアントや直接の呼び出しが、より多くを求めて得てしまいます。対処: リフレッシュも含め、発行のたびに発行者で上限と交差させます。 新しいクレームのない古いトークンを、最も強い権限として扱うこと。 症状: kind を導入したあと、導入前に発行されたトークンがもっとも強力になります。対処: 欠けている場合は最も低い上限に対応させ、トークンは短命にして自然に消えるようにします。 未知の種類に既定値を当てること。 症状: 打ち間違いや新しいクライアントの種類が、黙って対話型の権限を受け取ります。対処: 発行者と検証側の両方で、未知の種類は例外にします。 crit がペイロードのクレームも守ると思い込むこと。 症状: あるクレームを「critical」にしたつもりで、古い検証側が拒否してくれると信じてしまいます。対処: crit はヘッダーのパラメーターにだけ使い、古い検証側で必ず試します。 鍵で署名された JWT なら何でも受け入れること。 症状: ID トークンや別のサービスのトークンが、アクセストークンとして受け入れられます。対処: 期待する typ を要求し、audience と issuer も検証します。実験で見せているのは typ だけです。 テストが覆う範囲と、残る範囲 確認したことです。Node.js 22.23.3 と jose 6.2.12 で、上の7つの動作すべてを確認しました(node --test、7つのテスト成功)。認識できない critical ヘッダーパラメーターに対する jose の正確なエラーも含みます。RFC 7519 の4節と7.2節、RFC 7515 の4.1.11節、RFC 8725 の3.11節を RFC の本文で読みました。macaroons については論文の検証手順を読みました。\n確認していないことです。ほかの JWT ライブラリ(特に crit と未知のクレームの扱い)、非対称鍵のアルゴリズム(実験では簡単のため共有の HS256 鍵を使っています)、トークンの失効、本物のリソースサーバーでの動作です。「古い検証側」と「新しい検証側」は、1つのファイルにある3つの関数で、時間経過に伴うバージョンを真似たものです。実際のデプロイではありません。\n次のクレームを出す前に 理解できないものを無視する検証側は、新しいクレームが言いうる最悪のことの分しか安全ではありません。クレームが加算するなら、無視しても何も失いません。減算するなら、全員がすでに強制している値に効果を入れ、欠けている場合の扱いは、それが起きる前に決めておきます。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/ignoring-unknown-jwt-claims-only-removes-access/","summary":"JWT の仕様は、検証側が理解できないクレームは無視するよう定めています。権限を与えるクレームなら無害ですが、制限するクレームでは危険です。クライアントの種類ごとに持てる権限の上限を決める4つの設計を、小さな発行者と3種類の検証側で試し、僕が選ぶ設計を示します。","title":"古い JWT 検証側は新しい制限クレームを無視するので、制限は scope を絞って表す"},{"content":"銀行のような API に、2種類の呼び出しがあるとします。口座の参照は、ログイン済みのユーザーなら足ります。送金は、直近の数分以内に第二要素でサインインしたユーザーでなければなりません。最初のサインインの時点では、API は後者を要求できません。リスクはリクエストごとに変わるからです。だから、送金への最初の応答は「このトークンでは足りない。もう一度認証してほしい」となります。\nこのメッセージを標準化したのが RFC 9470(OAuth 2.0 Step Up Authentication Challenge Protocol)です。短い文書です。最初から最後まで読み、認可サーバー、リソースサーバー、クライアントの試作を作って、何が決まっていて何が決まっていないかを調べました。\nTL;DR チャレンジは WWW-Authenticate: Bearer error=\u0026quot;insufficient_user_authentication\u0026quot; で、任意の acr_values と max_age が付きます。RFC の例はすべて HTTP 401 です。insufficient_scope に付く 403 を持ち込まないでください。 クライアントはこれらを通常の認可リクエストにします。OpenID Connect が定義済みの acr_values と max_age と同じパラメーターです。ネイティブアプリは、システムブラウザと PKCE で行います(RFC 8252)。 複数のリクエストが同時にチャレンジされた場合や、求められたレベルにユーザーが到達できない場合に、クライアントがどうするかは RFC に書かれていません。試作のクライアントでは、並列の送金3件で、ステップアップを単一フライトにするまでプロンプトが3回出ました。求められたレベルを出せない認可サーバーが相手のときも、プロトコルにはクライアントの再プロンプトを止める仕組みがないので、クライアント側に止める仕組みが必要です。 認可に依存するレスポンスをキャッシュするときは、キーをユーザー単位ではなくトークン(またはそのレベル)にします。ユーザーをキーにしたキャッシュは、ステップアップに成功したあとも古い縮小表示を返しました。 試作のリソースサーバーは、レベルを判定できないとき、リスクの高いルートに 503 を返します。チャレンジは返さず、許可もしません。これは僕の設計上の選択で、RFC が定めているものではありません。 プロトコルの全体像 RFC の順に、細部を見ていきます。\n3節:チャレンジ RFC はエラーコードを1つ追加し、insufficient_user_authentication としています。auth-param は2つです。\nacr_values:認証コンテキストクラス参照のスペース区切りの一覧で、「優先順」に並びます。保護されたリソースは、そのうちどれか1つを求めます。 max_age:最後の能動的な認証イベント(ユーザーが認可サーバーとやりとりしたこと)から許容される経過秒数です。token でも quoted-string でもよいものの、「非負の整数を表す必要がある」とされています。 RFC の例(図2と図3)は、どちらも HTTP/1.1 401 Unauthorized です。RFC 6750 は insufficient_scope に 403 を組み合わせていて、それをそのまま持ち込みやすいところです。このプロトコルで問題になっているのは、トークンが何を許すかではなく、ユーザーがどうログインしたかです。応答は再認証の要求なので、401 が合います。クライアントのライブラリが 401 でしか再試行しないなら、ステータスは重要です。\n値が引用符つきでも、なしでも来るので、パーサーが最初にバグの入りやすい場所です。僕のパーサーは両方を読みます。\nexport function parseBearerChallenge(header) { if (!/^Bearer\\s/i.test(header ?? \u0026#39;\u0026#39;)) return null; const params = {}; for (const m of header.slice(7).matchAll(/([a-z_]+)=(?:\u0026#34;((?:[^\u0026#34;\\\\]|\\\\.)*)\u0026#34;|([^\\s,]+))/gi)) params[m[1].toLowerCase()] = m[2] ?? m[3]; return params; } max_age=\u0026quot;5\u0026quot; と max_age=5 を同じに読み、RFC の図2と図3の形でテストしました。1つのヘッダーに対する正規表現なので、本番のクライアントでは、複数のチャレンジを持てる WWW-Authenticate の本物のパーサーを使ってください。\n4節と5節:認可リクエストとその応答 クライアントは、WWW-Authenticate から acr_values と max_age を読み取って新しい認可リクエストに使う(SHOULD)とされています。この2つは OpenID Connect の既存のリクエストパラメーターです(OIDC Core 3.1.2.1)。max_age は、最後の認証がそれより古ければ能動的な再認証を強制し、サーバーは auth_time を返す必要があります。ネイティブアプリには RFC 8252 の要件が2つあります。公開クライアントは PKCE を使うこと(6節)、リクエストは埋め込みの Web ビューではなく外部のユーザーエージェントで行うこと(8.12節)です。\n5節は、実務で問題になる状況を扱っています。認可サーバーが求められたレベルを満たせないことがあります。たとえば第二要素が登録されていないユーザーです。OIDC では、セッションが実際に持つレベルを返してよいことになっています。アクセストークンについては、この RFC は代わりに unmet_authentication_requirements で失敗させる(SHOULD)としています。そうしないと、「認可サーバーが、リソースサーバーがすでに要件を満たさないと判断したトークンを返し続ける、ループにクライアントが陥る」からです。\nこの文は、正常系だけで書いたときに入るバグをそのまま描いています。OIDC の既定の動作に従うサーバーは、元のレベルのトークンを返し、リソースサーバーが再びチャレンジし、クライアントがユーザーにもう一度プロンプトを出します。RFC はそれをサーバー側の問題としていますが、クライアントはそれに頼らない作りにする必要があります。\nRFC がクライアントに残していること RFC では決められない判断を、クライアントに3つ持たせました。\n複数のチャレンジに対して、プロンプトは1回。 画面は複数のリクエストを同時に出すことがよくあります。送金3件が同時にチャレンジされ、それぞれがログインを始めると、ユーザーにはプロンプトが3回出ます。共有の Promise にして、最初のチャレンジがステップアップを始め、残りはそれを待ってから、最初のものが得たトークンで再試行します。\nループ防止。 1回ステップアップしたあとの再試行が、再びチャレンジされることがあります。たとえば認可サーバーが mfa ではなく pwd に到達した場合です。クライアントはリクエストごとにステップアップの回数を数え、1回で諦めて、呼び出し元に説明を任せる形で 401 を返します。\nトークンは送信時に読む。 ステップアップが終わる前にキューに入ったリクエストは、古いトークンで出してはいけません。クライアントは、送る瞬間に current を取ります。\nexport function makeClient({ rs, as, token, maxStepUps = 1 }) { let current = token, stepUp = null; const doStepUp = async (params) =\u0026gt; { stepUp ??= (async () =\u0026gt; { // 単一フライト const code_verifier = randomBytes(24).toString(\u0026#39;base64url\u0026#39;); const code_challenge = createHash(\u0026#39;sha256\u0026#39;).update(code_verifier).digest(\u0026#39;base64url\u0026#39;); const code = as.authorize({ acr_values: params.acr_values, max_age: params.max_age, code_challenge }); current = as.token({ code, code_verifier }).access_token; })().finally(() =\u0026gt; { stepUp = null; }); return stepUp; }; return { async call(method, path) { for (let attempt = 0; ; attempt++) { const bearerUsed = current; // 送信時に読む const res = rs(method, path, bearerUsed); const ch = res.status === 401 ? parseBearerChallenge(res.headers[\u0026#39;www-authenticate\u0026#39;]) : null; if (ch?.error !== \u0026#39;insufficient_user_authentication\u0026#39;) return res; if (attempt \u0026gt;= maxStepUps) return { ...res, gaveUp: true }; // ループ防止 if (current === bearerUsed) await doStepUp(ch); else if (stepUp) await stepUp; } }, }; } 実際のアプリでは、as.authorize はシステムブラウザを経由する往復で、await にはユーザーが操作する時間も含まれます。RFC の2節は、クライアントがアクセストークンを不透明なものとして扱い、トークンからレベルを読み取ってはならないとも述べています。ここでクライアントがレベルを知るのは、チャレンジを通じてだけです。同じ節は、新しいトークンが古いものを置き換えるとは限らず、クライアントが両方を持って呼び出しごとに選んでもよいとも述べています。試作のクライアントは current を1つしか持たない簡略版です。\n6節:リソースサーバーがレベルを知る方法 リソースサーバーは、トークンの acr と auth_time を必要とします。JWT のアクセストークンならクレームです(RFC 9068)。不透明なトークンなら、イントロスペクションで返ってきます。試作のサーバーはそれらを表に持ち、ルートごとにポリシーを適用します。\nconst policy = { \u0026#39;GET /account\u0026#39;: { acr: [\u0026#39;pwd\u0026#39;, \u0026#39;mfa\u0026#39;] }, \u0026#39;POST /transfer\u0026#39;: { acr: [\u0026#39;mfa\u0026#39;], maxAgeSec: 300, onUnknown: \u0026#39;closed\u0026#39; }, }; maxAgeSec は auth_time と比べます。ステップアップのあとでも、max_age により同じトークンは5分後に再び古くなり、次の送金は改めてチャレンジされます。これは時計を差し込んで確かめました。\nonUnknown が、失敗時の選択です。イントロスペクションが止まっているとき、試作のサーバーはリスクの高いルートに 503 と Retry-After を返し、チャレンジは返しません。チャレンジを返すと、成功しないログインにユーザーを通すことになります。許可してしまうと、システムの調子が悪いまさにその時に安全装置が消えます。リスクの低いルートは基本の表示を返します。どちら側に倒すかはあなたの決めることです。大事なのは、それが決定になっていることです。\n8つのテストを動かす mkdir stepup \u0026amp;\u0026amp; cd stepup # 実験の stepup.mjs と stepup.test.mjs をここに置く node --test stepup.test.mjs 8つのテストが対象にしているのは次のとおりです。RFC の例のパース、プロンプトなしの低リスクの呼び出し、チャレンジから1回のプロンプトと再試行の成功、並列3件でのプロンプト1回、mfa を出せない認可サーバー(1回のプロンプトのあと gaveUp)、max_age の失効、下記のキャッシュの件、フェイルクローズのルートです。共有の Promise が要る理由を見るには、stepUp ??= を stepUp = に変えて再実行してください。並列のテストが、プロンプト1回ではなく3回と数えます。\nステップアップが壊れる場面 チャレンジを insufficient_scope と同じに扱うこと。 症状: 403 を探しているため、ユーザーに追加の同意を求めたり、再試行しなかったりします。対処: 401 の WWW-Authenticate の error で分岐し、スコープの経路は別にします。 並列のプロンプト。 症状: ユーザーが何度も続けて認証を求められ、新しいトークンの一部が捨てられます。対処: ステップアップを単一フライトにして、トークンは送信時に読みます。 終わらないループ。 症状: 成功するたびにログイン画面が再び出ます。原因は、トークンのレベルがまだ低いことで、認可サーバーがセッションの現在のレベルを返すと起きます。対処: リクエストごとにステップアップの回数を制限し、失敗を呼び出し元に伝え、サーバーには unmet_authentication_requirements を返させます。 ユーザー単位のキャッシュ。 症状: ステップアップに成功しても、画面が縮小表示のままです。僕のテストでは、ユーザーをキーにしたレスポンスのキャッシュは、ユーザーが mfa のトークンを持った後も basic を返し、トークンをキーにすると full を返しました。対処: 認可に依存するレスポンスをキャッシュするものには、トークン(またはそのレベル)をキーに含めます。 どこへでもチャレンジに従うこと。 RFC は、悪意あるリソースサーバーが、ユーザーとのやりとりを起動する機能を悪用しうると指摘しています。対処: クライアントは、設定済みの認可サーバーに対してだけ、すでに呼び出しているリソースサーバーについてだけステップアップします。チャレンジから取り出した URL へは行きません。 acr_values から情報が漏れること。 RFC は、値によって、どのユーザーやリソースに高い保証レベルが必要かが分かってしまうと警告しています。対処: 中立的な値を選び、トークンを検証したあとにだけチャレンジを返すことも検討します。 ステップアップが合う場面と、合わない場面 必要な保証レベルがリクエストによって変わるときに使います。支払い、認証情報の変更、データの書き出しなどです。通常の呼び出しは軽いまま、リスクの高い操作だけが追加の認証を求めます。\nすべての呼び出しで同じレベルが必要なら、サインイン時にそのレベルを求め、このプロトコルは使いません。リソースサーバーがユーザーの認証方法を見られない場合(イントロスペクションのない不透明なトークンや、それを記録しない認可サーバー)は、チャレンジの判断材料がありません。RFC 自身も、ステップアップの体験はリソースサーバーと認可サーバーが合意するポリシーに左右され、ユーザーには満たせない要件も作れてしまうと述べています(8節)。\n動かしたことと、読んだだけのこと 確認したことです。実験を Node.js 22.23.3 で動かしました(node --test、8つのテスト成功)。RFC の例の形に対するパーサー、並列3件でのプロンプト1回(共有の Promise を外すと3回になること)、ループ防止、時計を差し込んだ max_age の失効、キャッシュキーの影響、フェイルクローズのルートです。RFC の本文では、401 の例、acr_values と max_age の定義、5節の動作、2節の不透明なトークンの注記、9節の考慮事項を読みました。\n確認していないことは、本物の認可サーバー上での動作すべてです。試作のサーバーはプロセス内で動き、ネットワーク、本物のブラウザの往復、リフレッシュトークン、JWT の検証はありません。実在の製品が acr の値をどう名付け、max_age をどう扱い、unmet_authentication_requirements をどう返すかは調べていません。スマートフォンでも動かしていません。システムブラウザの手順は、関数呼び出しで表しただけです。\nRFC から持ち帰るもの RFC は、「足りない、もう一度ログインして」を伝える標準的な方法を与えてくれます。その周りで必要になる仕事は標準にありません。プロンプトを1つにまとめ、再試行を数え、キャッシュのキーをトークンにすることです。この3つを正常系に足せば、401 は罠でなくなります。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/step-up-authentication-challenge-is-a-401/","summary":"RFC 9470 は、API がクライアントに「このトークンは、弱すぎる、または古すぎるログインで取得されたものだ」と伝える方法を定めています。RFC を節ごとに読み、試作のサーバーとクライアントを作ると、仕様が決めているもの(401 のチャレンジと2つのパラメーター)と、実装側に残されているもの(同時に届くチャレンジ、ループ、キャッシュ)が分かります。","title":"ステップアップ認証のチャレンジは 401 で届き、クライアントにはループ防止が要る"},{"content":"TL;DR 両プラットフォームのドキュメントは、バックグラウンドのアプリをOSがネットワークから切り離すことを許しています。Appleのアーカイブのネットワークガイドには、アプリは「一時停止され、ネットワークのトラフィックを処理できなくなることがある」「既存の接続が閉じられることさえある」とあります。AndroidのDozeのページには、端末が電源に接続されず、動かされず、画面も消えたまま一定時間たつとDozeに入り、Dozeは「ネットワークアクセスを停止」して、ジョブ、同期、標準のアラームを遅らせる、とあります。 一時停止や遅延の間はアプリのコードが動かないので、離れている間に接続の喪失に気づくことはできません。確実に分かるのは、アプリがフォアグラウンドに戻った瞬間です。 復帰後に readyState が1(OPEN)でも、それは自分が最後に処理したイベントの時点で信じていたことにすぎません。Linuxでの代役実験(クライアントプロセスの凍結)では、サーバーの send() は成功し続け、bufferedAmount は0のままで、サーバー側には3秒、僕が決めたハートビートの期限が切れるまで異常が何も見えませんでした。 事実から導いたルーチンはこうです。「アクティブになった」たびに、期限つきのアプリ層のpingを送る。応答がなければ、閉じて、再接続し、取りこぼしを取り戻す。これを4つのケースで動かし、すべて意図どおりに動きました。 iPhoneもAndroid端末も試していません。実験はすべて代役で、どこに影響するかは本文で書きます。 プラットフォームが「してよい」と言っていること 次のページは、書く際に元のサイトで読みました。\nプラットフォーム ドキュメントの記述 出典 iOS アプリは「バックグラウンドに入ると一時停止されることがあり、その場合はネットワークのトラフィックを処理できなくなる。場合によっては、アプリの一時停止中に既存の接続が閉じられることさえある」 Apple, Platform-Specific Networking Technologies(アーカイブ) Android, Doze 端末が電源に接続されず、動かされず、画面が消えたまま一定時間たつと、システムはDozeに入ります。Dozeの間、システムは「ネットワークアクセスを停止」し、ウェイクロックを無視し、標準のアラームをメンテナンスウィンドウまで遅らせる。 Android, Doze and App Standby Android, App Standby 「最近のユーザー操作がないアプリのバックグラウンドのネットワーク活動を遅らせる」 同じページ Android, 推奨 「メッセージを受け取るためにネットワークへの常時接続が必要なら、可能であればFirebase Cloud Messaging(FCM)を使う」。高優先度のメッセージは、通知になるものだけに使う。 同じページ この表には注意が2つあります。Appleのページは公式のドキュメントアーカイブのもので、古いものです。同じことを1文で書いている現行のページは見つけられなかったので、最新の言い回しではなく、原則として読んでください。また、どちらのページも、切り離されるまでの猶予が何秒か、いつ起きるかという数字は書いていません。システムが「しうること」が書かれているだけです。そこが肝心で、ある長さの時間を前提にした設計は、プラットフォームが約束していないことを前提にした設計です。\nReact Nativeでは、AppState がJavaScriptのアプリにこの遷移を知らせる窓口です。状態は active、background、iOSでは inactive で、change イベントがあります(React Nativeのドキュメント)。起きていた間の遷移は教えてくれますが、アプリが動いていない間にソケットに何が起きたかは教えてくれません。\nドキュメントを真面目に受け取ると導かれる4つのルール タイマーではなく、遷移の時点で決める。 一時停止中や、Dozeでアラームが遅らされている間は、タイマーが期待どおりには発火しません。アプリ内のハートビートは、接続が死ぬことに気づけません。サーバーのハートビートはアプリの沈黙に気づけますが、動けるのはサーバー側だけです。クライアントで確実なのは、フォアグラウンドになるイベントの瞬間です。 復帰後に OPEN と言うソケットには、証明させる。 相手のアプリケーションが答えるメッセージを送り、期限を付けます。ブラウザ流のWebSocket APIにはpingフレームがないので、アプリ層のメッセージにします。(僕が読んだReact Nativeのソース(0.87.1)では、WebSocketに ping() メソッドはありますが、pong を受け取るイベントは見つからず、そちらでも応答を待つことはできません。) 証明できなければ、作り直して取り戻す。 閉じて、再接続し、最後に見たものより後のすべてをサーバーに聞きます。再接続だけでは、離れていた間の出来事が黙って失われます。 サーバーはクライアントを見限れなければならない。 凍結したクライアントは、閉じたクライアントではありません。サーバーにハートビートの期限がなければ、誰もいない接続の状態を持ち続けます。 このリストにないものにも注意してください。バックグラウンドで生き残るための小技です。通話、音声、位置情報、ダウンロードのようにプラットフォームが認める用途には、専用の仕組みと審査ルールがあります。僕はどれも試していません。「何かが起きたとユーザーに知らせる」用途について、AndroidのページはFCMを案内しています。読んだのはAndroid側の助言だけで、Appleの現行のプッシュ通知のドキュメントは読んでいません。\nサーバーから見た、凍結したクライアント 「OSがアプリを止めた」状態のLinuxでの代役は、クライアントプロセスへの SIGSTOP です。iOSやAndroidがやることそのものではありません。ここではカーネルとTCP接続は生きていて、止まるのはコードだけです。再現しているのは1点で、「アプリは何にも応答できないのに、相手側は進み続ける」ことです。実験では、ws のサーバーが500msごとにメッセージとpingを送り、3000ms pongがなければそのクライアントはいなくなったと数えます。クライアントのプロセスは接続し、2.0秒で SIGSTOP、9.0秒で SIGCONT を受けます。\n// What does a server see when the client process is frozen (SIGSTOP) but its kernel and network are fine? // Linux stand-in for \u0026#34;the OS suspended the app\u0026#34;. Run: node freeze_lab.mjs import { WebSocketServer } from \u0026#34;ws\u0026#34;; import { spawn } from \u0026#34;node:child_process\u0026#34;; const t0 = Date.now(); const at = () =\u0026gt; `[${((Date.now() - t0) / 1000).toFixed(1).padStart(4)}s]`; const wss = new WebSocketServer({ port: 0 }); await new Promise((r) =\u0026gt; wss.on(\u0026#34;listening\u0026#34;, r)); const port = wss.address().port; const client = spawn(process.execPath, [\u0026#34;-e\u0026#34;, ` const WebSocket = require(\u0026#34;ws\u0026#34;); let n = 0; const ws = new WebSocket(\u0026#34;ws://127.0.0.1:${port}\u0026#34;); ws.on(\u0026#34;message\u0026#34;, () =\u0026gt; n++); ws.on(\u0026#34;close\u0026#34;, (c) =\u0026gt; { console.log(\u0026#34;client: close event, code \u0026#34; + c + \u0026#34;, messages seen: \u0026#34; + n); process.exit(0); }); ws.on(\u0026#34;error\u0026#34;, (e) =\u0026gt; console.log(\u0026#34;client: error \u0026#34; + e.code)); `], { stdio: \u0026#34;inherit\u0026#34;, cwd: process.cwd() }); wss.on(\u0026#34;connection\u0026#34;, (ws) =\u0026gt; { let seq = 0, lastPong = Date.now(), sent = 0, acked = 0; ws.on(\u0026#34;pong\u0026#34;, () =\u0026gt; { lastPong = Date.now(); }); const tick = setInterval(() =\u0026gt; { ws.send(`m${++seq}`, () =\u0026gt; acked++); // callback = handed to the kernel sent++; ws.ping(); const quiet = Date.now() - lastPong; if (seq % 2 === 0) console.log(`${at()} server: sent=${sent} handed-to-kernel=${acked} bufferedAmount=${ws.bufferedAmount} quiet-for=${quiet} ms readyState=${ws.readyState}`); if (quiet \u0026gt; 3000) { console.log(`${at()} server: no pong for ${quiet} ms -\u0026gt; declare the client gone, terminate()`); clearInterval(tick); ws.terminate(); } }, 500); ws.on(\u0026#34;close\u0026#34;, () =\u0026gt; console.log(`${at()} server: close event`)); }); setTimeout(() =\u0026gt; { console.log(`${at()} \u0026gt;\u0026gt;\u0026gt; SIGSTOP the client process`); client.kill(\u0026#34;SIGSTOP\u0026#34;); }, 2000); setTimeout(() =\u0026gt; { console.log(`${at()} \u0026gt;\u0026gt;\u0026gt; SIGCONT the client process`); client.kill(\u0026#34;SIGCONT\u0026#34;); }, 9000); setTimeout(() =\u0026gt; process.exit(0), 11000); [ 1.2s] server: sent=2 handed-to-kernel=1 bufferedAmount=0 quiet-for=497 ms readyState=1 [ 2.0s] \u0026gt;\u0026gt;\u0026gt; SIGSTOP the client process [ 2.2s] server: sent=4 handed-to-kernel=3 bufferedAmount=0 quiet-for=501 ms readyState=1 [ 3.2s] server: sent=6 handed-to-kernel=5 bufferedAmount=0 quiet-for=1503 ms readyState=1 [ 4.2s] server: sent=8 handed-to-kernel=7 bufferedAmount=0 quiet-for=2504 ms readyState=1 [ 4.7s] server: no pong for 3004 ms -\u0026gt; declare the client gone, terminate() [ 4.7s] server: close event [ 9.0s] \u0026gt;\u0026gt;\u0026gt; SIGCONT the client process client: close event, code 1006, messages seen: 9 (Node 20.19.2、ws 8.22.0、Linux 6.12、ループバック。)この結果から分かることです。\nクライアントが凍結している間、サーバーの send() は成功し続け、bufferedAmount は0のままでした。送信側から見て異常はありません。異常を示すのは、pongが来ないことだけで、それも僕が決めた期限(3秒)と比べて初めて分かります。 凍結したクライアントには何の通知もありません。SIGCONT のあと、カーネルが保持していた9件のメッセージを処理し、その後で接続が閉じられたことを、コード1006として知りました。それまでは、見に行ったコードにはソケットは OPEN に見えていたはずです。 両端が同じ出来事を、違う時刻に見ます。実際の端末でその差がどれくらいかは測っていませんし、ドキュメントにも書かれていません。この差を前提に設計する必要があります。 ルーチン: 確認する、作り直す、取り戻す 実装は、ライフサイクルの供給元、接続関数、取り戻す関数を引数にしています。React Nativeに依存しないので、偽の AppState を使ってNode上でテストできました。\n// On \u0026#34;active\u0026#34;: do not trust readyState. Prove the socket is alive, otherwise rebuild it and catch up. export function keepFresh({ lifecycle, connect, catchUp, probeMs = 2000, log = () =\u0026gt; {} }) { let socket = null, busy = null; const probe = (ws) =\u0026gt; new Promise((resolve) =\u0026gt; { // a round trip that the peer *application* must answer if (!ws || ws.readyState !== 1) return resolve(false); const done = (ok) =\u0026gt; { clearTimeout(timer); ws.removeEventListener(\u0026#34;message\u0026#34;, onMessage); resolve(ok); }; const onMessage = (e) =\u0026gt; { if (e.data === \u0026#34;pong\u0026#34;) done(true); }; const timer = setTimeout(() =\u0026gt; done(false), probeMs); ws.addEventListener(\u0026#34;message\u0026#34;, onMessage); try { ws.send(\u0026#34;ping\u0026#34;); } catch { done(false); } }); async function ensure(reason) { if (busy) return busy; // \u0026#34;active\u0026#34; can fire repeatedly return (busy = (async () =\u0026gt; { if (await probe(socket)) { log(`${reason}: socket proved alive, nothing to do`); return; } log(`${reason}: socket is ${socket ? \u0026#34;readyState \u0026#34; + socket.readyState : \u0026#34;missing\u0026#34;} or silent -\u0026gt; rebuild`); try { socket?.close(); } catch {} socket = await connect(); await catchUp(); // whatever was missed while we were away })().finally(() =\u0026gt; { busy = null; })); } lifecycle.addEventListener(\u0026#34;change\u0026#34;, (state) =\u0026gt; { if (state === \u0026#34;active\u0026#34;) ensure(\u0026#34;foreground\u0026#34;); }); return { start: () =\u0026gt; ensure(\u0026#34;start\u0026#34;), get socket() { return socket; } }; } 重要な点が2つあります。busy は、同時に来た起動を1回の実行で共有させます。active は繰り返し発火することがあり、ガードがなければそのたびに独立したプローブと再構築が走ります。もう1つは、プローブが OPEN でないものすべて(閉じたソケットも)に対して false を返すことで、「死んでいる」と「黙っている」を同じ関数で扱えます。\n4つのケースを、ループバック上の本物の ws サーバーに対して動かします。テストを短くするため、プローブの期限は500msです。\nimport { WebSocketServer, WebSocket } from \u0026#34;ws\u0026#34;; import { EventEmitter } from \u0026#34;node:events\u0026#34;; import { keepFresh } from \u0026#34;./keep_fresh.mjs\u0026#34;; const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); const wss = new WebSocketServer({ port: 0 }); await new Promise((r) =\u0026gt; wss.on(\u0026#34;listening\u0026#34;, r)); const serverSide = []; wss.on(\u0026#34;connection\u0026#34;, (ws) =\u0026gt; { serverSide.push(ws); ws.on(\u0026#34;message\u0026#34;, (m) =\u0026gt; { if (m.toString() === \u0026#34;ping\u0026#34;) ws.send(\u0026#34;pong\u0026#34;); }); }); const url = `ws://127.0.0.1:${wss.address().port}`; class FakeAppState extends EventEmitter { addEventListener(t, f) { this.on(t, f); } } // same shape as React Native\u0026#39;s AppState.addEventListener const lifecycle = new FakeAppState(); let catchUps = 0, connects = 0; const t0 = Date.now(); const log = (m) =\u0026gt; console.log(`[${String(Date.now() - t0).padStart(5)} ms] ${m}`); const app = keepFresh({ lifecycle, log, probeMs: 500, connect: async () =\u0026gt; { connects++; const c = new WebSocket(url); await new Promise((r) =\u0026gt; c.on(\u0026#34;open\u0026#34;, r)); c.on(\u0026#34;error\u0026#34;, () =\u0026gt; {}); return c; }, catchUp: async () =\u0026gt; { catchUps++; }, }); await app.start(); await sleep(100); log(`connects=${connects} catchUps=${catchUps}`); log(\u0026#34;--- case 1: app goes background and comes back; connection is fine\u0026#34;); lifecycle.emit(\u0026#34;change\u0026#34;, \u0026#34;background\u0026#34;); await sleep(100); lifecycle.emit(\u0026#34;change\u0026#34;, \u0026#34;active\u0026#34;); await sleep(300); log(`connects=${connects} catchUps=${catchUps}`); log(\u0026#34;--- case 2: the connection silently died while we were away (server stops reading, so no pong)\u0026#34;); serverSide.at(-1)._socket.pause(); log(`client still thinks: readyState=${app.socket.readyState} (1 = OPEN)`); lifecycle.emit(\u0026#34;change\u0026#34;, \u0026#34;active\u0026#34;); await sleep(900); log(`connects=${connects} catchUps=${catchUps}`); log(\u0026#34;--- case 3: the server closed it while we were away\u0026#34;); serverSide.at(-1).terminate(); await sleep(100); lifecycle.emit(\u0026#34;change\u0026#34;, \u0026#34;active\u0026#34;); await sleep(300); log(`connects=${connects} catchUps=${catchUps}`); log(\u0026#34;--- case 4: three \u0026#39;active\u0026#39; events in a row\u0026#34;); lifecycle.emit(\u0026#34;change\u0026#34;, \u0026#34;active\u0026#34;); lifecycle.emit(\u0026#34;change\u0026#34;, \u0026#34;active\u0026#34;); lifecycle.emit(\u0026#34;change\u0026#34;, \u0026#34;active\u0026#34;); await sleep(300); log(`connects=${connects} catchUps=${catchUps}`); process.exit(0); [ 0 ms] start: socket is missing or silent -\u0026gt; rebuild [ 113 ms] connects=1 catchUps=1 [ 113 ms] --- case 1: app goes background and comes back; connection is fine [ 217 ms] foreground: socket proved alive, nothing to do [ 516 ms] connects=1 catchUps=1 [ 516 ms] --- case 2: the connection silently died while we were away (server stops reading, so no pong) [ 517 ms] client still thinks: readyState=1 (1 = OPEN) [ 1017 ms] foreground: socket is readyState 1 or silent -\u0026gt; rebuild [ 1418 ms] connects=2 catchUps=2 [ 1419 ms] --- case 3: the server closed it while we were away [ 1520 ms] foreground: socket is readyState 3 or silent -\u0026gt; rebuild [ 1821 ms] connects=3 catchUps=3 [ 1822 ms] --- case 4: three \u0026#39;active\u0026#39; events in a row [ 1822 ms] foreground: socket proved alive, nothing to do [ 2122 ms] connects=3 catchUps=3 面白いのはケース2です。クライアントは readyState=1 と言い続けますが、プローブが500msで期限切れになり、ルーチンは再構築して取り戻し処理を走らせました。readyState を見るだけの確認は、これをすり抜けます。ケース4では、3回の active が1回のプローブになり、余計な再接続は起きませんでした。\nテストが「黙ったサーバー」を作る方法は、ソケットの読み取りを止めることで、これは実験用の細工です。実際の無音の死は、ネットワークの切り替え、NATのタイムアウト、プロセスの一時停止などで、どれも再現していません。\nサーバーでやること サーバーのハートビートは別の仕事で、リソースを解放し、在席を判定することです。ws のようなライブラリはpingフレームと terminate() を提供します(上で使ったとおりです)。凍結実験のサーバーは、「3秒pongがなければ」という規則で凍結クライアントを刈り取りました。期限はトレードオフです。短ければ状態を早く解放できますが、通信が悪いだけのクライアントも追い出します。長ければ、死んだクライアントの状態を持ち続けます。誤って追い出したときの痛みの大きさから決め、実際にどれくらい起きるかを測ってください。\n「ユーザーに知らせる」用途は、ソケットではなくプッシュを使います。サーバーがプッシュを送り、アプリがソケットを開いて取り戻す。Androidのドキュメントはこれを勧めていて、FCMの高優先度メッセージを使えば、Dozeの間でもアプリに一時的なネットワークアクセスが与えられる、とあります。Apple側は同等の記述を読んでいないので、iOSについては何も言いません。どちらにしても、上のフォアグラウンドのルーチンが結局必要になることは変わりません。プッシュは合図であって、データではありません。\nこのルーチンが向く場面 状況 このルーチンを使うか アプリがフォアグラウンドにいる間だけ意味のあるライブデータ(チャット画面、ダッシュボード、共同編集の文書) 使います。このために作ったものです。 アプリがバックグラウンドにいる間のイベントも知る必要がある 使いません。プラットフォームのプッシュで起こす、または通知し、復帰したらこのルーチンを走らせます。 メディアのストリーミング、通話、ナビゲーション 使いません。それぞれ文書化されたバックグラウンドモードがあります。試していません。 デスクトップのブラウザの普通のWebページ 部分的に。ページの可視性やネットワークのイベントが違い、試していません。 ルーチンが破綻するところ 復帰後に readyState を信じる。 症状: UIは「接続中」なのに、誰かが引っ張って更新するまで何も届かない。上のケース2で再現しました。直し方: 期限つきのプローブです。 期限のないプローブ。 症状: 復帰後にアプリがずっと待つ。ここのプローブは、probeMs のあとに必ず false で終わります。 取り戻さない再構築。 症状: エラーはないのに、離れていた間のメッセージがない。直し方: 最後に見たidや連番に基づく取り戻しの手順です。サーバー側にそのためのAPIが要ります。 イベントごとにプローブ。 症状: 不安定な遷移で再接続が連発する。直し方: busy のガードで、ケース4で確認しました。 プロトコルのpingでプローブする。 症状: socket.ping() を探しても存在しない。ブラウザにはpingメソッドがなく、React Nativeの ping()(読んだ0.87.1のソース)も対になる pong のイベントがないので、プローブはサーバーが答えるアプリ層のメッセージにします。 全員が同時に再接続する。 サーバーの再起動やネットワークの事象で多数のクライアントが一斉に切れると、全員が同じ瞬間に再試行します。再接続の待ち時間にランダムなジッターを足します。大規模では試していません。 見限らないサーバー。 症状: とっくにいないクライアントのためにメモリを持ち続ける。直し方: ハートビートの期限で、凍結実験のとおりです。 試してみる mkdir lab \u0026amp;\u0026amp; cd lab \u0026amp;\u0026amp; npm init -y \u0026amp;\u0026amp; npm i ws@8 を実行し、3つのスクリプトを保存します。 node freeze_lab.mjs を実行します。成功なら、SIGSTOP のあとも bufferedAmount=0 の行が続き、約3秒後に no pong の行が出て、クライアントが1006を表示するのは SIGCONT のあとです。 node test.mjs を実行します。成功なら、最後が connects=3 catchUps=3 で、ケース4の「proved alive」が1行だけです。 test.mjs の probeMs を50に変え、遅いマシンでケース1がどうなるかを見ます。自分のネットワークのラウンドトリップを測ってから、適切な値を決めてください。 自分のアプリでは、偽の AppState を react-native の本物に、connect を自分のソケットの生成関数に置き換えます。その組み合わせは、僕は動かしていません。 実機では何も確認していないこと 確認できたこと: 3つのスクリプトを1台のマシン(Linux 6.12、Node 20.19.2、ws 8.22.0)で動かし、示した出力を得ました。AppleとAndroidのページからの引用と、AppState の状態名は、書く際に元のページで読みました。\n確認できていないこと: iPhoneやAndroidの実機、エミュレーター、React Nativeのランタイム。アプリが一時停止やDozeに入るまでの実際の猶予、その時点でOSがTCP接続をどうするか、特定のバージョンと端末でソケットが開いたままか閉じられるか、バックグラウンドモード(VoIP、音声、位置情報)、プッシュの配信と遅延、実機での AppState イベントの順序、本物の active の連発。SIGSTOP の代役はコードを凍結してもネットワークは生かしたままで、プラットフォームがやることと同じとは限りません。サーバーの3秒とプローブの500msは、実験用の値です。\n離れていたあとのアプリは アプリがしばらく離れていたあとの開いたソケットは、主張です。期限つきで質問し、答えなければ新しく作り、サーバーに取りこぼしを聞きます。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/mobile-os-background-sockets/","summary":"AppleとAndroidの公式ドキュメントには、バックグラウンドのアプリは一時停止され、ネットワークが止められ、既存の接続が閉じられうると書かれています。つまり、フォアグラウンドに戻ったときの readyState === OPEN は、最後に処理したイベントの記憶にすぎません。この記事では、その記述から「確認する、作り直す、取りこぼしを取り戻す」という小さなルーチンを導き、Linux上の凍結プロセスという代役で動かします。実機では試していない点も、はっきり書きます。","title":"バックグラウンドから戻ったWebSocketのOPENは、確かめるまで主張にすぎない"},{"content":"WebSocket サーバーでよく見る規則に、「Origin ヘッダーが自分たちのものでなければ upgrade を拒否する」があります。良い規則ですが、ここから正反対の勘違いが2つ生まれます。「これで自分たちのアプリしか接続できない」と考えるものと、コマンドラインのツールやネイティブアプリは Origin を送らないので、サーバーは拒否すべきだと考えるものです。\nどちらも、このヘッダーが何のためにあるかの取り違えから来ています。小さなサーバーとブラウザからの攻撃を作り、主張を1つずつ試しました。\nTL;DR Origin は、どのページが接続を開いたかについてブラウザが述べる内容です。ページのスクリプトは変更できませんが、ブラウザ以外のクライアントは好きな値を送れます。RFC 6455 も、ブラウザ以外のクライアントは「偽の Origin ヘッダーを送れる」と書いています。 許可リストが防ぐのは1つの攻撃です。悪意あるページが、被害者のブラウザに自分のサーバーへの WebSocket を開かせ、ブラウザが被害者の Cookie を付けてしまうものです。実験では、検証がなければこの攻撃は成功し、完全一致の検証があれば失敗しました。 同じ検証は、ブラウザ以外のクライアントについては何も教えてくれません。許可された Origin を名乗り Cookie を持たない Node クライアントは 401 でした。盗んだ Cookie を持たせると入れました。認証したのは Cookie で、ヘッダーは何もしていません。 したがって規則は2段になります。Origin があれば許可リストと完全一致させます。値にかかわらず、接続にはブラウザが勝手に付けない資格情報も必要です。 includes や startsWith で比較すると、https://app.example.com.evil.test が通ります。シリアライズされた origin を集合で引いてください。 RFC 6455 は Origin を何のためと言っているか ブラウザは WebSocket を開くとき、ページの origin(スキーム、ホスト、ポート)を Origin ヘッダーに入れます。ページのスクリプトは上書きできません。MDN は Origin を禁止リクエストヘッダーに挙げています。RFC 6455 の4.1節は、ブラウザのクライアントにはこのヘッダーを必須(MUST)とし、それ以外のクライアントには、用途が合う場合に限って送ってよい(MAY)としています。\n目的は10.2節に書かれています。ブラウザ内で動くスクリプトからの利用を制限したいサーバーは Origin を確認し、受け入れられないなら 403 を返すべきだというものです。狙いは「ブラウザ以外が接続するのを防ぐことではなく」、悪意ある JavaScript の支配下にある信頼されたブラウザが、ハンドシェイクを偽造できないようにすることだと明記されています。確認しないサーバーは「どこからの接続も受け入れる」ことになります(4.2.2節)。\n契約はこれがすべてです。このヘッダーは、ブラウザがどのウェブサイトのために動いているかをサーバーが知る手段にすぎず、それ以外は何も保証しません。\n誤解1:「許可リストがあれば、自分たちのアプリだけが接続できる」 完全一致の許可リストと Cookie セッションだけを確認するサーバーを起動し、許可された origin を名乗る Node クライアントで接続しました。\nNode クライアント、許可された Origin を偽装、Cookie なし =\u0026gt; HTTP 401 Node クライアント、許可された Origin を偽装、Cookie あり =\u0026gt; OPEN {\u0026#34;hello\u0026#34;:\u0026#34;alice\u0026#34;, ...} Cookie がなければ偽装した Origin は何の役にも立たず、Cookie があれば不要でした。サーバーが認証したのは Cookie です。ヘッダーは必要条件でも十分条件でもありません。このヘッダーを信用できるのは、それを強制するブラウザの場合だけです。\n誤解2:「Cookie と SameSite を使っているから大丈夫」 許可リストはこの攻撃のためにあるので、実際に動かしました。構成は次のとおりです。\nサーバー(ポート4100)は Cookie セッションを受け付け、Origin を見ません。 headless Chrome(バージョン154、puppeteer-core で操作)で、被害者が http://localhost:4100/login を開き、sid=...; SameSite=Lax を受け取ります。 同じブラウザで、別のサーバー(ポート4200)のページを開きます。そのページが new WebSocket('ws://localhost:4100/ws') を実行します。 MODE=none :4200 のページ、被害者の Cookie =\u0026gt; OPEN {\u0026#34;hello\u0026#34;:\u0026#34;alice\u0026#34;,\u0026#34;origin\u0026#34;:\u0026#34;http://localhost:4200\u0026#34;} MODE=allowlist :4200 のページ、被害者の Cookie =\u0026gt; CLOSED code=1006 (サーバーは Origin=http://localhost:4200 を見て 403) 悪意あるページは被害者のデータを受け取りました。サーバーのログには Origin: http://localhost:4200 が残っており、拒否できたはずです。OWASP の WebSocket チートシートが cross-site WebSocket hijacking と呼ぶ攻撃です。\nなぜ SameSite=Lax が効かなかったのでしょうか。localhost のポート4200とポート4100は、cross-origin ですが same-site だからです。site の判定ではポートが無視されます。違いはweb.dev の解説にあります。同じ登録可能ドメインの兄弟サブドメイン同士でも同じことが起きます。僕が試したのは localhost のポートだけです。サブドメインの場合は定義から導かれることで、実行はしていません。SameSite だけを頼りにすると、同じ site にあってスクリプトを動かせる別のページ(ユーザーのコンテンツを置くサブドメインや、ステージングの複製)が、被害者の Cookie で接続を開けます。\n誤解3:「origin を比較しているから検証できている」 origin は文字列として比較されるので、比較の書き方が結果を左右します。もっともらしい実装を3つ、悪意ある4つの値に対して試しました。\nconst ALLOWED = [\u0026#39;https://app.example.com\u0026#39;]; includes: (o) =\u0026gt; ALLOWED.some((a) =\u0026gt; o.includes(new URL(a).host)), startsWith: (o) =\u0026gt; ALLOWED.some((a) =\u0026gt; o.startsWith(a)), exactSet: (o) =\u0026gt; new Set(ALLOWED).has(o), includes は https://app.example.com.evil.test、https://evil.test/?https://app.example.com、https://notapp.example.com を通しました。startsWith は最初の1つを通しました。集合による完全一致は、文字列の null を含む4つすべてを拒否しました。ブラウザが送る origin はパスも末尾のスラッシュもない形でシリアライズされているので、完全一致は正確で、書くのも簡単です。\n誤解4:「Origin がなければ拒否」(または「なければ問題ない」) Origin のないリクエストは、ブラウザの規則に従っていないものから来ています。デスクトップアプリ、スクリプト、サーバー、テストツールなどです。これらをすべて拒否すると、自分たちのブラウザ以外のクライアントも動かなくなります。資格情報なしで受け入れれば、検証には意味がなくなります。正しい読み方は、Origin がないとブラウザの助けがなくなるので、リクエスト自身が証明を持つ必要がある、というものです。\nその証明は、ブラウザが自分では送らないものでなければなりません。Cookie はその条件を満たしません。上の攻撃はまさに Cookie に乗っています。Authorization ヘッダーは、ブラウザの WebSocket API からは設定できません。ページのスクリプトが渡せるのはサブプロトコルのリストで、僕のサーバーはそこでベアラートークンを受け取ります(OWASP のシートは、クエリ文字列や最初のメッセージで渡す方法も挙げています。クエリ文字列はアクセスログに残ります)。\n判定をコードにしたものが次です。実験用のサーバーが実行している関数そのものです。\nexport function gate({ origin, bearer, allowed, tokens }) { if (origin !== undefined \u0026amp;\u0026amp; !allowed.has(origin)) return { ok: false, status: 403, why: \u0026#39;origin-not-allowed\u0026#39; }; const user = tokens[bearer]; if (!user) return { ok: false, status: 401, why: \u0026#39;no-valid-bearer\u0026#39; }; return { ok: true, user, kind: origin === undefined ? \u0026#39;no-origin\u0026#39; : \u0026#39;browser\u0026#39; }; } ヘッダーがないとき origin は undefined です。sandbox 付きの iframe は文字列の null を送りますが、これは集合にないので 403 になります。\n同じサーバーを gate モードで動かした結果です。\nクライアント 結果 Node、トークンあり、Origin なし 接続できました Node、トークンあり、許可された Origin を偽装 接続できました Node、トークンあり、許可されていない Origin 403 Node、トークンなし 401 Node、誤ったトークン、許可された Origin 401 ポート4200のページ(許可外) 閉じました。サーバーは origin を見て 403 を返しました sandbox 付き iframe 閉じました。サーバーは Origin: null を見て 403 を返しました この表で見直す価値があるのは2行です。「トークンあり、許可された Origin を偽装: 接続できました」は問題ありません。有効なトークンを持つクライアントは接続してよく、ヘッダーを偽装しても得るものはありません。「トークンあり、許可されていない Origin: 403」は、規則のうちブラウザ向けの部分です。信用できないページが、何らかの形でトークンを手に入れたブラウザ上で動いていても、拒否されます。\nヘッダーに任せることと、任せないこと ブラウザから届きうる WebSocket のエンドポイントには、完全一致の Origin 許可リストを置きます。特にセッションが Cookie の場合です。安く済み、サイト間の攻撃を塞げます。\nこれを唯一の認証にしないでください。ネイティブクライアントとスクリプトを見分ける手段にもなりません。サーバーは、正直に名乗るクライアントと名乗らないクライアントを区別できないからです。特定のアプリのビルドからの接続であることを確かめたいなら、別の問題です(アプリの構成証明で、ここでは調べていません)。その答えもヘッダーではなく資格情報です。\nブラウザ以外の、明示的なトークンを持つクライアントしか届かないエンドポイントでは、この検査があっても害はなく、何も変わりません。そこに Origin ヘッダー付きのリクエストが来たなら、それ自体が記録しておく価値のある兆候です。\nOrigin 検証がそれでも漏れる場所 間違い 起きること 対処 部分一致や前方一致での比較 紛らわしいドメインが接続し、データを受け取ります。 シリアライズされた origin 全体を集合と比べ、上の4つのような悪意ある値をテストに入れます。 *.example.com のようなワイルドカード 忘れていたサブドメインや、乗っ取られうるサブドメインが、Cookie 認証付きの接続を開けます。 origin を1つずつ列挙します。パターンが必要なら、ユーザーのコンテンツを配信できるサブドメインはすべて悪意ある origin として扱います。 null を許可する sandbox 付き iframe や、data: URL から作った文書が接続できます。MDN は、この2つを Origin が null になる場合に挙げています。 null をリストに入れません。 ベアラートークンをクエリ文字列に入れる アクセスログやプロキシのログにトークンが残ります。OWASP のシートがまさにこの点を警告しています。 サブプロトコルか最初のメッセージで渡し、ログに残るものは伏せます。 開発用の origin を本番のリストに残す http://localhost:3000 が、どのユーザーのマシンからでも受け入れられます。 環境ごとの設定からリストを作り、拒否した origin をすべて記録します。 upgrade のあとで検査する 最初のメッセージで閉じても、サーバーはすでにデータを送っています。 handleUpgrade の前に、HTTP の応答で拒否します。 攻撃を再現する mkdir origin-try \u0026amp;\u0026amp; cd origin-try \u0026amp;\u0026amp; npm init -y \u0026gt;/dev/null \u0026amp;\u0026amp; npm i ws@8.22.0 # 実験の server.mjs を置く(上の gate 関数に、http サーバーと ws.handleUpgrade を足したもの) MODE=gate PORT=4100 node server.mjs \u0026amp; mkdir other \u0026amp;\u0026amp; echo \u0026#39;\u0026lt;script\u0026gt; const ws = new WebSocket(\u0026#34;ws://localhost:4100/ws\u0026#34;); ws.onopen = () =\u0026gt; document.title = \u0026#34;OPEN\u0026#34;; ws.onclose = (e) =\u0026gt; document.title = \u0026#34;CLOSED \u0026#34; + e.code; \u0026lt;/script\u0026gt;\u0026#39; \u0026gt; other/index.html python3 -m http.server 4200 -d other \u0026amp; # ブラウザで http://localhost:4200/ を開く。サーバーのログに、見た Origin と判定が出る MODE=none にして、http://localhost:4100/login で Cookie を受け取ったあとなら、同じページが接続できます。MODE=gate では拒否されます。Node 側は、ws パッケージで new WebSocket(url, ['bearer.tok-bob-native'], { headers: { Origin: 'http://localhost:4100' } }) とすれば、偽装したヘッダーを確かめられます。\n実験で確かめたこと、確かめていないこと この環境で確認したこと(Node.js 22.23.3、ws 8.22.0、headless Chrome 154)です。\n別ポートのページからの Cookie セッションの乗っ取りと、完全一致の許可リストによる拒否。 許可された Origin を偽装した Node クライアント。Cookie なしで 401、Cookie ありで接続成功。 上の表の7つのケースにわたる gate 関数の動作。Origin: null を送った sandbox 付き iframe を含みます。 3つの文字列比較を、悪意ある4つの値に対して試した結果(node --test、すべて成功)。 上で引用した RFC 6455 と MDN の記述。RFC の本文と MDN のページで確認しました。 確認していないことです。\nほかのブラウザ。Chrome だけで試しました。 サブドメイン間の乗っ取り。また、使っているプロキシや CDN が Origin を転送するか書き換えるか。受け取ったヘッダーを記録するリクエストで確かめてください。 本物の証明書を使った wss://。実験では localhost の ws:// を使いました。 ネイティブアプリの構成証明に関する主張。 ws のサーバーは実験用に書いた最小のもので、本番の構成ではありません。中の秘密(sid-alice、tok-bob-native)は作り物です。\n僕ならこう出荷します Origin は、どのウェブサイトがブラウザを通して話しているかを、ブラウザがサーバーに教えるものです。悪意あるウェブサイトへの対策としては適切で、ブラウザ以外の相手については何も言いません。完全一致で確認し、一致しなければ拒否し、すべての接続に、ブラウザが勝手には付けない明示的な資格情報を求めてください。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/websocket-origin-check-is-not-authentication/","summary":"WebSocket のハンドシェイクにある Origin ヘッダーは、ブラウザが付けるもので、それ以外のクライアントは好きな値を送れます。許可リストが守るのは、Cookie 認証のブラウザセッションが他のサイトから使われることで、接続してきた相手が誰かは証明しません。動くサーバー、実ブラウザでの攻撃、そこから導かれるハンドシェイクの規則を示します。","title":"WebSocket の Origin 検証が止めるのは他サイトのページで、他のクライアントではない"},{"content":"TL;DR 多数のリクエストを流している接続では、リクエストの期限切れはそのリクエストだけをキャンセルします(HTTP/2なら RST_STREAM)。実験では、1回のタイムアウトでセッション全体を閉じると、無関係な2本が失敗しました。ストリームだけをキャンセルすると、2本とも同じTCP接続で完了しました。 この方針が成り立つのは、返信とリクエストをidで対応づけられる場合だけです。順番で対応づけていると、遅れて届いた返信が次のリクエストに渡され、前のリクエストの答えがエラーなしで返りました。WebSocketの上に作ったリクエスト/レスポンス層で再現しています。 ストリームより下で止まっているときは、キャンセルでは助かりません。LinuxのネットワークネームスペースでTCP接続1本のサーバー→クライアント方向を100ms止めると、その接続を共有する全ストリームが約220〜320ms沈黙しました(5回ずつ、2回のセッション)。ストリームごとに接続を分けると、被害を受けた側は約130ms、もう片方は通常の10msおきのままでした。 問いは2つあります。「このリクエストは遅すぎるか」(ストリーム単位で答える)と、「この接続はまだ生きているか」(HTTP/2の PING などの接続レベルの確認と、自分で決めた期限で答える)です。接続を閉じるのは、後者の問いに失敗したときだけです。 以下はすべてNodeで試せます。パケットを落とす実験にだけ、root、ip netns、nft が要ります。 似ているが別の2つの問い クライアントがサーバーに1本の接続を張り、その上で多数のリクエストを同時に流します。HTTP/2はそういう作りですし、WebSocketやRPCの多くも慣習として同じことをします。1つのリクエストが遅く、期限が来ました。どうするでしょうか。\n選択肢は2つで、代償は大きく違います。\n接続を閉じる。 実装は簡単で、確実にすべてを解放できます。ただし、その接続で動いていた他のリクエストもすべて死にます。次のリクエストは新しいTCP(とTLS)のハンドシェイクを払います。 そのリクエストだけをキャンセルする。 接続は残り、他のリクエストは続きます。ただし接続が健全だと確信する必要があります。1つのリクエストの期限切れは、他のリクエストについて何も教えてくれないからです。 各分岐を、動かせるもので見ていきます。\n比べる: セッションを閉じるか、ストリームをキャンセルするか HTTP/2には、他に影響せず1つのリクエストだけを終わらせる仕組みがあります。RST_STREAM は「ストリームの即時終了を許す」もので、「ストリームのキャンセルを求めるために送られる」とされています(RFC 9113 §6.4)。送ったあとも、すでに相手が送出していたフレームを受け取る準備は必要です。無視してよいのは、ヘッダー圧縮やフロー制御のように接続の状態を変えるフレームを除いたものです(§5.4.2)。接続は生き残ることが前提の作りです。\n実験ではNodeの標準 http2 モジュールを平文のTCPで使います。サーバーは GET /\u0026lt;ms\u0026gt; に、そのミリ秒だけ待って応答します。クライアントは、3000msかかるリクエストを期限500msで1本、800msかかるリクエストを余裕のある期限で2本、同時に送ります。2つの戦略の違いは1行だけです。\n// One HTTP/2 connection, several requests, one of them is slow. // What should the client close when the slow one hits its deadline: the stream, or the whole session? import http2 from \u0026#34;node:http2\u0026#34;; const { NGHTTP2_CANCEL } = http2.constants; const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); const server = http2.createServer(); // cleartext HTTP/2 is enough for this let sessions = 0; let serverLog = []; server.on(\u0026#34;session\u0026#34;, (s) =\u0026gt; { sessions++; }); server.on(\u0026#34;stream\u0026#34;, (stream, headers) =\u0026gt; { const delay = Number(headers[\u0026#34;:path\u0026#34;].slice(1)); // GET /\u0026lt;milliseconds\u0026gt; stream.on(\u0026#34;close\u0026#34;, () =\u0026gt; { if (stream.rstCode) serverLog.push(`server saw RST_STREAM(code ${stream.rstCode}) for the /${delay} stream`); }); setTimeout(() =\u0026gt; { if (!stream.destroyed) { stream.respond({ \u0026#34;:status\u0026#34;: 200 }); stream.end(`done after ${delay} ms`); } }, delay); }); await new Promise((r) =\u0026gt; server.listen(0, r)); const url = `http://localhost:${server.address().port}`; async function run(strategy) { sessions = 0; serverLog = []; const session = http2.connect(url); session.on(\u0026#34;error\u0026#34;, () =\u0026gt; {}); await new Promise((r) =\u0026gt; session.on(\u0026#34;connect\u0026#34;, r)); const t0 = Date.now(); const request = (ms, deadline) =\u0026gt; new Promise((resolve) =\u0026gt; { const req = session.request({ \u0026#34;:path\u0026#34;: `/${ms}` }); let body = \u0026#34;\u0026#34;; const timer = setTimeout(() =\u0026gt; { if (strategy === \u0026#34;close-stream\u0026#34;) req.close(NGHTTP2_CANCEL); // RST_STREAM(CANCEL) for this stream only else session.destroy(); // tear down the TCP connection resolve(`TIMEOUT after ${Date.now() - t0} ms`); }, deadline); req.on(\u0026#34;data\u0026#34;, (d) =\u0026gt; (body += d)); req.on(\u0026#34;error\u0026#34;, () =\u0026gt; {}); req.on(\u0026#34;close\u0026#34;, () =\u0026gt; { clearTimeout(timer); resolve(body.startsWith(\u0026#34;done\u0026#34;) ? `ok (${body})` : \u0026#34;FAILED: closed without a response\u0026#34;); }); }); // one slow request (3 s) with a 500 ms deadline, two others that need 800 ms and have 2 s deadlines const results = await Promise.all([request(3000, 500), request(800, 2000), request(800, 2000)]); console.log(` strategy \u0026#34;${strategy}\u0026#34;: slow -\u0026gt; ${results[0]} | other #1 -\u0026gt; ${results[1]} | other #2 -\u0026gt; ${results[2]} | TCP connections opened: ${sessions}`); await sleep(300); console.log(` ${serverLog.length ? serverLog.sort().join(\u0026#34;; \u0026#34;) : \u0026#34;server saw no RST_STREAM\u0026#34;}`); session.destroy(); await sleep(100); } console.log(\u0026#34;timeout handling on a shared HTTP/2 connection\u0026#34;); await run(\u0026#34;close-session\u0026#34;); await run(\u0026#34;close-stream\u0026#34;); server.close(); timeout handling on a shared HTTP/2 connection strategy \u0026#34;close-session\u0026#34;: slow -\u0026gt; TIMEOUT after 505 ms | other #1 -\u0026gt; FAILED: closed without a response | other #2 -\u0026gt; FAILED: closed without a response | TCP connections opened: 1 server saw RST_STREAM(code 8) for the /800 stream strategy \u0026#34;close-stream\u0026#34;: slow -\u0026gt; TIMEOUT after 501 ms | other #1 -\u0026gt; ok (done after 800 ms) | other #2 -\u0026gt; ok (done after 800 ms) | TCP connections opened: 1 server saw RST_STREAM(code 8) for the /3000 stream (Node 20.19.2。)close-stream の実行では、サーバーが見た RST_STREAM は遅いストリームの1つだけで、エラーコード8、つまり CANCEL でした。失われたのは遅いリクエストだけです。close-session の実行では、無関係な2本が約505msの時点で、800msの処理が終わる前に切られました。実際のクライアントなら、ここから新しい接続が要ります。(この実行のサーバーログには、2本ある /800 のうち1本ぶんの RST_STREAM の行だけが出ています。フレームは取っておらず、理由も調べていません。上の結論はクライアント側の結果だけに基づいています。)\nこれでコスト面は決着です。安全性の面は、クライアントが返信とリクエストをどう対応づけているかで決まります。\nキャンセルが安全なのは、返信がidを持つときだけ HTTP/2ではリクエストごとにストリームIDがあるので、対応づけは無償です。1本の接続を使う他のプロトコル(JSONを流すWebSocket、RPCチャネル、行プロトコルなど)にはそれがなく、到着順で対応づけてしまう実装がよくあります。その失敗を再現しました。\nこの実験のサーバーは、パイプライン型のプロトコルのように「順序どおり」に返信することもできます。Bの返信は、Bの準備ができていても、Aの返信より先には出ません。クライアント1は位置で対応づけます。クライアント2は、リクエストにも返信にも id を入れます。どちらもA(遅い、2000ms)、続けてBとC(各50ms)を期限500msで送り、最後に新しいリクエストDを送ります。\n// Correlating replies on one shared connection: by position (FIFO) or by request id. // One slow request (A), then two quick ones (B, C), 500 ms deadline each. Run: node mux_lab.mjs import { WebSocketServer, WebSocket } from \u0026#34;ws\u0026#34;; const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); // --- server: one handler per connection; \u0026#34;ordered\u0026#34; replies in request order (like a pipelined protocol), \u0026#34;tagged\u0026#34; replies as soon as ready const wss = new WebSocketServer({ port: 0 }); await new Promise((r) =\u0026gt; wss.on(\u0026#34;listening\u0026#34;, r)); wss.on(\u0026#34;connection\u0026#34;, (ws) =\u0026gt; { let chain = Promise.resolve(); ws.on(\u0026#34;message\u0026#34;, (raw) =\u0026gt; { const m = JSON.parse(raw); const work = sleep(m.delay).then(() =\u0026gt; JSON.stringify({ id: m.id, tag: m.tag })); if (m.ordered) chain = chain.then(() =\u0026gt; work).then((out) =\u0026gt; ws.send(out)); // must wait for everything before it else work.then((out) =\u0026gt; ws.send(out)); }); }); const url = `ws://127.0.0.1:${wss.address().port}`; const open = async () =\u0026gt; { const c = new WebSocket(url); await new Promise((r) =\u0026gt; c.on(\u0026#34;open\u0026#34;, r)); return c; }; // --- client 1: replies carry no id, the n-th reply belongs to the n-th request async function positional() { const c = await open(); const waiting = []; let late = 0; c.on(\u0026#34;message\u0026#34;, (raw) =\u0026gt; { const w = waiting.shift(); if (w) w.resolve(JSON.parse(raw).tag); else late++; }); const call = (tag, delay, deadline = 500) =\u0026gt; new Promise((resolve) =\u0026gt; { const w = { resolve }; waiting.push(w); c.send(JSON.stringify({ tag, delay, ordered: true })); setTimeout(() =\u0026gt; { const i = waiting.indexOf(w); if (i \u0026gt;= 0) { waiting.splice(i, 1); resolve(\u0026#34;TIMEOUT\u0026#34;); } }, deadline); }); const first = await Promise.all([call(\u0026#34;A\u0026#34;, 2000), call(\u0026#34;B\u0026#34;, 50), call(\u0026#34;C\u0026#34;, 50)]); console.log(\u0026#34;positional, first round :\u0026#34;, first.join(\u0026#34; \u0026#34;)); const d = await call(\u0026#34;D\u0026#34;, 10, 3000); // a new request, while A\u0026#39;s reply is still on its way console.log(`positional, request D : asked for D, got \u0026#34;${d}\u0026#34;`); await sleep(500); c.close(); } // --- client 2: replies carry the request id; the timeout removes exactly that id async function tagged() { const c = await open(); const pending = new Map(); let nextId = 1, orphans = 0; c.on(\u0026#34;message\u0026#34;, (raw) =\u0026gt; { const m = JSON.parse(raw); const w = pending.get(m.id); if (w) { pending.delete(m.id); w(m.tag); } else orphans++; }); const call = (tag, delay, deadline = 500) =\u0026gt; new Promise((resolve) =\u0026gt; { const id = nextId++; pending.set(id, resolve); c.send(JSON.stringify({ id, tag, delay })); setTimeout(() =\u0026gt; { if (pending.delete(id)) resolve(\u0026#34;TIMEOUT\u0026#34;); }, deadline); }); const first = await Promise.all([call(\u0026#34;A\u0026#34;, 2000), call(\u0026#34;B\u0026#34;, 50), call(\u0026#34;C\u0026#34;, 50)]); console.log(\u0026#34;by id, first round :\u0026#34;, first.join(\u0026#34; \u0026#34;)); const d = await call(\u0026#34;D\u0026#34;, 10, 3000); console.log(`by id, request D : asked for D, got \u0026#34;${d}\u0026#34;`); await sleep(2500); console.log(`by id, afterwards : late replies dropped as orphans: ${orphans}, pending map size: ${pending.size}`); c.close(); } await positional(); await tagged(); wss.close(); positional, first round : TIMEOUT TIMEOUT TIMEOUT positional, request D : asked for D, got \u0026#34;A\u0026#34; by id, first round : TIMEOUT B C by id, request D : asked for D, got \u0026#34;D\u0026#34; by id, afterwards : late replies dropped as orphans: 1, pending map size: 0 位置対応の行には、別々の失敗が2つ隠れています。\nBとCもタイムアウトします。 サーバーはAを終えてからでないと返せないので、BとCはAの後ろで詰まりました。これはアプリケーション層のヘッドオブラインブロッキングで、RFC 9113の冒頭が、パイプライン化したHTTP/1.1は「依然として」抱えると書いている問題です(RFC 9113 §1)。idがあっても、順序を守るサーバーが速くなるわけではありません。idが可能にするのは、返信の準備ができたらすぐ返すサーバーです。idの実行のサーバーはそう動いています。 DがAの答えを受け取りました。 タイムアウトでクライアントは待ち行の記録を消し、その後でAの遅い返信が届き、次の待ち手であるDに渡されました。間違った答えが、成功として渡されたのです。どこにもエラーは出ません。 idを使うクライアントが失ったのはAだけで、Aの返信がようやく届いたときは「持ち主のいない返信」として捨てられ、保留表は最後に空でした。この安全性のコストは、小さな表1つと次の決まりだけです。タイムアウトしたらidを消す。未知のidの返信は黙って捨てる。\nプロトコルにidがなく、足せないなら、タイムアウト時に安全な対応は接続を閉じることだけです。それは接続をもっと閉じる理由ではなく、idを足す理由です。\nキャンセルが効かないとき: 止まっているのがストリームより下の場合 ここまでは、接続自体は健全だと仮定していました。HTTP/2は全ストリームを1本のTCPバイト列に載せ、TCPはバイトを順序どおりに渡します。1つのパケットが失われると、それ以降のバイトは、どのストリームのものであっても待たされます。RFC 9113ははっきり書いています。「TCPのヘッドオブラインブロッキングは、このプロトコルでは解決されない」(§1)。QUICではこれを避けられます。「そのパケットにデータを持つストリームだけが再送を待ち」ますが、複数ストリームのデータが同じパケットに入っていれば、それらは全部止まります(RFC 9000 §13)。HTTP/3は試していません。\nこれを見るために、小さなネットワークを作りました。仮想イーサネットのペアでつないだ2つのLinuxネットワーク名前空間で、片方にサーバー、もう片方にクライアントがあります。サーバーは2つの応答(/a と /b)をストリーミングし、それぞれ10msごとに1000バイトを送ります。実行開始から約500msの時点で、nft がサーバーからクライアントの接続#1宛のパケットを100ms(DROP=100)だけ落とします。クライアントは各ストリームの最長の沈黙を記録します。「single」モードでは両ストリームが接続#1を共有します。「two」モードではそれぞれ別の接続で、損失を受けるのはストリーム a の接続だけです。\n// Two long responses (/a, /b), each a 1000-byte chunk every 10 ms for 1.5 s. Plain-text HTTP/2. import http2 from \u0026#34;node:http2\u0026#34;; const server = http2.createServer(); server.on(\u0026#34;stream\u0026#34;, (stream) =\u0026gt; { stream.respond({ \u0026#34;:status\u0026#34;: 200 }); const chunk = Buffer.alloc(1000, 1); let n = 0; const t = setInterval(() =\u0026gt; { if (stream.destroyed || ++n \u0026gt; 150) { clearInterval(t); stream.end(); } else stream.write(chunk); }, 10); }); server.listen(9200, \u0026#34;10.1.0.2\u0026#34;, () =\u0026gt; console.log(\u0026#34;server up\u0026#34;)); // mode \u0026#34;single\u0026#34;: both requests share one HTTP/2 connection. mode \u0026#34;two\u0026#34;: one connection per request. // Around t = 500 ms, packets from the server to connection #1 are silently dropped for 30 ms. import http2 from \u0026#34;node:http2\u0026#34;; import { execFile } from \u0026#34;node:child_process\u0026#34;; import { promisify } from \u0026#34;node:util\u0026#34;; const mode = process.argv[2]; const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); const run = promisify(execFile); const nft = (...a) =\u0026gt; run(\u0026#34;nft\u0026#34;, a); // async: do not block the event loop while measuring const DROP_MS = Number(process.argv[3] ?? 30); await nft(\u0026#34;add\u0026#34;, \u0026#34;table\u0026#34;, \u0026#34;inet\u0026#34;, \u0026#34;hol\u0026#34;); await nft(\u0026#34;add\u0026#34;, \u0026#34;chain\u0026#34;, \u0026#34;inet\u0026#34;, \u0026#34;hol\u0026#34;, \u0026#34;in\u0026#34;, \u0026#34;{ type filter hook input priority 0; }\u0026#34;); const s1 = http2.connect(\u0026#34;http://10.1.0.2:9200\u0026#34;); const s2 = mode === \u0026#34;single\u0026#34; ? s1 : http2.connect(\u0026#34;http://10.1.0.2:9200\u0026#34;); await Promise.all([s1, s2].map((s) =\u0026gt; new Promise((r) =\u0026gt; s.once(\u0026#34;connect\u0026#34;, r)))); const port1 = s1.socket.localPort; // the loss will hit this connection only const stats = {}; function fetch(session, name) { return new Promise((resolve) =\u0026gt; { const req = session.request({ \u0026#34;:path\u0026#34;: `/${name}` }); const st = (stats[name] = { last: performance.now(), maxGap: 0, at: 0, bytes: 0 }); const t0 = performance.now(); req.on(\u0026#34;data\u0026#34;, (d) =\u0026gt; { const now = performance.now(); const gap = now - st.last; if (gap \u0026gt; st.maxGap) { st.maxGap = gap; st.at = st.last - t0; } st.last = now; st.bytes += d.length; }); req.on(\u0026#34;end\u0026#34;, resolve); req.resume?.(); }); } const done = Promise.all([fetch(s1, \u0026#34;a\u0026#34;), fetch(s2, \u0026#34;b\u0026#34;)]); await sleep(500); await nft(\u0026#34;add\u0026#34;, \u0026#34;rule\u0026#34;, \u0026#34;inet\u0026#34;, \u0026#34;hol\u0026#34;, \u0026#34;in\u0026#34;, \u0026#34;ip\u0026#34;, \u0026#34;saddr\u0026#34;, \u0026#34;10.1.0.2\u0026#34;, \u0026#34;tcp\u0026#34;, \u0026#34;sport\u0026#34;, \u0026#34;9200\u0026#34;, \u0026#34;tcp\u0026#34;, \u0026#34;dport\u0026#34;, String(port1), \u0026#34;drop\u0026#34;); await sleep(DROP_MS); await nft(\u0026#34;flush\u0026#34;, \u0026#34;chain\u0026#34;, \u0026#34;inet\u0026#34;, \u0026#34;hol\u0026#34;, \u0026#34;in\u0026#34;); await done; for (const [n, st] of Object.entries(stats)) console.log(` stream ${n}: longest silence ${st.maxGap.toFixed(0).padStart(4)} ms (starting ${st.at.toFixed(0)} ms in), ${st.bytes} bytes`); s1.destroy(); s2.destroy(); #!/bin/bash # root needed. Two namespaces joined by a veth pair; server in \u0026#34;sv\u0026#34;, client in \u0026#34;cl\u0026#34;. NODE=$(command -v node); HERE=$(cd \u0026#34;$(dirname \u0026#34;$0\u0026#34;)\u0026#34; \u0026amp;\u0026amp; pwd) ip netns del sv 2\u0026gt;/dev/null; ip netns del cl 2\u0026gt;/dev/null ip netns add sv; ip netns add cl ip link add name vsv type veth peer name vcl ip link set vsv netns sv; ip link set vcl netns cl ip -n sv addr add 10.1.0.2/24 dev vsv; ip -n sv link set vsv up; ip -n sv link set lo up ip -n cl addr add 10.1.0.1/24 dev vcl; ip -n cl link set vcl up; ip -n cl link set lo up ip netns exec sv $NODE $HERE/server.mjs \u0026amp; SRV=$! sleep 1 for round in 1 2 3 4 5; do for mode in single two; do echo \u0026#34; [$mode connection(s)] round $round\u0026#34; ip netns exec cl $NODE $HERE/client.mjs $mode ${DROP:-30} ip netns exec cl nft delete table inet hol 2\u0026gt;/dev/null done done kill $SRV; ip netns del sv; ip netns del cl Node 20.19.2での5ラウンドの出力です(各セルは、そのストリームのデータイベントの間隔のうち最長のもので、単位はミリ秒です)。\nモード ストリーム a(損失のある接続) ストリーム b 1本の接続を共有 315, 224, 274, 258, 260 316, 223, 269, 219, 225 ストリームごとに接続 129, 132, 133, 126, 132 12, 13, 13, 12, 13 健全なストリームが12〜13msなのは、サーバーが10msおきに送るためです。接続が1本だと、100msの途切れが両方のストリームで219〜316msの沈黙になりました。接続を分けた場合は、損傷した接続のストリームだけが影響を受けました。TCPの中身は見ていないので、どの再送タイマーがどのラウンドで効いたのかは分からず、219〜316msのばらつきも説明できません。別の日に100msの条件を5ラウンド再実行すると、共有した接続の両ストリームは218〜225ms、接続を分けた場合は126〜139msと13〜15msでした。形は同じで、ばらつきは違いました。どの数字もTCPの性質として引用するつもりはありません。またこの実験はLinux専用で、ネットワークネームスペースと nft、そしてLinuxの再送の挙動に依存します。短い途切れ(DROP=30)では挙動が違い、1本の接続上の両ストリームが40〜52ms、つまり途切れと同程度の沈黙で、接続を分けた実行でも損傷したほうは同じ程度(40〜51ms)で、隣のストリームは12〜13msのままでした。つまりパケットを失った区間のコストは、その長さだけでは決まりません。共有の影響は、誰が一緒に待たされるかに表れます。\nここで、この停止中にリクエストの期限が来たとします。そのリクエストの RST_STREAM は何も解放しません。そのフレーム自体が、届いていない接続の上を通るからです。他のストリームも同じように静かです。期限切れは症状であって、必要なのは接続レベルの問いです。\n接続に聞く: 自分の期限つきのPING HTTP/2にはそのためのフレームがあります。PING は、「送信側から見た最小のラウンドトリップ時間の測定と、アイドル状態の接続がまだ機能しているかの判断」に使えます(RFC 9113 §6.7)。返信の期限はプロトコルが決めていません。自分で決めます。\n// Is this HTTP/2 connection alive? PING frame + our own deadline. Then the path is black-holed. import http2 from \u0026#34;node:http2\u0026#34;; import { execFile } from \u0026#34;node:child_process\u0026#34;; import { promisify } from \u0026#34;node:util\u0026#34;; const run = promisify(execFile); const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); function alive(session, ms) { // resolves true/false, never rejects return new Promise((resolve) =\u0026gt; { const timer = setTimeout(() =\u0026gt; resolve(false), ms); session.ping((err, rttMs) =\u0026gt; { clearTimeout(timer); resolve(err ? false : rttMs); }); }); } const session = http2.connect(\u0026#34;http://10.1.0.2:9200\u0026#34;); session.on(\u0026#34;error\u0026#34;, () =\u0026gt; {}); await new Promise((r) =\u0026gt; session.once(\u0026#34;connect\u0026#34;, r)); const t0 = performance.now(); console.log(\u0026#34;healthy path : alive() -\u0026gt;\u0026#34;, await alive(session, 1000) !== false ? \u0026#34;true\u0026#34; : \u0026#34;false\u0026#34;, `(PING round trip ${(await alive(session, 1000)).toFixed(2)} ms)`); await run(\u0026#34;nft\u0026#34;, [\u0026#34;add\u0026#34;, \u0026#34;table\u0026#34;, \u0026#34;inet\u0026#34;, \u0026#34;bh\u0026#34;]); await run(\u0026#34;nft\u0026#34;, [\u0026#34;add\u0026#34;, \u0026#34;chain\u0026#34;, \u0026#34;inet\u0026#34;, \u0026#34;bh\u0026#34;, \u0026#34;in\u0026#34;, \u0026#34;{ type filter hook input priority 0; }\u0026#34;]); await run(\u0026#34;nft\u0026#34;, [\u0026#34;add\u0026#34;, \u0026#34;rule\u0026#34;, \u0026#34;inet\u0026#34;, \u0026#34;bh\u0026#34;, \u0026#34;in\u0026#34;, \u0026#34;ip\u0026#34;, \u0026#34;saddr\u0026#34;, \u0026#34;10.1.0.2\u0026#34;, \u0026#34;drop\u0026#34;]); const t1 = performance.now(); const ok = await alive(session, 1000); console.log(`black-holed : alive() -\u0026gt; ${ok !== false} after ${(performance.now() - t1).toFixed(0)} ms (deadline 1000 ms)`); session.destroy(); run2.sh の実行結果です(ネットワークの作り方は同じで、ストリーミングはなし。最初のPINGのあと、nft がサーバーからのパケットをすべて落とします)。\nhealthy path : alive() -\u0026gt; true (PING round trip 0.12 ms) black-holed : alive() -\u0026gt; false after 1001 ms (deadline 1000 ms) 2行目はHTTP/2のことを何も示していません。プローブは自分のタイマーが切れたときに返ったのであって、それより早くではない、というだけです。黙ったままの接続は、自分が死んだとは教えてくれません。だから期限つきで聞く必要があります。どれだけ待つかは、検出の速さと、負荷や遅延の大きい経路での誤検知とのトレードオフで、環境で変わります。自分の環境でラウンドトリップを測って決めてください。\n選択肢の比較 選択肢 得られるもの 代償 タイムアウトのたびに接続を閉じる 実装が最も簡単 巻き添えの失敗(上で再現)。再接続のコスト。多数が同時にタイムアウトすると再接続の嵐 ストリームをキャンセル(HTTP/2の RST_STREAM)して接続は維持 遅いリクエストだけが失われる サーバーが仕事を止める必要がある(後述)。停止がストリームより下なら効かない ストリームをキャンセルし、無関係な複数が同時にタイムアウトしたらPING 上に加えて、経路の死活を検出できる 調整するタイマーが1つ増える 1オリジンに複数接続 パケット損失の影響が、その接続のストリームだけで済む ハンドシェイクとメモリが増える。サーバー側の接続あたりの上限に当たる HTTP/3(QUIC) 1ストリームの損失が他を止めない(RFC 9000 §13) ここでは未検証。使えるかはスタックとネットワーク次第 タイムアウトまわりの失敗 見えること よくある原因 対策 遅いエンドポイントが1つあるだけで、無関係な失敗がまとまって出る。接続数が跳ね上がる タイムアウトのたびにセッション全体を閉じている(上で再現しました) リクエストだけキャンセルする。接続を閉じるのは、プローブ失敗、GOAWAY、プロトコルエラーのときだけにする エラーなしで、別のリクエストのデータが返る 返信を位置で対応づけている(上で再現しました) idを足す。未知のidの返信は捨てる 負荷をかけるとメモリがじわじわ増える。誰も待っていないPromiseが遅い返信で解決される タイムアウト時に保留表を掃除していない(上のidの実行では表は0で終わりました) 一部のリクエストをわざとタイムアウトさせる負荷試験で確かめる クライアントが諦めたのに、CPUやDBの負荷が高いまま キャンセル後もサーバーが仕事を続けている。RST_STREAM はストリームが終わったことを知らせるだけで、ハンドラーが自分で見る必要がある(h2_timeout.mjs のサーバーは、応答する前に stream.destroyed を見ています)。大規模では測っていません 実際に重い処理をするハンドラーすべてで、キャンセルを確認する 遅いサーバーをさらに遅くする再接続ループ 複数のタイムアウトを、接続が死んだ証拠とみなしている まず PING を送る すでに遅いサーバーへの負荷が増える 劣化した経路への即時の再試行(ここでは測っていません。よくある決まりで、最初の2行と一緒に出やすいので挙げました) ジッター付きのバックオフと、再試行回数の上限 普段は何も起きない HTTPクライアントが、中断時にストリームをキャンセルしてくれるという思い込み。特定のライブラリやブラウザは確認していません 頼る前に、サーバーのログかパケットキャプチャで確かめる 再現する h2_timeout.mjs を保存し、node h2_timeout.mjs を実行します。成功なら、close-session の行にFAILEDが2つ、close-stream の行に ok が2つ、どちらも TCP connections opened が1です。 npm i ws@8 のあと mux_lab.mjs を保存して実行します。成功なら、positional に asked for D, got \u0026quot;A\u0026quot;、by id に TIMEOUT B C と got \u0026quot;D\u0026quot; が出ます。 Linuxでroot、ip、nft が使えるなら、server.mjs、client.mjs、pingtest.mjs、run.sh、run2.sh を1つのディレクトリに置きます。停止の表は sudo DROP=100 bash run.sh、PINGの実験は sudo bash run2.sh です(rootの PATH に nft がなければ /usr/sbin を足します)。スクリプトは名前空間 sv と cl を作って消します。 DROP を100から30や300に変え、見る前に沈黙の長さを予想してください。 mux_lab.mjs のAが期限内に終わるように変え、2つのクライアントで3つの返信が揃うことを確かめてください。 この結果が届く範囲 確認できたこと: 1台のマシン(Linux 6.12、Node 20.19.2)で、上の出力を、表示されたとおりに確認しました。パケット損失の実験は、2種類の長さ(100msと30ms)でモードごとに5ラウンド、さらに後日、100msの条件を5ラウンド再実行しました。PINGの実験は1回実行し、1回再現しました。引用したRFC 9113とRFC 9000の節は、書く際にrfc-editor.orgで読みました。\n確認できていないこと: 実ネットワークやミドルボックス、TLS、HTTP/3、ブラウザやHTTPクライアントライブラリの中断時の挙動、219〜316msのばらつきの正確な原因、キャンセル後に実負荷がかかったときのサーバーの挙動。期限の値(500ms、1000ms)は実験用の選択で、推奨値ではありません。nft をブロッキング呼び出しにした初期の試行は、イベントループを止めるため誤った数字を出しました。ここのスクリプトは非同期版です。\n期限はリクエストのもの 期限はリクエストのもので、リクエストにはidがあります。タイムアウトしたものだけをキャンセルします。接続の生死は別に、PING と自分で決めた期限で確かめ、答えなかったときだけ閉じます。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/http2-timeout-stream-vs-connection/","summary":"たくさんのリクエストを同時に流している1本の接続で、1つのリクエストの期限が来たとき、接続ごと閉じるべきか、そのリクエストだけを閉じるべきか。小さなNodeの実験では、HTTP/2のセッションを閉じると無関係な2本が失敗し、ストリームだけをキャンセルすると同じTCP接続のまま2本とも完了しました。ただし、TCPの1パケットの遅れは接続上の全ストリームを止めます。その場合に接続の生死を確かめる方法として、PINGを使った確認も試します。","title":"HTTP/2で1本がタイムアウトしたら、閉じるのは接続ではなくストリーム"},{"content":"TL;DR send() は、バイト列をキューに積んだ時点で戻ります。僕の環境では、まったく読まない受信側に対して、64MiBを約0.15〜0.25秒で渡し終えました。Node(ws)でもヘッドレスChromeでも同じでした。 bufferedAmount が数えるのは、まだOSに渡していない分だけです。受信側が止まっていても、Linuxのループバックでは最初の2.56MiB(Node)、2.75MiB(Chrome)の間は 0 のままでした。カーネルの送信・受信バッファが先に受け取ったからです。bufferedAmount === 0 は、相手に届いた印ではありません。 bufferedAmount が下がるのを待ってから send() する方法で守れるのは、送信側のメモリです。ソケットは速く読み、仕事を自分のメモリに積んで遅く処理する受信側は守れません。この実験では、5回とも400件中387〜393件(約24MiB)が受信側に溜まりました。8件のクレジット窓なら、溜まるのは8件でした。 受信側の上限を超えるメッセージは、そのメッセージだけでは済みません。受信側は接続全体をコード1009で閉じ、直後に送った小さなメッセージも失われます。 下の5つの短いスクリプトで、すべて再現できます。必要なのはNode 20、wsパッケージ、そして1つだけChromeです。 4つの思い込みを、順番に試す WebSocketのコードは、だいたいこう書かれています。\nsocket.send(payload); 1行で、例外も出ません。相手が遅いときにバイト列がどこで待つのかは、この行からは分かりません。この行について僕を含め多くの人が置きがちな4つの前提を、小さな実験で確かめました。\nbufferedAmount が見えるのは青い箱だけです。受信側カーネルのバッファが埋まるまでは、TCP自身のフロー制御がその手前を守ります。橙の箱は、何か作らない限り誰も守ってくれません。\n思い込み1「send()が戻ったから、送れた」 実験の構成です。ハンドシェイクだけ済ませてあとはソケットを読まないサーバーと、64KiBのメッセージを1024個(合計64MiB)、同期ループで送るクライアントを用意します。\n// A WebSocket server that accepts connections and then stops reading from the socket. import { WebSocketServer } from \u0026#34;ws\u0026#34;; const wss = new WebSocketServer({ port: 8081 }); wss.on(\u0026#34;connection\u0026#34;, (ws) =\u0026gt; { ws._socket.pause(); // stop reading: the kernel receive buffer fills, then the window closes console.log(\u0026#34;client connected; not reading\u0026#34;); }); console.log(\u0026#34;listening on ws://127.0.0.1:8081\u0026#34;); // Send 64 KiB messages as fast as possible to a server that is not reading. import WebSocket from \u0026#34;ws\u0026#34;; const ws = new WebSocket(\u0026#34;ws://127.0.0.1:8081\u0026#34;); const CHUNK = Buffer.alloc(64 * 1024, 1); ws.on(\u0026#34;open\u0026#34;, () =\u0026gt; { let sent = 0, firstBuffered = null; const t0 = process.hrtime.bigint(); for (let i = 1; i \u0026lt;= 1024; i++) { // 1024 x 64 KiB = 64 MiB in one synchronous loop ws.send(CHUNK); sent += CHUNK.length; if (firstBuffered === null \u0026amp;\u0026amp; ws.bufferedAmount \u0026gt; 0) firstBuffered = sent; } const ms = Number(process.hrtime.bigint() - t0) / 1e6; console.log(`send() x1024 returned after ${ms.toFixed(1)} ms`); console.log(`bufferedAmount stayed 0 until ${(firstBuffered / 1048576).toFixed(2)} MiB had been \u0026#34;sent\u0026#34;`); console.log(`bufferedAmount now: ${(ws.bufferedAmount / 1048576).toFixed(2)} MiB of ${(sent / 1048576)} MiB`); console.log(`rss: ${(process.memoryUsage().rss / 1048576).toFixed(0)} MiB`); setTimeout(() =\u0026gt; process.exit(0), 200); }); 片方の端末で node stall_server.mjs、もう片方で node probe_node.mjs を動かします。僕の環境での3回分です。\nsend() x1024 returned after 193.4 ms (残り2回は152.3msと152.8ms) bufferedAmount stayed 0 until 2.56 MiB had been \u0026#34;sent\u0026#34; bufferedAmount now: 61.51 MiB of 64 MiB rss: 129 MiB ループは1秒もかからず終わり、例外も待ちもありませんでした。受信側は何も読んでいないのに、です。約61.5MiBが送信側プロセスの中に残っていました。同じ状況を、同じ止まったサーバーに対してヘッドレスChromeで試します。\n// Same probe from a real browser WebSocket (headless Chrome). Needs: npm i puppeteer-core, google-chrome. const puppeteer = require(\u0026#34;puppeteer-core\u0026#34;); (async () =\u0026gt; { const b = await puppeteer.launch({ executablePath: \u0026#34;/usr/bin/google-chrome\u0026#34;, headless: \u0026#34;new\u0026#34;, args: [\u0026#34;--no-sandbox\u0026#34;] }); const p = await b.newPage(); const http = require(\u0026#34;http\u0026#34;); const srv = http.createServer((q,r)=\u0026gt;r.end(\u0026#34;\u0026lt;!doctype html\u0026gt;\u0026lt;title\u0026gt;x\u0026lt;/title\u0026gt;\u0026#34;)).listen(8082); await p.goto(\u0026#34;http://127.0.0.1:8082/\u0026#34;); const r = await p.evaluate(async () =\u0026gt; { const ws = new WebSocket(\u0026#34;ws://127.0.0.1:8081\u0026#34;); await new Promise((res) =\u0026gt; (ws.onopen = res)); const chunk = new Uint8Array(64 * 1024); const t0 = performance.now(); for (let i = 0; i \u0026lt; 1024; i++) ws.send(chunk); const sameTask = ws.bufferedAmount; const ms = performance.now() - t0; const samples = []; for (let k = 0; k \u0026lt; 5; k++) { await new Promise((r) =\u0026gt; setTimeout(r, 400)); samples.push(ws.bufferedAmount); } return { ms, sameTask, samples, wss: typeof WebSocketStream, readyState: ws.readyState }; }); console.log(JSON.stringify(r)); console.log(\u0026#34;sameTask MiB\u0026#34;, (r.sameTask / 1048576).toFixed(2), \u0026#34;later MiB\u0026#34;, r.samples.map((x) =\u0026gt; (x / 1048576).toFixed(2)).join(\u0026#34; \u0026#34;)); await b.close(); srv.close(); })(); sameTask MiB 64.00 later MiB 61.25 61.25 61.25 61.25 61.25 ループの直後(同じタスクの中)では、Chromeは64MiB全部を報告します。これはWebSocketsの標準の記述と合っています。getterが返すのは、send() でキューに積まれ、「イベントループが最後にステップ1に到達した時点でまだネットワークに送られていない」バイト数で、現在のタスクの中で送った分も含まれる、とあります。その後は61.25MiBで安定しました。3回とも、バイト数は同じでした。タイミングを表示した回では、1024回の send() に約0.15〜0.25秒かかっています(別の日に再実行した結果は190〜240msでした)。バイト数はソケットバッファに、所要時間はマシンに左右されます。\n標準は、上限に達したときのことも書いています。送るデータが「バッファに入れる必要があるが、バッファが一杯」で送れない場合、ブラウザはWebSocketに「full」の印を付けて接続を閉じます(WHATWG WebSockets、send())。そのバッファの大きさは書かれておらず、64MiBではそこに届きませんでした。\nつまり send() は「キューに入れる」です。相手が受け取るかどうかは別の問題で、send() には答えられません。\n思い込み2「bufferedAmountが0だから、相手に届いている」 同じ実行がそのまま答えです。bufferedAmount は、2.56MiB(Node、ws 8.22.0)を送るまで 0 のままで、Chromeでは同じ値が 64 − 61.25 = 2.75MiB でした。受信側は何も読んでいません。データはカーネルの中にありました。この環境の tcp_wmem は 4096 16384 4194304、tcp_rmem は 4096 131072 6291456(最小・既定・最大、単位はバイト、自動調整)で、ループバックなら2つのソケットバッファに数MiBは入ります。値は環境によって変わります。\nこれは仕様どおりです。標準には、bufferedAmount は「プロトコルのフレーミングのオーバーヘッドや、OSやネットワーク機器によるバッファリングを含まない」とあります(WHATWG WebSockets)。MDNには、接続が閉じても0に戻らず、send() を呼び続けると増え続ける、とあります。この点はブラウザでは試していません。\nNodeの ws ライブラリはドキュメントで2点だけ違います。すぐ送れたら 0 になること、そして標準と違ってフレーミングのバイトを含むことです。\nbufferedAmount === 0 は「自分のキューが空」と読みます。「配達済み」とは読みません。配達を知りたいなら、相手アプリケーションからの確認応答が要ります。\n思い込み3「bufferedAmountが減るのを待てば、遅い受信側に潰されない」 これは僕自身が意外でした。標準の例も bufferedAmount == 0 を待ってから次の更新を送っていますし、MDNの WebSocketStream のページは「ストリームのバックプレッシャーを自動的に活用できる」と説明しています。bufferedAmount で待てばバックプレッシャーになる、と考えるのは自然です。たしかにそれは、ネットワークと受信側カーネルからのバックプレッシャーです。では、受信側カーネルは問題なく、遅いのが受信側のアプリだったらどうでしょうか。\n実験の構成です。受信側は、メッセージを届いた順に即座に読み、キューに積み、1件5msで処理します。送信側は64KiBのメッセージ400件を、3通りの方法で送ります。スクリプトは、受信側のキューが最も長くなった時点の件数を表示します。\n// Does gating on bufferedAmount protect a slow consumer? Run: node slow_lab.mjs import { WebSocketServer, WebSocket } from \u0026#34;ws\u0026#34;; const MSGS = 400, SIZE = 64 * 1024, WORK_MS = 5; // the consumer needs 5 ms per message const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); async function run(mode) { const wss = new WebSocketServer({ port: 0 }); await new Promise((r) =\u0026gt; wss.on(\u0026#34;listening\u0026#34;, r)); const port = wss.address().port; let peak = 0, done; // peak = most messages ever waiting in the consumer\u0026#39;s JS memory const finished = new Promise((r) =\u0026gt; (done = r)); wss.on(\u0026#34;connection\u0026#34;, (ws) =\u0026gt; { const queue = []; let handled = 0, working = false; const pump = async () =\u0026gt; { if (working) return; working = true; while (queue.length) { await sleep(WORK_MS); queue.shift(); handled++; if (mode === \u0026#34;credit\u0026#34;) ws.send(\u0026#34;ack\u0026#34;); // credit: one ack per message that is really finished if (handled === MSGS) done(); } working = false; }; ws.on(\u0026#34;message\u0026#34;, () =\u0026gt; { queue.push(1); peak = Math.max(peak, queue.length); pump(); }); }); const c = new WebSocket(`ws://127.0.0.1:${port}`); await new Promise((r) =\u0026gt; c.on(\u0026#34;open\u0026#34;, r)); const payload = Buffer.alloc(SIZE, 7); let inFlight = 0, wake = null, peakBuffered = 0; c.on(\u0026#34;message\u0026#34;, () =\u0026gt; { inFlight--; wake?.(); }); const t0 = Date.now(); for (let i = 0; i \u0026lt; MSGS; i++) { if (mode === \u0026#34;bufferedAmount\u0026#34;) while (c.bufferedAmount \u0026gt; 256 * 1024) await sleep(5); if (mode === \u0026#34;credit\u0026#34;) while (inFlight \u0026gt;= 8) await new Promise((r) =\u0026gt; (wake = r)); // window of 8 messages c.send(payload); inFlight++; peakBuffered = Math.max(peakBuffered, c.bufferedAmount); } const sendMs = Date.now() - t0; await finished; console.log(`${mode.padEnd(15)} sender done sending after ${String(sendMs).padStart(5)} ms | sender peak bufferedAmount ${(peakBuffered/1048576).toFixed(2)} MiB | consumer peak backlog ${String(peak).padStart(3)} messages (${(peak*SIZE/1048576).toFixed(1)} MiB)`); c.close(); wss.close(); } for (const m of [\u0026#34;naive\u0026#34;, \u0026#34;bufferedAmount\u0026#34;, \u0026#34;credit\u0026#34;]) await run(m); node slow_lab.mjs を5回実行した結果です(Node 20.19.2、ws 8.22.0、ループバック)。\n送信側 400件を渡し終えるまで 送信側の bufferedAmount 最大 受信側のバックログ最大 naive: ループで送るだけ 62〜78ms 22.57MiB 388〜390件(24.3〜24.4MiB) bufferedAmount: 256KiB以下になるまで待つ 108〜216ms 0.25MiB 387〜393件(24.2〜24.6MiB) credit: 未応答は最大8件 約2050ms 0MiB 8件(0.5MiB) (各セルは5回の範囲です。naive の送信側の最大 bufferedAmount だけは、毎回22.57MiBでした。)\n注目すべきは真ん中の行です。送信側の自分のキューは0.25MiBにきちんと収まっています。ところがデータは消えていません。受信側のコードは届いたそばから読むので、TCPのウィンドウは閉じず、bufferedAmount は増えず、送信側は待たずに、24MiBがそのまま相手のJavaScript配列に溜まりました。問題が移動しただけです。\n3行目はクレジット窓です。受信側が1件処理し終えるたびに小さな \u0026quot;ack\u0026quot; を返し、送信側は未応答を最大8件に抑えます。バックログは構造上8件で止まります。かかった時間は約2秒ですが、これは受信側が実際に必要とする時間(400 × 5ms)です。送信側が、消費する側のペースに合わされた、ということです。\nbufferedAmount が不要になったわけではありません。ネットワークが遅いときに、送信側自身のメモリを抑える道具としては正しい選択です。2つは組み合わせられます。\n受信側アプリに対して: クレジット窓 ネットワークと受信側カーネルに対して: bufferedAmount の上限 思い込み4「メッセージサイズは性能のつまみにすぎない」 RFCではメッセージを複数フレームに分割できるので、サイズは何でもよさそうに見えます(RFC 6455 §5.4)。それでも受信側は最大サイズを決めます。同じRFCにはクローズコード1009があり、「処理するには大きすぎるメッセージを受け取ったため、エンドポイントが接続を終了する」と定義されています(§7.4.1)。プラットフォームもこうした上限を公開しています。たとえばCloudflare Durable Objectsのドキュメントには、受信するWebSocketメッセージは32MiBとあります(limits)。実験では ws の maxPayload を1MiBにしました。\n// What happens to a message that is larger than the receiver allows? Run: node limit_lab.mjs import { WebSocketServer, WebSocket } from \u0026#34;ws\u0026#34;; const wss = new WebSocketServer({ port: 0, maxPayload: 1024 * 1024 }); // receiver accepts at most 1 MiB per message await new Promise((r) =\u0026gt; wss.on(\u0026#34;listening\u0026#34;, r)); wss.on(\u0026#34;connection\u0026#34;, (ws) =\u0026gt; { ws.on(\u0026#34;message\u0026#34;, (m) =\u0026gt; console.log(`server got a message of ${m.length} bytes`)); ws.on(\u0026#34;error\u0026#34;, (e) =\u0026gt; console.log(`server error: ${e.message}`)); }); const c = new WebSocket(`ws://127.0.0.1:${wss.address().port}`); await new Promise((r) =\u0026gt; c.on(\u0026#34;open\u0026#34;, r)); c.send(Buffer.alloc(512 * 1024)); // fits c.send(Buffer.alloc(2 * 1024 * 1024)); // does not fit c.send(Buffer.alloc(16)); // a perfectly small message, sent afterwards await new Promise((r) =\u0026gt; c.on(\u0026#34;close\u0026#34;, (code, reason) =\u0026gt; { console.log(`client: closed with code ${code} ${reason}`); r(); })); wss.close(); server got a message of 524288 bytes server error: Max payload size exceeded client: closed with code 1009 512KiBは届きました。2MiBは接続ごと閉じられ、その直後に送った16バイトのメッセージは届きませんでした。サーバーは2回目の「got a message」を表示していません。大きすぎるメッセージ1件の影響範囲は接続全体で、後ろにいた無関係のメッセージも巻き込まれます。\n実務上の決め方はこうです。やり取りする相手すべての上限のうち最小のものより小さい最大メッセージサイズを決め、それを超えるものは自分で分割し、分割した各チャンクも他のメッセージと同じゲート(まずクレジット、次に bufferedAmount)を通して送ります。分割すれば、メモリが有界になり、待つ場所もはっきりします。再送はTCPがすでにやっているので、チャンクが失われるのは接続が失われるときです。\nWebSocketStreamなら解決するのか MDNは WebSocketStream を、ストリームの上に作られたPromiseベースのAPIで、「ストリームのバックプレッシャーを自動的に活用できる」と説明しています。実験的で非標準であり、「現時点ではどの仕様にも含まれていない」とも書かれています(MDN)。使ったヘッドレスChrome 154では、typeof WebSocketStream は \u0026quot;function\u0026quot; でした。動作は試していませんし、他のブラウザやReact Nativeでの有無も確認していません。なお、書き込み側のストリームのバックプレッシャーが反映するのも、やはりトランスポートの状態です。相手アプリの遅さに対するクレジット窓は、どのストリームAPIも無償では提供しません。\nどれを使えばよいか 状況 使うもの 小さなメッセージ、低頻度、受信側の処理が軽い 何も要りません。素の send() で十分です。 遅いネットワークで、自分のメモリを超えうる突発的な送信(大きなペイロード、速い生産者) bufferedAmount でゲートします(ポーリング、または ws の send(data, cb) のようなコールバック)。readyState が OPEN でなくなったら止めます。 受信側の処理が送信側の生産より遅くなりうる アプリの確認応答によるクレジット窓。メッセージサイズがばらつくならバイト数で数えます。 どの受信側の上限も超えうるペイロード 最小の上限より小さいサイズに決め、分割し、上のゲートを通して送ります。 「相手が処理した」ことを確実に知りたい そのメッセージのアプリ層の確認応答。アプリより下のどの層も、それは教えてくれません。 最初に疑う失敗 終了条件のない while (bufferedAmount \u0026gt; high) ループ。 症状: 接続が切れたあとも、タブやプロセスが回り続けます。直し方: readyState も確認し、OPEN でなければ失敗として返します(MDNによれば、閉じても値は0に戻りません)。 エラー経路でクレジットを返さない。 症状: 1件失敗しただけで、送信側が永遠に止まります。直し方: 確認応答を finally で返すか、否定応答を返します。 サイズがばらつくのにクレジットを件数で数える。 症状: 窓8件で問題なかったのに、1件が30MiBのとき破綻します。直し方: バイト数で数えます。 再接続時にクレジットのカウンタを戻さない。 症状: 再接続後、送信側は永遠に返らない8件が飛行中だと思い込み、何も送りません。直し方: 新しい接続が開いたら窓をリセットします。 固定間隔のポーリング。 症状: スループットがスリープの長さで頭打ちになるか、無駄な起床が増えます。ここでの5msは問題ありませんでしたが、自分の環境で測ってください。 サイズ超過の切断を一時的なエラーとして扱う。 症状: 再接続し、同じ大きすぎるメッセージを再送し、また切られる、を永遠に繰り返します。直し方: そのペイロードにとって1009は恒久的な失敗として扱います。 試してみる mkdir lab \u0026amp;\u0026amp; cd lab \u0026amp;\u0026amp; npm init -y \u0026amp;\u0026amp; npm i ws@8 を実行し、上のスクリプトを package.json の隣に保存します。 端末1で node stall_server.mjs、端末2で node probe_node.mjs を動かします。bufferedAmount が数MiBの間 0 のままで、その後、送った総量近くまで増えれば想定どおりです。 node slow_lab.mjs を動かします。成功した場合は上の表と同じ形になります。最初の2つの送信側は受信側のバックログが400に近く、credit は8件ちょうどです。 node limit_lab.mjs を動かします。1009で閉じられ、「got a message of 16 bytes」の行が出なければ成功です。 ブラウザ版は、puppeteer-core を入れ、executablePath を自分のChromeに向け、node stall_server.mjs と node probe_chrome.cjs を動かします。 slow_lab.mjs の WORK_MS や窓の大きさ(8)を変え、実行する前にバックログと所要時間を予想してみてください。 この計測が届く範囲 確認できたこと: 1台のマシン(Linux 6.12、ループバック、Node 20.19.2、ws 8.22.0、ヘッドレスChrome 154)で、上の表の数字を確認しました。数字は、印字されたスクリプトの出力そのままです(slow_lab.mjs 5回、各プローブ3回、limit_lab.mjs 1回)。WebSocketsの標準、RFC 6455、MDN、ws のドキュメント、Cloudflareの上限ページからの引用は、書く際に元のページで確認しました。\n確認できていないこと: 実際のネットワーク(カーネルが吸収した2.56MiBや2.75MiBはソケットバッファの大きさに依存し、環境で変わります)、他のブラウザ、React Native、Safari、ブラウザの送信バッファが実際に一杯になったときの挙動、接続が閉じたあとの bufferedAmount、WebSocketStream の動作、permessage-deflate(RFC 7692)、Cloudflareのランタイム自体(ドキュメントの上限を引用しただけです)。1件5msや窓8件は実験用の値で、推奨値ではありません。\n受け取れる量は、相手に言わせる send() は「キューに入れた」。bufferedAmount は「まだカーネルに渡していない」。どちらも「相手が追いついている」とは言っていません。相手が自分より遅くなりうるなら、相手が自分で制御できる数字、つまりクレジットで、遅さを申告させます。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/websocket-bufferedamount-backpressure/","summary":"WebSocketのsend()は待ってくれず、bufferedAmountが見ているのは自分のプロセスの中の待ち行列だけです。小さな実験では、bufferedAmountが減るのを待ってから送る送信側でも、受信側のメモリには、400件のほぼ全部が溜まりました。受信側が処理済みを返す「クレジット窓」(8件)なら、溜まるのは8件で止まりました。send()、bufferedAmount、メッセージサイズ、バックプレッシャーについてよくある4つの思い込みを実際に試し、使い分けの表にまとめます。","title":"bufferedAmountを待っても守れるのは送信側だけで、遅い受信側は守れない"},{"content":"たとえば、クライアントが40秒オフラインになってから再接続し、その間にサーバーが300件のイベントを発行したとします。簡単な答えは2つあり、どちらも、あとから気づく形で失敗します。何も送らなければ、クライアントは黙って遅れたままになります。履歴を全部送れば、再接続のたびのコストが、ストリームの年齢に比例して増えます。\nこの記事は2つ目の答え(版0)から始め、問題を1つずつ直して、上限つきのログとスナップショットのフォールバック(版3)、そしてスナップショットを安全にする規則(版4)まで進みます。途中で、ブラウザの EventSource が実際に何をするかを確かめます。ブラウザはすでに、解決策の半分を持っているからです。\nTL;DR 再開には3つが要ります。クライアントが覚えておける位置(カーソル)、その位置から再生できるサーバー側のログ、そして位置が古すぎて再生できないときのフォールバック(スナップショット)です。 Server-Sent Events は、クライアント側の半分を無料で提供します。ブラウザは最後の id を覚え、再接続時に Last-Event-ID として送り返します。Chrome 154 で確認しました。提供されないのは、サーバー側の半分と、「あなたを再開できません」と伝える手段です。 non-200 のレスポンスを受けると、ネイティブの EventSource は永久に終了し、ページにはステータスも本文も見えません(Chrome 154 で確認)。だからブラウザ向けには、「古すぎる、最初からやり直して」をストリームの中で伝えます。200 で応答し、スナップショットを載せた reset イベントを送ります。自分で制御するクライアントなら、410 Gone でも構いません。 スナップショットは (状態, カーソル) の組です。カーソルを先に、状態をあとに読み、再生を冪等にします。逆の順序だと、更新が消えることがあります。僕の簡易モデルでは、1000回の試行のうち699回でした。 カーソルが安全なのは、シーケンス番号が順番どおりに見えるようになる場合だけです。書き手 A が 5 を取り、書き手 B が 6 を公開したあとに A がコミットすると、カーソルが 6 の読み手は 5 を二度と見ません。 この記事のエンドツーエンドのテスト(ランダムな接続切断と、ログの保持期間より長い不在)は、クライアントとサーバーが同じ状態になり、欠落ゼロ、重複ゼロで終わります。 版0:再接続して、全部読み直す 最も単純で正しい設計は、接続のたびに状態の全体を送り、そのあとライブのイベントを流すことです。カーソルもログもなく、間違えようがありません。コストは、再接続のたびに履歴の大きさに比例します。小さなデータなら問題ありませんが、大きなデータでは悲惨です。再接続は、ネットワークが悪いときにまとまって起きるからです。この版は手元に残しておいてください。以降のすべての版のフォールバックになります。\n版1:カーソル すべてのイベントに、サーバーが公開する順に1ずつ増えるシーケンス番号を付けます。クライアントは、最後に適用した番号を覚えておき、再接続したらその後のすべてを求めます:GET /events?after=42。サーバーはログから答えます。\nこれが動くかどうかは、3つの規則で決まります。\nシーケンスはストリームごとに単調で、欠番がない。 そうすれば、クライアントは番号を見るだけで、欠けたイベントを検出できます。 カーソルはクライアントにとって不透明にする。 クライアントは保存して返すだけです。何を符号化しているかを後から変えられます。 番号が順番どおりに見えるようになる。 見落とされるのはここです。書き込みの開始時に番号を割り当て、コミット時に見えるようになるとします。書き手 A が 5 を取り、書き手 B が 6 を取って先にコミットすると、6 を見た読み手のカーソルは、5 を飛び越えてしまいます。 visibility_gap.mjs // A cursor is only safe if sequence numbers become visible in order. // Writer A takes number 5 and is slow to publish; writer B takes 6 and publishes first. Run: node visibility_gap.mjs const published = []; // what readers can see, in publication order const publish = (seq, data) =\u0026gt; published.push({ seq, data }); const readAfter = (cursor) =\u0026gt; published.filter((e) =\u0026gt; e.seq \u0026gt; cursor); publish(4, \u0026#34;x\u0026#34;); // everything up to 4 is visible // writer A allocated seq 5 but has not committed yet publish(6, \u0026#34;y\u0026#34;); // writer B allocated 6 and committed first let cursor = 4; const first = readAfter(cursor); // the reader polls: sees only 6 cursor = Math.max(cursor, ...first.map((e) =\u0026gt; e.seq)); // cursor advances to 6 publish(5, \u0026#34;z\u0026#34;); // writer A commits late const second = readAfter(cursor); // the reader polls again with cursor 6 console.log(\u0026#34;first read :\u0026#34;, first.map((e) =\u0026gt; e.seq)); console.log(\u0026#34;second read:\u0026#34;, second.map((e) =\u0026gt; e.seq), \u0026#34;\u0026lt;- event 5 is never delivered\u0026#34;); first read : [ 6 ] second read: [] \u0026lt;- event 5 is never delivered このモデルは意図的に抽象的です。特定のデータベースではなく、バグの形を示しています。対策はどれも、見えるようになる順序を、割り当ての順序に合わせる方法です。公開の瞬間に番号を割り当てる(書き手を1つにする、またはロックを使う)、あるいは、読み手が「まだ処理中かもしれない最小の番号」で止まる、という方法です。\n版2:カーソルをブラウザに持たせる Server-Sent Events は、まさにこのクライアントの挙動を標準化しています。HTML Standard によると、id: フィールドは接続の最後のイベント ID を設定します。その値は、サーバーが別の値を設定するまで保持されます。再接続のとき、ブラウザはそれを Last-Event-ID リクエストヘッダーとして送ります。NUL 文字を含む id は無視されます。retry: は再接続までの待ち時間を設定します。EventSource のクライアントは、再接続ループつきのカーソル保存庫です。\nこれを鵜呑みにしたくなかったので、実際のブラウザで確かめました。ページが EventSource を開き、サーバーがイベント 1〜3 を送って接続を切り、サーバーは再接続のときのヘッダーを記録します。\neventsource_check.mjs // What does a real browser EventSource do on reconnect, and on a non-200 response? // Run: node eventsource_check.mjs (needs a Chrome/Chromium binary; set CHROME=/path if needed) import http from \u0026#34;node:http\u0026#34;; import { spawn } from \u0026#34;node:child_process\u0026#34;; const seenHeaders = []; // Last-Event-ID values the server received on /sse let attempts410 = 0; const page = `\u0026lt;script\u0026gt; const out = { messages: [], sseErrors: 0, gone: { errors: 0, readyState: null } }; const es = new EventSource(\u0026#34;/sse\u0026#34;); es.onmessage = (e) =\u0026gt; { out.messages.push(e.lastEventId); }; es.onerror = () =\u0026gt; { out.sseErrors++; }; const gone = new EventSource(\u0026#34;/gone\u0026#34;); gone.onerror = () =\u0026gt; { out.gone.errors++; out.gone.readyState = gone.readyState; }; setTimeout(() =\u0026gt; { fetch(\u0026#34;/report\u0026#34;, { method: \u0026#34;POST\u0026#34;, body: JSON.stringify(out) }); }, 2500); \u0026lt;/script\u0026gt;`; const server = http.createServer(async (req, res) =\u0026gt; { if (req.url === \u0026#34;/\u0026#34;) { res.writeHead(200, { \u0026#34;content-type\u0026#34;: \u0026#34;text/html\u0026#34; }); return res.end(page); } if (req.url === \u0026#34;/sse\u0026#34;) { const last = req.headers[\u0026#34;last-event-id\u0026#34;]; seenHeaders.push(last ?? null); res.writeHead(200, { \u0026#34;content-type\u0026#34;: \u0026#34;text/event-stream\u0026#34; }); res.write(\u0026#34;retry: 100\\n\\n\u0026#34;); const start = last ? Number(last) + 1 : 1; for (let id = start; id \u0026lt; start + 3; id++) res.write(`id: ${id}\\ndata: hello\\n\\n`); if (!last) setTimeout(() =\u0026gt; res.destroy(), 100); // first connection: drop it after 3 events return; } if (req.url === \u0026#34;/gone\u0026#34;) { attempts410++; res.writeHead(410, { \u0026#34;content-type\u0026#34;: \u0026#34;application/json\u0026#34; }); return res.end(\u0026#39;{\u0026#34;reset\u0026#34;:true}\u0026#39;); } if (req.url === \u0026#34;/report\u0026#34;) { let b = \u0026#34;\u0026#34;; for await (const c of req) b += c; console.log(\u0026#34;page reported:\u0026#34;, b); console.log(\u0026#34;Last-Event-ID headers seen by /sse:\u0026#34;, JSON.stringify(seenHeaders)); console.log(\u0026#34;connection attempts to the 410 endpoint:\u0026#34;, attempts410); res.end(\u0026#34;ok\u0026#34;); clearTimeout(guard); chrome.kill(); server.close(); server.closeAllConnections(); } }); await new Promise((r) =\u0026gt; server.listen(0, \u0026#34;127.0.0.1\u0026#34;, r)); const chrome = spawn(process.env.CHROME ?? \u0026#34;google-chrome\u0026#34;, [ \u0026#34;--headless=new\u0026#34;, \u0026#34;--no-sandbox\u0026#34;, \u0026#34;--disable-gpu\u0026#34;, \u0026#34;--user-data-dir=./es-check-profile\u0026#34;, `http://127.0.0.1:${server.address().port}/`, ], { stdio: \u0026#34;ignore\u0026#34; }); const guard = setTimeout(() =\u0026gt; { console.log(\u0026#34;timeout\u0026#34;); chrome.kill(); process.exit(1); }, 20000); page reported: {\u0026#34;messages\u0026#34;:[\u0026#34;1\u0026#34;,\u0026#34;2\u0026#34;,\u0026#34;3\u0026#34;,\u0026#34;4\u0026#34;,\u0026#34;5\u0026#34;,\u0026#34;6\u0026#34;],\u0026#34;sseErrors\u0026#34;:1,\u0026#34;gone\u0026#34;:{\u0026#34;errors\u0026#34;:1,\u0026#34;readyState\u0026#34;:2}} Last-Event-ID headers seen by /sse: [null,\u0026#34;3\u0026#34;] connection attempts to the 410 endpoint: 1 最初の接続にはヘッダーがなく、再接続には 3 が付いていました。そのあと、ページは 4、5、6 を受け取りました。再開プロトコルの半分が、クライアントのコードなしで動いています。(Linux 上のヘッドレス Chrome 154。ほかのブラウザは確認していません。)\n出力の後半は、設計にとっていちばん重要です。/gone のエンドポイントは 410 を返しました。ブラウザは1回だけ試し、error を発火し、readyState は 2(CLOSED)になりました。再試行しませんでした。仕様もそう書いています。レスポンスのステータスが 200 でない、またはコンテンツタイプが text/event-stream でない場合は「接続を失敗させる」(fail the connection)。そして「ユーザーエージェントが接続を失敗させたあとは、再接続を試みない」(Once the user agent has failed the connection, it does not attempt to reconnect.)。ページには、ステータスコードも本文も見えません。\nつまりブラウザでは、「あなたのカーソルは古すぎる」を 410 で伝えることはできません。200 のレスポンスの中、ストリームの中で伝える必要があります。\n版3:上限つきのログと、ストリーム内のリセット ログを無限に伸ばすことはできません。直近 N 件(または直近 N 分)だけを保持すると、それより長く不在だったクライアントは、ログから再開できません。サーバーはそれに気づいて、そう伝える必要があります。ネイティブの EventSource にも、fetch ベースのクライアントにも使える設計は、次のとおりです。\nクライアントは、カーソルを送る(Last-Event-ID)。 cursor + 1 \u0026gt;= 保持している最古の番号 なら、カーソルより後のイベントを再生し、そのあとライブのログを追いかける。 そうでなければ、現在のスナップショットとそのカーソルを載せた event: reset を送り、そのカーソルから続ける。 カーソルのない接続は、「古すぎる」と同じ経路を通る。 EventSource と同じ規則(最後の ID を覚え、再接続で送る)に従うクライアントつきの、完全な実装です。テストで超過できるように、保持数は 50 件にしてあります。\nresume.mjs:サーバー、クライアント、障害を起こすエンドツーエンドのテスト // A resumable event stream in ~100 lines: cursor + bounded log + snapshot fallback. // Requires Node 18+. Run: node resume.mjs import http from \u0026#34;node:http\u0026#34;; const RETAIN = 50; // the server keeps only the last 50 events const log = []; // [{ seq, key, value }] const state = {}; // key -\u0026gt; { value, seq } (the \u0026#34;current\u0026#34; truth) let seq = 0; const write = (key, value) =\u0026gt; { // every state change appends to the log const ev = { seq: ++seq, key, value }; state[key] = { value, seq }; log.push(ev); if (log.length \u0026gt; RETAIN) log.shift(); return ev; }; const snapshot = () =\u0026gt; ({ seq, state: structuredClone(state) }); // (cursor, state) read together: no await in between const sse = (id, event, data) =\u0026gt; `id: ${id}\\nevent: ${event}\\ndata: ${JSON.stringify(data)}\\n\\n`; export const server = http.createServer((req, res) =\u0026gt; { res.writeHead(200, { \u0026#34;content-type\u0026#34;: \u0026#34;text/event-stream\u0026#34;, \u0026#34;cache-control\u0026#34;: \u0026#34;no-store\u0026#34; }); const header = req.headers[\u0026#34;last-event-id\u0026#34;]; let cursor = header === undefined ? null : Number(header); const oldest = log.length ? log[0].seq : seq + 1; // first sequence number still available if (cursor === null || !Number.isInteger(cursor) || cursor + 1 \u0026lt; oldest || cursor \u0026gt; seq) { const snap = snapshot(); // cannot resume: tell the client in-band, then continue from the snapshot res.write(sse(snap.seq, \u0026#34;reset\u0026#34;, snap)); cursor = snap.seq; } for (const ev of log) if (ev.seq \u0026gt; cursor) { res.write(sse(ev.seq, \u0026#34;put\u0026#34;, ev)); cursor = ev.seq; } const timer = setInterval(() =\u0026gt; { // tail the log for (const ev of log) if (ev.seq \u0026gt; cursor) { res.write(sse(ev.seq, \u0026#34;put\u0026#34;, ev)); cursor = ev.seq; } }, 5); req.on(\u0026#34;close\u0026#34;, () =\u0026gt; clearInterval(timer)); }); // ---- a minimal client that behaves like EventSource: remember the last id, send it back on reconnect ---- export async function follow(url, onEvent, { signal }) { let lastId = null; while (!signal.aborted) { try { const res = await fetch(url, { headers: lastId === null ? {} : { \u0026#34;last-event-id\u0026#34;: String(lastId) }, signal }); let buf = \u0026#34;\u0026#34;; for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) { buf += chunk; for (let i; (i = buf.indexOf(\u0026#34;\\n\\n\u0026#34;)) \u0026gt;= 0; ) { const raw = buf.slice(0, i); buf = buf.slice(i + 2); const ev = Object.fromEntries(raw.split(\u0026#34;\\n\u0026#34;).map((l) =\u0026gt; [l.slice(0, l.indexOf(\u0026#34;:\u0026#34;)), l.slice(l.indexOf(\u0026#34;:\u0026#34;) + 2)])); lastId = Number(ev.id); onEvent(ev.event, JSON.parse(ev.data)); } } } catch (e) { if (signal.aborted) return; } await new Promise((r) =\u0026gt; setTimeout(r, 20)); // reconnect delay (EventSource\u0026#39;s `retry`) } } if (import.meta.url === `file://${process.argv[1]}`) { await new Promise((r) =\u0026gt; server.listen(0, \u0026#34;127.0.0.1\u0026#34;, r)); const url = `http://127.0.0.1:${server.address().port}/`; const mine = {}; let expected = null, gaps = 0, dups = 0, resets = 0, applied = 0; const ac = new AbortController(); const done = follow(url, (type, d) =\u0026gt; { if (type === \u0026#34;reset\u0026#34;) { resets++; for (const k in mine) delete mine[k]; Object.assign(mine, d.state); expected = d.seq + 1; return; } if (expected !== null \u0026amp;\u0026amp; d.seq \u0026lt; expected) { dups++; return; } if (expected !== null \u0026amp;\u0026amp; d.seq \u0026gt; expected) gaps++; mine[d.key] = { value: d.value, seq: d.seq }; expected = d.seq + 1; applied++; }, { signal: ac.signal }); // Producer: 400 writes, sometimes in bursts. Chaos: kill every open connection now and then, // and once keep the client away for long enough that the log moves past its cursor. for (let i = 0; i \u0026lt; 400; i++) { write(`k${i % 7}`, i); if (i % 60 === 59) server.closeAllConnections(); // short outage: resume from the cursor if (i === 200) { server.closeAllConnections(); for (let j = 0; j \u0026lt; 120; j++) write(`k${j % 7}`, 1000 + j); } // long outage: \u0026gt; RETAIN events missed if (i % 5 === 0) await new Promise((r) =\u0026gt; setTimeout(r, 3)); } await new Promise((r) =\u0026gt; setTimeout(r, 300)); ac.abort(); await done; server.closeAllConnections(); server.close(); const same = JSON.stringify(Object.keys(state).sort().map((k) =\u0026gt; [k, state[k].value])) === JSON.stringify(Object.keys(mine).sort().map((k) =\u0026gt; [k, mine[k].value])); console.log(`server seq=${seq} applied=${applied} resets=${resets} gaps=${gaps} duplicates=${dups} client state equals server state: ${same}`); } 末尾のテストは、400 回の書き込みを行い、60 回ごとにすべての接続を切断し、さらに一度、クライアントを遠ざけたまま 120 件のイベントを書き込みます(保持数 50 を超えます)。\nserver seq=520 applied=354 resets=2 gaps=0 duplicates=0 client state equals server state: true resets=2 は、最初の接続(カーソルなし)と、長い不在です。短い中断はすべて、カーソルから再開しました。gaps=0 と duplicates=0 は、クライアントがイベントを適用するときにシーケンス番号について行うチェックです。3回の実行で、最終状態はそのたびに一致しました(切断のタイミングが変わるので、applied は 349 から 354 の間でした)。\n保持期間のチェックは、本当に効いているのでしょうか。サーバーから cursor + 1 \u0026lt; oldest の条件を取り除いて、もう一度実行しました。gaps=1 になりました。クライアントは、黙ってイベントを飛ばしたのです。その実行でも最終状態は一致しました。あとの書き込みが、失われた値をたまたま上書きしたからで、このバグがテストをすり抜けるのはそのためです。最終状態だけでなく、クライアントで欠落を数えてください。\nreset イベントは、クライアントが持っているものを捨て、スナップショットを据える場所でもあります。\nif (type === \u0026#34;reset\u0026#34;) { for (const k in mine) delete mine[k]; Object.assign(mine, d.state); expected = d.seq + 1; return; } 版4:スナップショットは組である スナップショットは「状態」ではなく、「カーソル C の時点の状態」で、その組であることが要点です。カーソルが状態を正しく表していなければ、カーソルから始める再生は、穴か繰り返しを残します。規則は、2つのものをロックなしで読むときと同じです。\nカーソルを先に、状態をあとに読む。再生を冪等にする。\nカーソルを先に読めば、状態はカーソル以上に新しいので、カーソルからの再生は、イベントを繰り返すことしかありません。再生が冪等(キーごとに、すでに持っているより新しくないものを無視して値を設定する)なら、繰り返しは無害です。状態を先に、カーソルをあとに読むと、カーソルが状態より新しくなりえて、その間のイベントは二度と再生されません。\nsnapshot_order.mjs // A snapshot is a pair (state, cursor). Read them in the wrong order and an update can vanish. // Run: node snapshot_order.mjs const rng = (a) =\u0026gt; () =\u0026gt; { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a \u0026gt;\u0026gt;\u0026gt; 15), 1 | a); t = (t + Math.imul(t ^ (t \u0026gt;\u0026gt;\u0026gt; 7), 61 | t)) ^ t; return ((t ^ (t \u0026gt;\u0026gt;\u0026gt; 14)) \u0026gt;\u0026gt;\u0026gt; 0) / 2 ** 32; }; function trial(order, rand) { const log = [], state = {}; let seq = 0; const write = () =\u0026gt; { const key = \u0026#34;k\u0026#34; + Math.floor(rand() * 3); log.push({ seq: ++seq, key, value: seq }); state[key] = { value: seq, seq }; }; const writes = (n) =\u0026gt; { for (let i = 0; i \u0026lt; n; i++) write(); }; writes(3); // Reading the state and reading the cursor are two steps. Writes can land between them (0 to 3 here). let snapState, cursor; if (order === \u0026#34;state, then cursor\u0026#34;) { snapState = structuredClone(state); writes(Math.floor(rand() * 4)); cursor = seq; } else { cursor = seq; writes(Math.floor(rand() * 4)); snapState = structuredClone(state); } writes(Math.floor(rand() * 2)); // and a few more after the snapshot // The client installs the snapshot, then replays every log entry after the cursor, // skipping entries that are not newer than what it already holds for that key. const mine = structuredClone(snapState); for (const ev of log) if (ev.seq \u0026gt; cursor \u0026amp;\u0026amp; ev.seq \u0026gt; (mine[ev.key]?.seq ?? 0)) mine[ev.key] = { value: ev.value, seq: ev.seq }; return JSON.stringify(Object.entries(mine).sort()) === JSON.stringify(Object.entries(state).sort()); } for (const order of [\u0026#34;state, then cursor\u0026#34;, \u0026#34;cursor, then state\u0026#34;]) { const rand = rng(7); let ok = 0; for (let i = 0; i \u0026lt; 1000; i++) ok += trial(order, rand) ? 1 : 0; console.log(`${order.padEnd(20)} client matches server in ${ok}/1000 trials`); } state, then cursor client matches server in 301/1000 trials cursor, then state client matches server in 1000/1000 trials 301 という数字は、僕の簡易な割り込み(2つの読み取りの間に 0〜3 回の書き込み、キーは3つ)に依存します。大事なのは、ゼロか、ゼロでないかの違いです。resume.mjs は別の方法でこの問題を避けています。snapshot() は、間に await を挟まず、同期的な1ステップで両方を読みます。これは、1つのデータベーストランザクションや、1つの一貫したスナップショットで読むことの、単一プロセスでの対応物です。\n設計を選ぶ 検討した代替案です。\n選択肢 向く場面 向かない場面 全部読み直す 小さな状態。修復の経路 大きな状態。頻繁な再接続 カーソル + 上限つきログ + スナップショット 多数のクライアントが見る、変化する状態 飛ばしたイベントが許されない厳密な監査 HTTP の Range リクエスト(RFC 9110 §14) バイト位置で指せるリソース:ファイル、追記専用の blob 「位置」がバイトオフセットではないイベントストリーム クライアントがすべてをメモリに保持する 短いセッション アプリが再起動するもの全般 レベルトリガーの再取得(無効化の記事を参照) キーで安く読み直せる状態 大きな履歴と、順序のあるイベント 再開の仕組みが壊れる場所 失敗 症状 防御 カーソルがログより古い 黙った欠落 サーバーで検出し、スナップショットつきの reset を送る(読めるクライアントには 410 でもよい)。 ネイティブの EventSource が non-200 を受ける 永久に停止し、ステータスも見えない 200 で応答し、ストリームの中で伝える。 スナップショットの状態をカーソルより先に読む 更新が消える カーソルを先に、状態をあとに。または一貫した1回の読み取り。冪等な再生。 シーケンス番号が順不同で見える イベントを飛ばす 見える順序を、割り当て順序に合わせる。 再生が冪等でない 重なった部分で重複する キーごとにバージョンを持ち、新しくないものは無視する。 クライアントのテストが最終状態だけを見る あとのイベントが失われた値を上書きするため、バグが生き残る クライアントでシーケンスの欠落を数える。 ログが無制限 メモリやディスクが増える N 件または T 分を保持し、保持期間を契約の一部にする。 障害のあとの再接続ストーム すべてのクライアントが同時にスナップショットを求める 再接続の待ち時間にジッターを入れる。retry フィールドは全クライアントに同じ固定値を与えるので、自前のクライアントでジッターを足す。 クライアントがカーソルの中身に意味を持たせる 符号化を変えられない カーソルは不透明にしておく。 NUL を含む id フィールド ブラウザがその id を無視するので、カーソルが進まない 単純な整数か、URL セーフなトークンにする。仕様は、値を NUL、LF、CR を含まない任意の UTF-8 文字列と説明している。 使う場面と使わない場面 カーソル + ログ + スナップショットを使うのは、多数のクライアントが変化する状態を見ていて、再接続が頻繁で、毎回状態全体を読み直すコストが無視できないときです。\n次の場合は使いません。\n履歴が小さいとき。版0には、気にするほどの失敗モードがありません。 キーごとの最新の値だけが要るとき。ヒントを受けて再取得するほうが単純で、コードの経路が1つで済みます。 すべてのイベントが法的に意味を持つとき。欠落を隠してしまうスナップショットのフォールバックは、適切な道具ではありません。永続的なログを持ち、「古すぎる」をクライアントが必ず処理すべきエラーにします。 試して、壊してみる # Node.js 18 以降(実行したのは Node.js 20)。eventsource_check.mjs 以外は依存関係なし。これは Chrome か Chromium が必要。 node resume.mjs # ランダムな切断 + 保持期間より長い不在 node snapshot_order.mjs # 2つの読み取りの順序が重要な理由 node visibility_gap.mjs # シーケンス番号が順番どおりに見えるべき理由 node eventsource_check.mjs # 実際のブラウザの挙動(必要なら CHROME=/path/to/chrome を設定) そして、壊してみてください。RETAIN を 5 にする、あるいは cursor + 1 \u0026lt; oldest の条件を消して、最後だけでなく、切断のたびに「クライアントのキーの集合がサーバーと等しい」というアサーションを加えます。\n3つの約束 再開できる性質は3つの約束でできていて、再開の仕組みを出荷する前に、3つすべてに答えられるようにしておくべきです。\n位置。 クライアントは何を持ち運び、それは不透明になっているか。SSE が標準化しているのはここです。 保持。 ログは、その位置をどれだけの期間守るか。これは、あなたが選んで書き残す数字です。 リセット。 位置が古すぎるとき、サーバーは何をするか。クライアントがそれを見逃せない形になっているか。多くの設計が欠いているのはここで、回復するストリームと、黙ってずれていくストリームの分かれ目です。 確認した範囲と、していない範囲 すべて Linux 6.12、Node.js 20.19、ヘッドレスの Google Chrome 154 で実行しました。ブラウザの挙動(再接続時の Last-Event-ID、non-200 のあとに再接続しないこと)は、その1つのブラウザで観察したもので、2026-10-04 時点の HTML Standard と一致しています。サーバーは、メモリ上のログを持つ単一のプロセスです。snapshot_order.mjs と visibility_gap.mjs はモデルで、データベースの実験ではありません。マルチノードのサーバー、永続的なログ、イベントストリームをバッファリングするプロキシ、ほかのブラウザは確認していません。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/resumable-streams-cursors-last-event-id-snapshots/","summary":"再接続したクライアントが、状態を取りこぼさず、重複せず、履歴を読み直さずに追いつく方法を、「全部読み直す」から「上限つきのログとスナップショットのフォールバック」まで、版0から版4まで段階的に組み立てます。ブラウザの EventSource が再接続時と non-200 応答のときに実際に何をするか、スナップショットの順序のバグ、決定木も扱います。","title":"カーソル・有限ログ・スナップショットで、再接続したイベントストリームを再開する"},{"content":"TL;DR メッセージを小さな write() 2回に分けて送り、そのあと返事を待つクライアントは、Linux で1往復ごとに約40ms止まることがあります。CPUは空いていて、パケットロスもありません。僕のサンドボックスでは、中央値が44msでした。同じバイト列を1回の write で送ると0.02msでした。 原因は2つのアルゴリズムの噛み合わせです。送信側の Nagleアルゴリズムは、最初の小さなセグメントにACKが返るまで、2つ目を溜めておきます。受信側の遅延ACKは、返信に相乗りさせたくてACKを遅らせます。ところが返信は、2つ目のセグメントが届かないと作れません。お互いに相手を待ち、遅延ACKのタイマーが切れるまで進みません。Linux ではこのタイマーの下限が40msです。 対処は、優先順に次のとおりです。メッセージを1回の write で送る(バッファに組み立てる、または writev / sendmsg を使う)。それが難しければ、書き込み側に TCP_NODELAY を設定します。最後の手段として、受信側の TCP_QUICKACK(Linux 固有で、再設定が必要)があります。 以下はすべて再現できます。短いスクリプトが2本あり、1本は時間を測り、もう1本はパケット単位のタイムラインを出します。 パズル:1往復は何msかかるか 小さなリクエストプロトコルを考えます。リクエストは4バイトのヘッダーと、それに続く8バイトの本文です。サーバーは12バイト揃ってから、2バイトの返事を返します。クライアントのコードは、ごく自然なものです。\nsock.sendall(HEADER) # 4 bytes sock.sendall(BODY) # 8 bytes reply = sock.recv(2) 両端は同じマシンのループバックにあります。ロスも輻輳もなく、サーバーは何も計算しません。1往復は何msかかるでしょうか。\n先を読む前に、予想してみてください。「数十マイクロ秒」と答えた方は多いはずですが、デフォルトの Linux ソケットでは、それは3桁ほど外れます。この記事の後半のスクリプトでは、次の結果になりました(Linux 6.12、Python 3.13、サンドボックス1台、50往復の中央値です。環境が違えば値も変わります)。\ntwo writes, Nagle on (default) median 44.00 ms one write, Nagle on (default) median 0.02 ms バイト列は同じで、変わったのは write() の回数だけです。さらに、このバグがコードレビューや一発きりのテストをすり抜ける理由がもう1つあります。接続した直後の1往復目は速いのです(後のトレースでは約0.3msでした)。止まり始めるのは、2回目のリクエストからです。\n4つの対処とそれぞれのコスト 対処 何をするか コストと注意点 1回の write にまとめる(バッファに組み立てる、または writev / sendmsg にバッファのリストを渡す) リクエスト全体が1セグメントで出ていきます。Nagle が溜める対象がなくなります。 書き込むコードを自分で触れる必要があります。ソケットオプションは不要で、移植性もあります。最初に試すならこれです。 書き込み側の TCP_NODELAY Nagle を無効にし、小さなセグメントもすぐ送ります。 細かい書き込みを続けると、小さなパケットが増えます。1セグメントにつき、オプションなしでも IPv4 と TCP のヘッダーで最低40バイトかかります。リクエスト/レスポンスなら問題になりにくく、バルク転送では無駄になります。 読み取り側の TCP_QUICKACK(Linux) 遅延させず、すぐACKを返します。 Linux 専用です。しかも永続しません。カーネルが遅延ACKのモードに戻ることがあるので、読み取りの前後で設定し直します。書き込み側を変えられないときに使えます。 書き込み側の TCP_CORK(Linux) uncork するまで部分的なフレームを溜め、まとめて送ります。 Linux 固有です。man ページによると、コークしておく時間には200msの上限があります。「ヘッダーを書いてから sendfile」には向きますが、普通のリクエスト/レスポンスには扱いにくい方式です。 Linux には tcp_autocorking(3.14以降、デフォルトで有効)もあります。前のパケットがqdiscやデバイスの送信キューに残っているときに、小さな書き込みをまとめる仕組みです(tcp(7))。これは Nagle とは別の仕組みです。サンドボックスのカーネルでは /proc/sys/net/ipv4/tcp_autocorking が 1 でしたが、停止は起きました。つまりこのケースは救ってくれません。\nランタイムによっては、すでに答えが選ばれています。Go の net パッケージは、デフォルトで no-delay です。libcurl も、7.50.2 以降は TCP_NODELAY がデフォルトです。Node.js の http サーバーは v18 から noDelay がデフォルトで true ですが、素の net.Socket は Nagle が有効な状態で始まります。僕のスクリプトの Python ソケットは、数字のとおり Nagle が有効なままです。どのデフォルトを引き継ぐかは、足場にしているライブラリ次第です。\n2つのアルゴリズムの仕組み Nagle:小さなセグメントは同時に1つまで RFC 896 は、1バイトのパケットでネットワークが溢れるのを防ぐために、このルールを導入しました。RFC 9293 §3.7.4 は次のように簡潔に書いています。\nIf there is unacknowledged data (i.e., SND.NXT \u0026gt; SND.UNA), then the sending TCP endpoint buffers all user data (regardless of the PSH bit) until the outstanding data has been acknowledged or until the TCP endpoint can send a full-sized segment (Eff.snd.MSS bytes).\nつまり、未確認のデータがなければ、小さな書き込みもすぐ出ます。小さなセグメントが未確認のまま飛んでいるあいだは、次の小さな書き込みは待たされます。実装は「SHOULD」(推奨)で、アプリケーションが接続ごとに Nagle を無効にできる手段は「MUST」(必須)です(RFC 1122 §4.2.3.4、RFC 9293 §3.7.4)。その手段が TCP_NODELAY です。\n遅延ACK:少し待てば、相乗りできるデータがあるかもしれない 受信側は、送信するデータに相乗りさせたり、2セグメントを1つのACKで済ませたりするために、ACKを遅らせられます。RFC 1122 §4.2.3.2 は、その遅延は「0.5秒未満でなければならない(MUST)」としています。RFC 5681 §4.2 も、最初の未確認パケットから500ms以内、かつ最大サイズのセグメント2つにつき少なくとも1回はACKを出すよう求めています。500msはあくまで上限です。Linux はもっと短いタイマーを使います。カーネルの include/net/tcp.h では、TCP_DELACK_MIN が HZ/25(40ms)、TCP_DELACK_MAX が HZ/5(200ms)です。タイトルの「40ms」は、この実装の定数です。RFC の数字ではありません。接続ごとの値は ss -i の ato:40 で見られます。\n組み合わさると止まる理由 標準もこの相互作用を把握しています。RFC 9293 は、「Nagle アルゴリズムと遅延ACKのあいだには、問題のある相互作用がありうる」と書いています。付録 A.3 では、一部のOSが実装している修正(IETF ドラフトで提案されたもの)も紹介しています。デフォルトのソケットでは、次の順に進みます。\nクライアントが4バイトのヘッダーを書きます。未確認のデータはないので、すぐ送られます。 クライアントが8バイトの本文を書きます。ヘッダーはまだ未確認で、本文は最大サイズに満たないので、Nagle が溜めます。 サーバーがヘッダーを受け取ります。返事は12バイト揃うまで作れないため、ACKを相乗りさせる送信データがなく、ACKを遅らせます。 遅延ACKのタイマー(約40ms)が切れてACKが出ます。クライアントの Nagle が本文を放出し、サーバーに12バイト揃って返事が出ます。 どちらも誤動作していません。どちらか一方を外せば、停止は消えます。\n中を見る:計測とパケットトレース 計測 次のスクリプトは、4つの変種に加えて、受信側 TCP_QUICKACK の変種も含めて1往復の時間を測ります。サーバーは、12バイトのメッセージが揃ってから返事をします。\n#!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34;Write-write-read over TCP: why does the reply take ~40 ms? The server answers only after it has received a full 12-byte message (4-byte header + 8-byte body). The client sends the message either as two small writes or as one. Run it on any Linux box and compare the medians. \u0026#34;\u0026#34;\u0026#34; import socket, threading, time, statistics, sys HEADER, BODY = b\u0026#34;HEAD\u0026#34;, b\u0026#34;BODYBODY\u0026#34; N = 50 def server(listener, quickack): conn, _ = listener.accept() while True: got = b\u0026#34;\u0026#34; while len(got) \u0026lt; len(HEADER) + len(BODY): if quickack: # not permanent (tcp(7)): re-arm before every wait conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_QUICKACK, 1) chunk = conn.recv(4096) if not chunk: return got += chunk conn.sendall(b\u0026#34;ok\u0026#34;) def run(label, *, nodelay, send, quickack=False): listener = socket.socket() listener.bind((\u0026#34;127.0.0.1\u0026#34;, 0)) listener.listen() threading.Thread(target=server, args=(listener, quickack), daemon=True).start() c = socket.create_connection(listener.getsockname()) if nodelay: c.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) samples = [] for _ in range(N): t0 = time.perf_counter() send(c) c.recv(2) samples.append((time.perf_counter() - t0) * 1000) c.close() print(f\u0026#34;{label:\u0026lt;34} median {statistics.median(samples):8.2f} ms max {max(samples):8.2f} ms\u0026#34;) two_writes = lambda c: (c.sendall(HEADER), c.sendall(BODY)) one_write = lambda c: c.sendall(HEADER + BODY) gather = lambda c: c.sendmsg([HEADER, BODY]) # writev-style: one syscall, one segment print(sys.platform, \u0026#34;-\u0026#34;, \u0026#34;n =\u0026#34;, N, \u0026#34;round trips per row\u0026#34;) run(\u0026#34;two writes, Nagle on (default)\u0026#34;, nodelay=False, send=two_writes) run(\u0026#34;two writes, TCP_NODELAY\u0026#34;, nodelay=True, send=two_writes) run(\u0026#34;one write, Nagle on (default)\u0026#34;, nodelay=False, send=one_write) run(\u0026#34;sendmsg([hdr, body]), Nagle on\u0026#34;, nodelay=False, send=gather) run(\u0026#34;two writes, receiver TCP_QUICKACK\u0026#34;, nodelay=False, send=two_writes, quickack=True) サンドボックスでの1回の実行結果です(Linux 6.12、Python 3.13、ループバック)。絶対値はカーネルやタイマーの設定、負荷で変わります。注目してほしいのは、差と、それが消える場所です。\nlinux - n = 50 round trips per row two writes, Nagle on (default) median 44.00 ms max 48.04 ms two writes, TCP_NODELAY median 0.02 ms max 0.08 ms one write, Nagle on (default) median 0.02 ms max 0.07 ms sendmsg([hdr, body]), Nagle on median 0.02 ms max 0.05 ms two writes, receiver TCP_QUICKACK median 0.02 ms max 0.07 ms この表から3つ読み取れます。書き込み側の TCP_NODELAY で停止が消えます。同じバイト列を sendall や sendmsg 1回で送れば、ソケットオプションに触れなくても消えます。受信側の TCP_QUICKACK でも、反対側から消せます。(スクリプトの初期版では、TCP_NODELAY をサーバー側のソケットに付けていましたが、何も変わりませんでした。溜められているのはクライアントのセグメントだからです。オプションは、小さな断片を書く側に付けます。)\nパケットトレース 遅延の表からは、停止していることは分かりますが、理由は分かりません。理由はトレースで見えます。使い捨てのユーザー名前空間とネットワーク名前空間(unshare -Urn。本物のroot権限は要りません)の中で、lo に raw の AF_PACKET ソケットを開けば、送信される TCP セグメントすべてにタイムスタンプを付けられます。\n#!/usr/bin/env python3 # Run: unshare -Urn python3 nagle_trace.py (needs CAP_NET_RAW inside a throwaway network namespace) import socket, struct, subprocess, threading, time, sys subprocess.run([\u0026#34;ip\u0026#34;,\u0026#34;link\u0026#34;,\u0026#34;set\u0026#34;,\u0026#34;lo\u0026#34;,\u0026#34;up\u0026#34;],check=True) sniff = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(3)); sniff.bind((\u0026#34;lo\u0026#34;,0)) events=[]; t0=None def run_sniff(): while True: raw,addr=sniff.recvfrom(65535) if addr[2]!=4: continue # lo shows every packet twice; keep PACKET_OUTGOING p=raw[14:] if len(p)\u0026lt;40 or p[9]!=6: continue ihl=(p[0]\u0026amp;15)*4; sp,dp,seq,ack,off,fl=struct.unpack(\u0026#34;!HHIIBB\u0026#34;,p[ihl:ihl+14]); plen=len(p)-ihl-(off\u0026gt;\u0026gt;4)*4 if fl\u0026amp;2: continue events.append((time.perf_counter(),sp,dp,fl,plen)) threading.Thread(target=run_sniff,daemon=True).start() H,B=b\u0026#34;HEAD\u0026#34;,b\u0026#34;BODYBODY\u0026#34; def server(l): c,_=l.accept() while True: got=b\u0026#34;\u0026#34; while len(got)\u0026lt;12: d=c.recv(4096) if not d: return got+=d c.sendall(b\u0026#34;ok\u0026#34;) l=socket.socket(); l.bind((\u0026#34;127.0.0.1\u0026#34;,0)); l.listen(); sport=l.getsockname()[1] threading.Thread(target=server,args=(l,),daemon=True).start() c=socket.create_connection(l.getsockname()) for i in range(4): # a few rounds: the first is in quick-ack mode time.sleep(0.2); events.clear() t=time.perf_counter(); c.sendall(H); c.sendall(B); c.recv(2); dt=(time.perf_counter()-t)*1000 time.sleep(0.1) print(f\u0026#34;--- round {i+1}: reply after {dt:.1f} ms\u0026#34;) for (ts,sp,dp,fl,pl) in list(events): who = \u0026#34;client-\u0026gt;server\u0026#34; if dp==sport else \u0026#34;server-\u0026gt;client\u0026#34; kind = f\u0026#34;{pl} B data\u0026#34; if pl else \u0026#34;pure ACK\u0026#34; print(f\u0026#34; +{(ts-t)*1000:7.2f} ms {who} {kind}\u0026#34;) 1つの接続で4往復します。最初の2往復の出力は次のとおりです。\n--- round 1: reply after 0.3 ms + 0.19 ms client-\u0026gt;server 4 B data + 0.29 ms server-\u0026gt;client pure ACK + 0.34 ms client-\u0026gt;server 8 B data + 0.34 ms server-\u0026gt;client pure ACK + 0.35 ms server-\u0026gt;client 2 B data + 0.36 ms client-\u0026gt;server pure ACK --- round 2: reply after 44.1 ms + 0.20 ms client-\u0026gt;server 4 B data + 43.91 ms server-\u0026gt;client pure ACK + 43.94 ms client-\u0026gt;server 8 B data + 43.95 ms server-\u0026gt;client pure ACK + 44.03 ms server-\u0026gt;client 2 B data + 44.04 ms client-\u0026gt;server pure ACK 1往復目は速く、受信側がすぐACKを返し、ヘッダーの0.15ms後に本文が出ます。2往復目では、ヘッダーが+0.20msに出たあと、約44ms何も起きません。その後にサーバーからの純粋なACKが届き、そのあとで初めてクライアントが本文を送ります。クライアントの2回目の write() はすぐ返っていて、カーネルがデータを保留していただけです。同じ実行の3、4往復目も、2往復目と同じ形でした。\n1往復目が速い理由はこうです。受信側は、接続で最初にデータを受け取ったとき、quick-ACK モードで始まり、最初のセグメントにはすぐACKを返します。カーネルは最初のデータパケットが届いたときにこれを初期化します(net/ipv4/tcp_input.c の tcp_event_data_recv)。やり取りが対話的に見えてくると、遅延ACKに切り替わります。効果は観測し、コードの経路も読みましたが、カーネルのどの遷移で quick-ACK モードが終わるのかまでは追っていません。ここは「矛盾しない」という説明であり、「証明した」ではありません。\nどの対処をいつ使うか 小さなメッセージのリクエスト/レスポンスプロトコル: 1メッセージを1つのバッファに組み立て、1回で書きます。遅延が重要な経路で、別の場所に2回目の write がないと言い切れないなら、TCP_NODELAY も付けておきます。 書き込みをフレームワークやライブラリが行い、変更できない: すでに no-delay になっているか確認します(上のデフォルトの一覧を参照)。なっていなければ、そのライブラリが使うソケットに TCP_NODELAY を設定します。読み取り側しか触れないなら、TCP_QUICKACK はつなぎの手段です。 大きな書き込みによる、一方向のバルク転送: Nagle には手を付けません。最大サイズのセグメントは溜められませんし、直すべき小さな書き込みのパターンもありません。 原因を診断せずに TCP_NODELAY で遅さを直そうとしないでください。 遅延が40ms付近(最大200ms)に集まっていなければ、この問題ではありません。 停止が戻ってくるパターン TCP_NODELAY を付けたうえで、細かい書き込みを続ける。 停止は消えますが、write() 1回ごとに別のセグメントになり、数バイトのペイロードに40バイト以上のヘッダーが付くことがあります。症状: データ量の割にパケット数が多い。対処: ソケットの前にバッファ付きライタを置き、メッセージごとに1回 flush します。 別のレイヤーが2回に分けて書く。 自分は1つのバッファを書いても、下のライブラリがヘッダーと本文を別々に出すことがあります。症状: まとめたのに停止が戻る。対処: システムコールの順序を確認し(例:strace -e trace=write,sendto,sendmsg。ここでは使っていません)、そのレイヤーの設定を直します。 オプションを付ける側を間違える。 TCP_NODELAY が効くのは、溜められたデータを書く側です。読み取り側に付けても、この停止には効きません(試しました)。 TCP_QUICKACK を1回設定して終わり。 カーネルは quick-ACK モードを再び抜けることがあるので、accept() の後に setsockopt を1回呼ぶだけでは直りません。読み取りのたびに設定し直します。Linux 専用でもあります。 問題を隠してしまうベンチマーク。 リクエスト1回だけの計測や、ほぼ1往復目だけのウォームアップでは速く見えます。1つの接続で、連続した多数の往復を測ってください。 サーバーが遅いと誤認する。 症状: CPUは空いているのに、遅延が特定の値に張り付く。対処: アプリケーションのプロファイルではなく、タイムラインを見ます。 手元で再現する 1つ目のスクリプトを nagle_demo.py として保存し、Linux で python3 nagle_demo.py を実行します。1行だけ他より約40ms大きく、それが2回書き込みの行になるはずです。 2つ目のスクリプトを unshare -Urn python3 nagle_trace.py で実行します(特権なしのユーザー名前空間が必要です)。4バイトのセグメントと8バイトのセグメントのあいだの空白を探してください。 N を500に上げて、最初の行が約20秒かかるようにし、そのあいだ別の端末で ss -tin dst 127.0.0.1 を実行します。デモの接続の2つのソケットで ato:40 を探してください。 サーバーを別のホストに移して比べます。これは試していません。ループバックと実際のリンクは仕組みを共有しますが、数字が同じとは限りません。 確認したことと、していないこと 2本のスクリプトを Linux 6.12 のサンドボックス1台で実行し、上の数字を出しました。計測を3回実行したところ、2回書き込みの行の中央値は44.00、44.00、43.99ms(1ms未満の行は、実行ごとに0.02〜0.04msの間で動きました)でした。RFC の該当箇所、tcp(7)、上でリンクしたカーネルの定数は、原文で確認しています。ほかのカーネル、ほかのOS、実際のネットワーク回線、TLS は試していません。\n1メッセージ、1回の write Nagle は、未確認のデータがあるあいだ小さな書き込みを保留し、遅延ACKはACKを保留します。小さな書き込みを2回行うリクエスト/レスポンスでは、2つがタイマーが切れるまで待ち合います。Linux ではこのタイマーが最低40msです。接続の最初のリクエストは速いことがあるので、一発きりのテストでは気づけません。根本的な対処は構造で解決することで、1メッセージを1回の write で送ることです。実用的な保険は書き込み側の TCP_NODELAY、最後の手段は受信側の TCP_QUICKACK です。この記事から診断を1つだけ持ち帰るなら、これにしてください。空いているマシンで遅延が決まった値に集まっているなら、原因は負荷ではなくタイマーです。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/two-small-writes-nagle-delayed-ack/","summary":"ヘッダーと本文を小さな write() 2回に分けて送り、返事を待つと、CPUもネットワークも空いているのに、1往復ごとに約40ms止まることがあります。Nagleアルゴリズムも遅延ACKも、単体では正しい仕様です。2つが組み合わさると、タイマーが切れるまで互いに待ち合ってしまいます。この記事では、停止を予想し、50行のスクリプトで再現し、パケットトレースで確かめ、4つの対処を比べます。","title":"write() を2回呼ぶだけで、TCPが40ms止まる"},{"content":"TL;DR リフレッシュトークンのローテーションでは、更新のたびに新しいリフレッシュトークンが返り、古いものは無効になります。無効になったトークンが再び提示されても、サーバーには盗んだ側か正規のクライアントか区別できません。そのため RFC 9700 §4.14.2 のとおり、有効なトークンを失効させ、ユーザーは認可をやり直すことになります。 複数のリクエストを並行して送り、複数の 401 を受け取り、401 ごとに更新するクライアントは、同じリフレッシュトークンを何度も提示します。成功するのは最初の1回だけで、残りは再利用(リプレイ)に見えます。後で示す疑似サーバーでは、素朴なクライアントは実行するたびにトークンのファミリーが失効しました。 クライアント側の対処は single-flight 更新です。すべての呼び出し元が、共有する1回の更新を待ちます。遅れて届いた 401 が2回目の更新を始めないように stale check を足し、各リクエストの再試行は最大1回にします。 サーバー側の対処は、直前のトークンを短時間だけ受け付ける猶予期間です。ただしこれはトレードオフがある対処で、無料の解決策ではありません。 デモは依存ライブラリなしの Node で93行です。失敗するケースと、3つの対処を並べて出力します。 背景:ローテーションは何を守り、何を代償にするか モバイルアプリやシングルページアプリのような公開クライアントは、秘密を保持できません。そのため、端末にあるリフレッシュトークンは盗む価値があります。RFC 9700 §4.14.2 は、認可サーバーが公開クライアントについて、悪意ある者によるリフレッシュトークンのリプレイを検出する方法を、次のどちらか一方は必ず使う(MUST)としています。送信者制約付きリフレッシュトークン(RFC 8705 または RFC 9449)か、リフレッシュトークンのローテーションです。\nRFC が説明するローテーションは次のとおりです。サーバーは更新のたびに新しいリフレッシュトークンを発行し、前のものを無効にしつつ、両者の関係は記録しておきます。トークンが盗まれ、攻撃者と正規のクライアントの両方が使うと、どちらかは無効になったトークンを提示します。RFC は代償もはっきり書いています。認可サーバーは、無効なトークンを提示したのがどちらか判断できませんが、有効なリフレッシュトークンを失効させます。これで攻撃は止まりますが、正規のクライアントは新しい認可グラントを取り直さなければなりません。\nここで、ごく普通のクライアントを想像してください。アプリがバックグラウンドにいるあいだにアクセストークンが期限切れになりました。ユーザーが画面を開くと、API 呼び出しが3つ同時に飛びます。3つとも期限切れのアクセストークンで送られ、3つとも 401 が返ります。各 401 のハンドラーが「更新して、再試行する」と考えると、同じリフレッシュトークンを持つ更新リクエストが3つ送られます。サーバーから見れば、正しい使用が1回、リプレイが2回です。警戒するのは正しい動作で、盗難と見分けがつきません。\nまず壊してみる 次が「当たり前」のハンドラーです(後のデモでは naive という戦略として出てきます)。\nlet r = await send(accessToken); if (r.status === 401) { await refresh(); // 並行する 401 がすべてここに来る r = await send(accessToken); } ローテーションと再利用検知を持つ疑似サーバーに対して、3つの並行呼び出しは次のように終わります(出力は実際のものです。どのリクエストが成功するかは、実行ごとに変わります)。\nnaive: every 401 refreshes | burst: ERR,200,ERR | POST /token: 3 | after next expiry: ERR (refresh failed: 400) 左から読んでください。1つの呼び出しが成功し、2つが失敗しました。更新リクエストは3つ送られました。しかも被害はあとから出ます。新しいアクセストークンが次に期限切れになったとき、クライアントのリフレッシュトークンはファミリーごと失効していて、更新は 400 を返し、ユーザーはログアウトされます。失敗が遅れて、間欠的に、タイミング次第で起きます。高速なマシンで1リクエストずつ試すテストをすり抜けるのは、そのためです。\n検討した選択肢 方式 内容 トレードオフ single-flight 更新(クライアント) 並行する呼び出し元が、実行中の1回の更新を共有します。 クライアントだけで完結します。基本はこれです。対象は1プロセスのみです(失敗パターンを参照)。 更新中はリクエストを待機させる(クライアント) 更新が動いているあいだ、新しいリクエストは古いトークンで送らずに待ちます。 失敗が確定している 401 の波を避けられます。キューが増え、詰まる場所もできます。single-flight の代わりではなく、補完です。 期限前の先回り更新(クライアント) トークンの有効期間から、少し早めに更新します。 401 は減りますが、ゼロにはなりません。停止中やスロットリングされたプロセスではタイマーが発火する保証がなく、時計もずれます。401 の経路は結局必要です。 猶予期間(サーバー) ローテーション直後のトークンを、短時間だけ有効にします。 並行実行と応答の喪失を吸収できます。代わりに、リプレイを許す時間がその分だけ延びます。長さを設定できるプロバイダーもあります。バグ修正ではなく、脅威モデルの判断です。 送信者制約付きトークン(サーバーとクライアント) トークンを鍵に結び付け、別の者によるリプレイを構造的に失敗させます(RFC 9449、RFC 8705)。 RFC 9700 の要件のもう一方の選択肢です。変更が大きいので、この記事ではローテーションに絞ります。 クライアントしか運用していないなら、答えは single-flight と1回きりの再試行です。サーバーも運用しているなら、猶予期間は意図して決め、理由を書き残してください。\nsingle-flight の仕組み single-flight は小さな発想です。結果ではなく、Promise を保存します。\nlet inflight = null; const refreshOnce = () =\u0026gt; (inflight ??= doRefresh().finally(() =\u0026gt; { inflight = null; })); 最初の呼び出し元は inflight が空なので更新を始め、その Promise を保存します。それが完了する前に来た呼び出し元は、同じ Promise を受け取って待ちます。finally は成功でも失敗でもスロットを空にするので、失敗した更新が次の試行を巻き込むことはありません。JavaScript はシングルスレッドなので、確認と保存の間に割り込みは入りません。マルチスレッドの言語では、ミューテックスなどの仕組みが必要です。\nただ、これだけでは穴が1つ残ります。古いアクセストークンで送られたものの、共有された更新が終わった後に 401 が届いたリクエストは、inflight が空なので2回目の更新を始めてしまいます。最新のリフレッシュトークンを使うのでリプレイにはなりませんが、無駄なローテーションです。stale check がこれを塞ぎます。各リクエストが使ったアクセストークンを覚えておき、401 が届いたら現在のトークンと比べます。違っていれば、すでに誰かが更新済みなので、更新せず再試行だけします。\nif (state.access !== tokenThatFailed) return; // 他の誰かがすでに更新した あと2つルールがあります。再試行は1回だけにします。再試行したリクエストがまた 401 なら、ループせずエラーを返します。また、新しいアクセストークンと新しいリフレッシュトークンは、ひとまとまりとして一緒に保存します。1つの応答に両方が入っているからです。\nデモ:疑似サーバーと4つの戦略 スクリプトは2つの部分でできています。前半は、RFC 9700 が説明する挙動(ローテーション、ファミリーを失効させる再利用検知、任意の猶予期間)を持つ疑似サーバーです。後半は、テスト対象のクライアントで、401 への反応が3種類あります。各バーストの3つ目のリクエストはわざと遅くしてあり、最初の更新が終わった後に 401 が届きます。refresh_demo.mjs として保存し、node refresh_demo.mjs で実行してください(Node 18 以降、パッケージ不要です)。\n// Refresh-token rotation vs. concurrent 401s. Run: node refresh_demo.mjs (Node \u0026gt;= 18, no dependencies) import http from \u0026#39;node:http\u0026#39;; // ---- A toy authorization/resource server: rotation + reuse detection (+ optional grace window) ---- function makeServer({ graceMs = 0 } = {}) { const s = { seq: 0, access: new Set(), refresh: new Map(), used: new Map(), revoked: new Set(), tokenCalls: 0 }; const issue = (family) =\u0026gt; { const n = ++s.seq; s.access.add(`a${n}`); s.refresh.set(`r${n}`, family); return { access: `a${n}`, refresh: `r${n}` }; }; s.first = () =\u0026gt; { const t = issue(\u0026#39;family-1\u0026#39;); s.access.delete(t.access); return t; }; // access token starts out expired const srv = http.createServer((req, res) =\u0026gt; { let body = \u0026#39;\u0026#39;; req.on(\u0026#39;data\u0026#39;, (d) =\u0026gt; (body += d)); req.on(\u0026#39;end\u0026#39;, async () =\u0026gt; { await new Promise((r) =\u0026gt; setTimeout(r, Number(req.headers[\u0026#39;x-delay\u0026#39;] ?? 20))); // network + processing if (req.url === \u0026#39;/api\u0026#39;) { const token = (req.headers.authorization ?? \u0026#39;\u0026#39;).replace(\u0026#39;Bearer \u0026#39;, \u0026#39;\u0026#39;); res.statusCode = s.access.has(token) ? 200 : 401; return res.end(\u0026#39;{}\u0026#39;); } s.tokenCalls++; // POST /token const { refresh } = JSON.parse(body); const family = s.refresh.get(refresh); if (family \u0026amp;\u0026amp; !s.revoked.has(family)) { // normal rotation s.refresh.delete(refresh); s.used.set(refresh, { family, at: Date.now() }); return res.end(JSON.stringify(issue(family))); } const old = s.used.get(refresh); if (old \u0026amp;\u0026amp; Date.now() - old.at \u0026lt; graceMs \u0026amp;\u0026amp; !s.revoked.has(old.family)) { // grace window return res.end(JSON.stringify(issue(old.family))); } if (old) s.revoked.add(old.family); // reuse detected: revoke everything res.statusCode = 400; res.end(\u0026#39;{\u0026#34;error\u0026#34;:\u0026#34;invalid_grant\u0026#34;}\u0026#39;); }); }); s.listen = () =\u0026gt; new Promise((ok) =\u0026gt; srv.listen(0, \u0026#39;127.0.0.1\u0026#39;, () =\u0026gt; { s.url = `http://127.0.0.1:${srv.address().port}`; ok(); })); s.close = () =\u0026gt; srv.close(); return s; } // ---- The client under test: three ways to react to a 401 ---- function makeClient(server, strategy, tokens) { const state = { ...tokens }; // { access, refresh } let inflight = null; // the one refresh currently running, if any async function doRefresh() { const r = await fetch(server.url + \u0026#39;/token\u0026#39;, { method: \u0026#39;POST\u0026#39;, body: JSON.stringify({ refresh: state.refresh }) }); if (!r.ok) throw new Error(\u0026#39;refresh failed: \u0026#39; + r.status); Object.assign(state, await r.json()); // access AND refresh token are replaced together } function refresh(tokenThatFailed) { if (strategy === \u0026#39;naive\u0026#39;) return doRefresh(); // every 401 refreshes if (strategy === \u0026#39;single-flight+stale-check\u0026#39; \u0026amp;\u0026amp; state.access !== tokenThatFailed) return Promise.resolve(); // someone already renewed it return (inflight ??= doRefresh().finally(() =\u0026gt; { inflight = null; })); // join the refresh in progress } return { state, async call(delay = 20) { const send = (token) =\u0026gt; fetch(server.url + \u0026#39;/api\u0026#39;, { headers: { authorization: \u0026#39;Bearer \u0026#39; + token, \u0026#39;x-delay\u0026#39;: String(delay) } }); const used = state.access; let r = await send(used); if (r.status !== 401) return r.status; await refresh(used); // may throw return (await send(state.access)).status; // retry exactly once }, }; } async function scenario(title, strategy, opts) { const server = makeServer(opts); await server.listen(); const client = makeClient(server, strategy, server.first()); // three requests start with the expired token; the third is slow, so its 401 arrives after the refresh finished const results = await Promise.allSettled([client.call(), client.call(), client.call(150)]); const burst = results.map((r) =\u0026gt; (r.status === \u0026#39;fulfilled\u0026#39; ? r.value : \u0026#39;ERR\u0026#39;)).join(\u0026#39;,\u0026#39;); const tokenCalls = server.tokenCalls; server.access.clear(); // later, the new access token expires too const next = await client.call().then(String, (e) =\u0026gt; `ERR (${e.message})`); console.log(`${title.padEnd(32)} | burst: ${burst.padEnd(11)} | POST /token: ${tokenCalls} | after next expiry: ${next}`); server.close(); } await scenario(\u0026#39;naive: every 401 refreshes\u0026#39;, \u0026#39;naive\u0026#39;); await scenario(\u0026#39;naive + 2 s server grace window\u0026#39;, \u0026#39;naive\u0026#39;, { graceMs: 2000 }); await scenario(\u0026#39;single-flight\u0026#39;, \u0026#39;single-flight\u0026#39;); await scenario(\u0026#39;single-flight + stale check\u0026#39;, \u0026#39;single-flight+stale-check\u0026#39;); 実行例です。\nnaive: every 401 refreshes | burst: ERR,200,ERR | POST /token: 3 | after next expiry: ERR (refresh failed: 400) naive + 2 s server grace window | burst: 200,200,200 | POST /token: 3 | after next expiry: 200 single-flight | burst: 200,200,200 | POST /token: 2 | after next expiry: 200 single-flight + stale check | burst: 200,200,200 | POST /token: 1 | after next expiry: 200 4行は次のように読みます。\nnaive:更新リクエストが3つ、リプレイが2つ。ファミリーが失効するので、次の期限切れが 400 で終わります。(別の実行では、成功するリクエストが別のものになりました。) naive + 猶予期間:更新リクエストは相変わらず3つですが、サーバーが2秒間はリプレイを許すので、全員が成功します。コストはサーバー側にあり、リプレイを許す時間の延長として現れます。 single-flight:最初の2つの 401 が1回の更新を共有します。遅い3つ目の 401 はあとで届き、2回目の更新を始めます。有効ですが不要です。 single-flight + stale check:更新はちょうど1回です。実際に使う形はこれです。 疑似サーバーは僕が作ったもので、実在するプロバイダーのものではありません。示しているのは仕組みであって、特定のベンダーの挙動ではありません。\nsingle-flight が効く範囲 サーバーがリフレッシュトークンをローテーションし、アプリが複数のリクエストを同時に飛ばせるなら、single-flight を使います。 ほとんどのアプリが該当します。 ローテーションがなければ、上の失敗は起きないので、single-flight は正しさの修正ではなく最適化(呼び出しの削減)です。それでも安上がりです。 複数のプロセスやコンテキストが1つのリフレッシュトークンを共有している場合は、これだけでは足りません。 次の節を見てください。 クライアントのバグを隠すためだけに猶予期間を足すのはやめてください。 まずクライアントを直し、クライアント側では直せない失敗(応答の喪失)のために猶予期間を残します。 修正したあとも残る失敗 タブ、ワーカー、プロセスが複数ある。 それぞれが独自の inflight を持つので、お互いのトークンをリプレイします。症状: アプリを2つ開いたときや、バックグラウンド処理が走るときだけ、ランダムにログアウトされる。対処: 「トークンを読む、更新する、トークンを書く」を、コンテキストをまたぐロックで囲みます。ブラウザでは、Web Locks API で、同一オリジンの複数のタブやワーカーのスクリプトが協調できます。例:navigator.locks.request('token-refresh', async () =\u0026gt; { /* 保存済みトークンを読み直し、まだ古い場合だけ更新する */ })。このスニペットは、ブラウザが必要なため、サンドボックスでは実行していません。それ以外の環境では、トークンの保存先を1つのコンポーネントだけが所有するようにします。 更新の応答が失われる。 サーバーはローテーションしたのに、クライアントが新しいトークンを受け取れなかった場合(タイムアウトやプロセスの強制終了)です。次の更新では古いトークンを提示し、リプレイに見えます。症状: 不安定なネットワークでのログアウト。対処: これはローテーションに内在する問題で、プロバイダーは猶予期間で対処しています。また、新しいトークンは使う前に永続化してください。 更新エラーなら何でもログアウトさせる。 タイムアウトや 5xx は、グラントの失効ではありません。RFC 6749 §5.2 は、リフレッシュトークンが「invalid, expired, revoked」(無効、期限切れ、失効)のときのエラーとして invalid_grant を定義しています。対処: invalid_grant のときだけログアウトし、ネットワークエラーはバックオフ付きで再試行します。 本文を再送できないリクエストを再試行する。 fetch の Request の本文は1回限りで、本文を使用済みだと clone() は例外を投げます。対処: 使用済みのオブジェクトを再利用せず、試行のたびにデータからリクエストを組み立てます。 際限のない再試行。 永続的に拒否されるトークンは、更新のループに入ります。対処: リクエストごとに再試行は1回までです。 stale check で比べる値を間違える。 比べるのは、失敗したリクエストが使ったトークンです。コードを読んだ時点で現在のトークンではありません。デモでは、送信前に const used = state.access で控えています。 デモをわざと壊す node refresh_demo.mjs を何度か実行します。naive の行は毎回失敗し、変わるのは成功するリクエストがどれかだけのはずです。 makeClient の stale check の行を削除すると、single-flight の行の POST /token が1から2に増えることを確認できます。 2つ目のシナリオの猶予期間({ graceMs: 2000 })を 0 にすると、また失敗します。僕の実行では、1 はときどき通る程度で、5 と 50 は試した3回とも通りました。実際のリプレイはネットワーク遅延で散らばるので、短い窓は見かけほど守ってくれません。窓の長さは、ローカルのテストではなく、吸収したい失敗から決めてください。 遅い呼び出し client.call(150) を client.call(20) に変えます。3つ目の 401 が更新の完了前に届くので、素の single-flight でも更新は1回で済み(3回の実行で1回でした)、stale check の有無は結果に影響しません。stale check が効くのは、遅れて届く 401 だけです。 実行したことと、していないこと スクリプトを5回実行して、naive の行は毎回失敗し、single-flight と stale check の行は毎回成功して、更新リクエストはそれぞれ2回と1回でした。疑似サーバーは RFC 9700 §4.14.2 の記述に沿っています。実在のIDプロバイダー、モバイルOS、ブラウザは試していません。Web Locks のスニペットも実行していません。\n出荷するときの規則 ローテーションは、リフレッシュトークンの再利用を警報にします。並行する 401 は、正規のクライアントにその警報を鳴らさせます。 並行する呼び出し元で1回の更新を共有し(Promise を保存する)、トークンを比べて遅れた更新を省き、再試行は1回にします。 サーバーを管理しているなら、猶予期間は応答の喪失に備えた、意図的で範囲が限られた譲歩です。クライアントの並行性の修正ではありません。 single-flight はプロセス内の仕組みです。複数のタブやプロセスがあるなら、コンテキストをまたぐロックか、トークン保存先の単一の所有者を足してください。 ","permalink":"https://blog.yusukeikoma.com/ja/posts/refresh-token-rotation-concurrent-requests/","summary":"リフレッシュトークンのローテーションは、使用済みトークンの再利用を盗難の警報として扱います。期限切れのアクセストークンで3つのリクエストを同時に送り、401ごとに更新するクライアントは、同じリフレッシュトークンを3回提示して、自分で警報を鳴らします。この記事では、素朴なクライアントをあえて壊し、single-flight と stale check で直します。そのうえで、サーバー側の猶予期間、リクエストの待機、先回り更新、送信者制約付きトークンを比べます。","title":"リフレッシュトークンをローテーションすると、並行リクエストでログアウトされることがある"},{"content":"タイムアウトが答えるのは、「期限までに返事が届いたか」という1つの問いだけです。リクエストが実行されたかどうかは、何も教えてくれません。注文に商品を1つ追加するリクエストを送り、30ミリ秒待っても何も返ってこなかったとき、その結果と矛盾しない経緯が3通りあります。\nTL;DR タイムアウトは3つの世界と矛盾しません。リクエストが届かなかった、リクエストは届いて適用されたが応答が失われた、リクエストはまだ処理中なのにこちらが早く待つのをやめた。送り手はこれらを区別できず、信頼できないリンクの上のどんなプロトコルも、区別できるようにはできません(Two Generals 問題)。 だから「ちょうど1回の配送」は、リンクに後から足せる性質ではありません。作れるのは、少なくとも1回の配送と、重複を除く受け手の組み合わせで、これは自分が制御できる境界の内側での、ちょうど1回の効果になります。 そのための仕組みが**冪等キー(idempotency key)**です。クライアントが意図ごとに1回、最初の送信の前に決め、再試行のたびに同じものを使い、サーバーは効果と原子的に記録します。 15% のリクエストが失われ、クライアントのタイムアウトがハンドラーの処理時間より短いことがある環境で試しました。キーなしの再試行は、約半数を重複させました。キーつきの再試行は、重複がゼロでした。効果のあとにキーを記録する実装は、その変種を1回実行したところ、200件中89件を重複させました。 再試行には、ジッターつきのバックオフ、試行回数の上限、Retry-After の尊重が要ります。ジッターがなければ、同時に失敗した1000のクライアントは、毎ラウンド同時に再試行します。 1つのタイムアウトの背後にある3つの世界 何が起きたか サーバーの状態 クライアントに見えたもの リクエストが途中で失われた 何も適用されていない タイムアウト リクエストが適用され、応答が失われた 1回適用済み タイムアウト リクエストが遅く、クライアントが待つのをやめた あとで1回適用される タイムアウト 正しい反応は、世界ごとに違います。1つ目の世界では、再送しなければ仕事が失われます。2つ目では、再送すると仕事が二重になります。3つ目では、再送が元のリクエストと競合します。\nこれはTwo Generals 問題です。1975 年に最初に発表され、1978 年にこの名前が付きました。信頼できないチャネルでしか通信できない2者は、双方が行動したという確実な合意に到達できません。実務上の帰結は、確認応答では無限後退を止められないことです。確認応答の確認応答も、失われうるからです。\nHTTP の仕様は、ちょうどよい場所に線を引いています。RFC 9110 §9.2.2 は、PUT、DELETE と安全なメソッドは冪等だとしています。通信障害が起きたあと、元のリクエストが成功していたとしても、自動的に繰り返してかまいません。一方、冪等でないメソッドは、リクエストの意味が実際には冪等であると知る手段か、元のリクエストが適用されなかったことを検出する手段がない限り、クライアントは自動的に再試行すべきでない(SHOULD NOT)とされています。この記事の残りは、その2つの「手段」の話です。\n「ちょうど1回」をうたうシステムは、実際に何をしているか ちょうど1回のセマンティクスをうたうシステムは、不可能性を打ち破っているのではありません。境界の内側で重複を除いています。Apache Kafka のプロデューサーのドキュメントははっきり書いています。冪等プロデューサーは、Kafka の配送セマンティクスを「少なくとも1回」から「ちょうど1回の配送」に強化します。仕組みは、ブローカーがプロデューサー ID とシーケンス番号を追跡することです(配送セマンティクス)。そして、プロデューサーが冪等性を保証できるのは「単一のセッション内で送ったメッセージ」だけで、アプリケーションが行う再送は重複を除けないので避けるように、と書かれています(KafkaProducer の javadoc)。レシピはこれがすべてです。メッセージごとの識別子、受け手の側の記憶、そして明示された範囲です。\n実務での言い方にすると、こうなります。ちょうど1回の処理 = 少なくとも1回の配送 + 冪等な効果。\n実験:3つのクライアントと、不安定なネットワーク 下のスクリプトは、ローカルの HTTP サーバーを起動します。ハンドラーは最大 60 ms かかり、副作用(意図ごとのカウンターの加算)を適用します。「ネットワーク」はリクエストの 15% を届く前に落とし、クライアントは 30 ms で待つのをやめます。そのため、かなりの数のリクエストは、クライアントが聞くのをやめたあとに適用されます。3つのクライアントが、それぞれ200件の意図を適用しようとします。\n再試行しない:1回だけ送る。 再試行する(キーなし):最大6回試行し、フルジッターのバックオフを使う。 再試行する(同じキー):同じだが、意図ごとに固定した Idempotency-Key を付ける。サーバーはキーを記録し、重複には保存した結果を返す。 once.mjs // Three clients, one flaky network, one side effect that must not happen twice. // Requires Node 18+ (global fetch). Run: node once.mjs (counts vary slightly between runs: real timers) import http from \u0026#34;node:http\u0026#34;; const INTENTS = 200; // distinct things the \u0026#34;user\u0026#34; wants to happen exactly once const CONCURRENCY = 10; const applied = new Map(); // intent id -\u0026gt; how many times the side effect really ran const seen = new Map(); // idempotency key -\u0026gt; { fingerprint, promise } (the dedupe store) const server = http.createServer(async (req, res) =\u0026gt; { let body = \u0026#34;\u0026#34;; for await (const c of req) body += c; const key = req.headers[\u0026#34;idempotency-key\u0026#34;]; const send = (code, obj) =\u0026gt; { res.writeHead(code, { \u0026#34;content-type\u0026#34;: \u0026#34;application/json\u0026#34; }); res.end(JSON.stringify(obj)); }; const run = async () =\u0026gt; { // the side effect await new Promise((r) =\u0026gt; setTimeout(r, Math.random() * 60)); // slow enough that clients sometimes give up first const { intent } = JSON.parse(body); applied.set(intent, (applied.get(intent) ?? 0) + 1); return { status: 201, intent }; }; if (!key) return send(201, await run()); // no key: every request is a new request const fingerprint = body; const prev = seen.get(key); if (prev \u0026amp;\u0026amp; prev.fingerprint !== fingerprint) return send(422, { error: \u0026#34;key reused with a different request\u0026#34; }); if (prev) { const r = await prev.promise; return send(r.status, r); } // duplicate or concurrent duplicate: share the one result const promise = run(); seen.set(key, { fingerprint, promise }); // record BEFORE awaiting, so concurrent duplicates find it const r = await promise; send(r.status, r); }); // A flaky network: drops 15% of requests before they arrive; the client gives up after 30 ms. const call = async (intent, key) =\u0026gt; { if (Math.random() \u0026lt; 0.15) throw new Error(\u0026#34;request lost\u0026#34;); const headers = { \u0026#34;content-type\u0026#34;: \u0026#34;application/json\u0026#34;, ...(key \u0026amp;\u0026amp; { \u0026#34;idempotency-key\u0026#34;: key }) }; const res = await fetch(url, { method: \u0026#34;POST\u0026#34;, headers, body: JSON.stringify({ intent }), signal: AbortSignal.timeout(30) }); if (res.status \u0026gt;= 500) throw new Error(\u0026#34;server error\u0026#34;); return res.status; }; const jitter = (n, base = 5, cap = 100) =\u0026gt; Math.random() * Math.min(cap, base * 2 ** n); // full jitter const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); const clients = { \u0026#34;never retry\u0026#34;: async (i) =\u0026gt; { try { await call(i); } catch {} }, \u0026#34;retry, no key\u0026#34;: async (i) =\u0026gt; { for (let n = 0; n \u0026lt; 6; n++) { try { return await call(i); } catch { await sleep(jitter(n)); } } }, \u0026#34;retry, same key\u0026#34;: async (i) =\u0026gt; { const key = `intent-${i}`; for (let n = 0; n \u0026lt; 6; n++) { try { return await call(i, key); } catch { await sleep(jitter(n)); } } }, }; await new Promise((r) =\u0026gt; server.listen(0, \u0026#34;127.0.0.1\u0026#34;, r)); const url = `http://127.0.0.1:${server.address().port}/`; for (const [name, client] of Object.entries(clients)) { applied.clear(); seen.clear(); let next = 0; // a small worker pool, so that we measure the protocol and not connection storms await Promise.all(Array.from({ length: CONCURRENCY }, async () =\u0026gt; { while (next \u0026lt; INTENTS) await client(`${name}-${next++}`); })); await sleep(200); // let slow in-flight requests finish const counts = [...applied.values()]; console.log(`${name.padEnd(16)} lost=${INTENTS - counts.length} applied once=${counts.filter((c) =\u0026gt; c === 1).length} duplicated=${counts.filter((c) =\u0026gt; c \u0026gt; 1).length}`); } server.close(); 1回の実行結果です(Node.js 20.19、Linux 6.12。並行ワーカーは10。実タイマーなので、実行のたびに数字は変わります)。\nnever retry lost=32 applied once=168 duplicated=0 retry, no key lost=0 applied once=95 duplicated=105 retry, same key lost=0 applied once=200 duplicated=0 何回か実行して、同じ傾向でした。キーがなければ約半数の意図が2回以上適用され、キーがあれば0件でした。再試行しないクライアントは、仕事の約10〜20% を失いました。(1行目の「lost」は、届く前に落ちたリクエストだけです。クライアントは、適用されたリクエストの多くでもタイムアウトしましたが、それを知りませんでした。それが要点です。)\n修正に見えるバグ サーバーのハンドラーの順序を見てください。\nconst promise = run(); seen.set(key, { fingerprint, promise }); // record BEFORE awaiting const r = await promise; キーは効果が終わる前に記録され、最初の処理が走っている間に届いた重複は、同じ promise を待ちます。seen.set を await run() のあとに動かしても、コードは「重複排除のストアを持っている」ように見えますが、並行する重複はどちらもそれを見逃します。実際にその変更をして実行しました。\nretry, same key lost=0 applied once=111 duplicated=89 キーを正しく送っているクライアントが、200件のうち89件の意図を重複させました。(件数はタイミングで動きます。同じ変種をあとで3回実行したら、75件、96件、90件でした。)この隙間は、クライアントのタイムアウトがまさに作る状況です。最初のリクエストがまだ走っている間に、再試行が届くのです。\n冪等キーの契約 キーは、クライアントとサーバーの間の約束です。サーバー側は、小さな状態機械です。\nリクエスト サーバーの動作 ステータス キーを見たことがない キーを記録し、操作を実行する 2xx キーを見たことがある、同じリクエスト、完了済み 保存した応答を返す。再実行しない 元のステータス キーを見たことがある、同じリクエスト、実行中 完了を待つか、あとで来るようクライアントに伝える 待ったうえでの 2xx、または 409 キーを見たことがある、異なるリクエスト本文 拒否する。クライアントがキーを誤用している 422 これらのステータスの選び方は、(失効した)Idempotency-Key ヘッダーの IETF ドラフトに沿っています。そのドラフトは、処理中のリクエストに 409、異なるペイロードでのキー再利用に 422 を提案していました。2026-10-04 時点で、Datatracker は draft-ietf-httpapi-idempotency-key-header-07 を、失効した Internet-Draft(最終更新 2026-04-18)として表示しており、RFC ではありません。比較のための慣習と考え、標準とは考えないでください。\nクライアント側も、同じくらい重要です。\nキーは、最初の送信の前に、意図ごとに1回だけ生成する。 試行ごとではありません。再試行のループの中で生成したキーは、毎回違うキーになり、キーがないのと同じです。 キーを、保留中の操作と一緒に永続化する。 試行の合間にアプリが再起動しても、再起動後の再試行は同じキーを使わなければなりません。キューに入れたリクエストの隣に保存します。 別の意図にキーを再利用しない。 サーバーの 422 は安全網で、計画ではありません。 再試行は、バイト単位で同一にする。 サーバーがペイロードをフィンガープリントするなら、少なくとも意味的に同一にします。 キーと効果は、一緒にコミットされなければならない 実験にはメモリ上の Map で十分ですが、本物のサーバーは「適用」と「記録」の間でクラッシュしえます。効果がコミットされてキーがされていなければ、再試行で効果が再び実行されます。キーがコミットされて効果がされていなければ、起きていないことへの応答が再生されます。対策は、この2つを1つの原子的なステップにすることです。リレーショナルデータベースなら、同じトランザクションにします。\natomic_dedupe.py(SQLite、Python 標準ライブラリのみ) # The dedupe record and the side effect must commit together. Run: python3 atomic_dedupe.py import sqlite3 db = sqlite3.connect(\u0026#34;:memory:\u0026#34;, isolation_level=None) # we manage transactions ourselves db.executescript(\u0026#34;\u0026#34;\u0026#34; CREATE TABLE balance (id INTEGER PRIMARY KEY, amount INTEGER); INSERT INTO balance VALUES (1, 0); CREATE TABLE idempotency (key TEXT PRIMARY KEY, response TEXT); \u0026#34;\u0026#34;\u0026#34;) def charge(key, amount, crash_before_commit=False): db.execute(\u0026#34;BEGIN IMMEDIATE\u0026#34;) row = db.execute(\u0026#34;SELECT response FROM idempotency WHERE key = ?\u0026#34;, (key,)).fetchone() if row: # duplicate: replay the stored answer, do nothing else db.execute(\u0026#34;COMMIT\u0026#34;) return row[0] db.execute(\u0026#34;UPDATE balance SET amount = amount + ? WHERE id = 1\u0026#34;, (amount,)) # the side effect if crash_before_commit: db.execute(\u0026#34;ROLLBACK\u0026#34;) # the process dies here: neither the effect nor the key survives raise RuntimeError(\u0026#34;crashed before commit\u0026#34;) db.execute(\u0026#34;INSERT INTO idempotency VALUES (?, ?)\u0026#34;, (key, f\u0026#34;charged {amount}\u0026#34;)) db.execute(\u0026#34;COMMIT\u0026#34;) return f\u0026#34;charged {amount}\u0026#34; def balance(): return db.execute(\u0026#34;SELECT amount FROM balance\u0026#34;).fetchone()[0] try: charge(\u0026#34;k1\u0026#34;, 100, crash_before_commit=True) except RuntimeError as e: print(\u0026#34;first attempt:\u0026#34;, e, \u0026#34;| balance =\u0026#34;, balance()) print(\u0026#34;retry :\u0026#34;, charge(\u0026#34;k1\u0026#34;, 100), \u0026#34;| balance =\u0026#34;, balance()) print(\u0026#34;duplicate :\u0026#34;, charge(\u0026#34;k1\u0026#34;, 100), \u0026#34;| balance =\u0026#34;, balance()) first attempt: crashed before commit | balance = 0 retry : charged 100 | balance = 100 duplicate : charged 100 | balance = 100 「クラッシュ」は更新とキーの記録の両方をロールバックするので、再試行は効果を1回だけ適用します。そのあとの重複はキーを見つけて、応答を再生します。(これは SQLite の BEGIN IMMEDIATE を使っており、書き込みを直列化します。他のデータベースでは、同じトランザクションの中で、キーに対する一意制約つきの挿入を行うのが通常のパターンです。その版はここでは実行していません。)\nここは、保証の範囲が終わる場所でもあります。効果が第三者の呼び出し(メール、決済プロバイダー)だった場合、あなたのトランザクションはそれをロールバックできません。唯一の防御は、冪等性の識別子を次に渡すことです。同じキー、またはそこから導いたキーを、次のホップに転送します。冪等性はエンドツーエンドの性質で、キーを尊重しないホップが1つあれば、上流のすべてにとってそれが壊れます。\n再試行:バックオフ、ジッター、予算 すぐに再試行すると、遅いサーバー1台が再試行の嵐になります。指数バックオフは試行の間隔を広げ、ジッターは、同じ瞬間に失敗したクライアントが別々の瞬間に再試行するようにします。よく引用される解析は Exponential Backoff And Jitter で、次の変種を「Full Jitter」と呼んでいます。\n$$ \\text{sleep}_n = \\mathrm{random}\\bigl(0,\\ \\min(\\text{cap},\\ \\text{base}\\cdot 2^{n})\\bigr) $$ここで \\(n\\) は、これまでに失敗した試行の回数です。決定的なモデルで効果が分かります。1000のクライアントが同じ瞬間に失敗し、すべての再試行がまた失敗するとして、スケジューリングだけを観察します。\njitter.mjs // 1000 clients fail at the same instant (say, the server restarts). When do their retries arrive? // Deterministic: seeded PRNG. Run: node jitter.mjs const rng = (a) =\u0026gt; () =\u0026gt; { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a \u0026gt;\u0026gt;\u0026gt; 15), 1 | a); t = (t + Math.imul(t ^ (t \u0026gt;\u0026gt;\u0026gt; 7), 61 | t)) ^ t; return ((t ^ (t \u0026gt;\u0026gt;\u0026gt; 14)) \u0026gt;\u0026gt;\u0026gt; 0) / 2 ** 32; }; const rand = rng(42); const BASE = 100, CAP = 10_000, CLIENTS = 1000, ATTEMPTS = 5; // ms const strategies = { \u0026#34;no jitter\u0026#34;: (n) =\u0026gt; Math.min(CAP, BASE * 2 ** n), \u0026#34;full jitter\u0026#34;: (n) =\u0026gt; rand() * Math.min(CAP, BASE * 2 ** n), \u0026#34;equal jitter\u0026#34;: (n) =\u0026gt; { const d = Math.min(CAP, BASE * 2 ** n); return d / 2 + rand() * (d / 2); }, \u0026#34;decorrelated\u0026#34;: (n, prev) =\u0026gt; Math.min(CAP, BASE + rand() * ((prev ?? BASE) * 3 - BASE)), }; for (const [name, delay] of Object.entries(strategies)) { const buckets = new Map(); // 10 ms bucket -\u0026gt; number of retry requests for (let c = 0; c \u0026lt; CLIENTS; c++) { let t = 0, prev; for (let n = 0; n \u0026lt; ATTEMPTS; n++) { prev = delay(n, prev); t += prev; // every retry fails again, so we measure pure scheduling const b = Math.floor(t / 10); buckets.set(b, (buckets.get(b) ?? 0) + 1); } } const peak = Math.max(...buckets.values()); console.log(`${name.padEnd(13)} peak retries in one 10 ms bucket: ${String(peak).padStart(4)} (total ${CLIENTS * ATTEMPTS})`); } no jitter peak retries in one 10 ms bucket: 1000 (total 5000) full jitter peak retries in one 10 ms bucket: 157 (total 5000) equal jitter peak retries in one 10 ms bucket: 214 (total 5000) decorrelated peak retries in one 10 ms bucket: 73 (total 5000) ジッターがなければ、1000のクライアントすべてが、毎ラウンド同じ 10 ms のバケットでサーバーに到達します。どのジッターでも、ピークは大きく下がります。この出力から3つのジッターの変種に優劣をつけるのは避けてください。最初のラウンドの窓の幅がそれぞれ違うので、ピークの違いはそれだけでも生じます。変種どうしの比較は、完了までの時間と総作業量で行っている、リンク先の解析を見てください。\nバックオフは、再試行の方針の半分にすぎません。もう半分は、いつやめるか、いつ始めないかです。\n再試行してよいものだけを再試行する。 ネットワークエラーとタイムアウト、503(Retry-Afterがあれば尊重する)、429(RFC 6585 §4)。リクエストが間違っていると伝える 4xx は再試行しない。 試行回数と総時間に上限を置く。 期限のない再試行ループはリークです。 再試行は1つの層で行う。 3つの層がそれぞれ最大3回試行すると、いちばん下の層は、ユーザーの1回の操作に対して最大27個のリクエストを見ることになります。 一時的な失敗を最終結果として保存しない。 サーバーが 503 をキーに紐づけて保存すると、以降のすべての再試行で、その失敗が永遠に再生されます。保存するのは、終端の結果です。 冪等性がそれでも壊れる場所 失敗 結果 防御 効果のあとにキーを記録する 並行する重複がどちらも実行される(僕の実行では200件中89件) 先に記録する、またはキーをロックする。重複は最初の処理を待たせる。 試行ごとに新しいキー 重複排除がまったく働かない 最初の送信の前に、意図ごとに1回だけ生成する。 アプリの再起動をまたいでキーを保存していない クラッシュ後の重複 キューに入れた操作と一緒にキーを保存する。 同じキーで違うペイロード 間違った結果が再生される リクエストをフィンガープリントし、422 を返す。 効果とキーが別々のコミット クラッシュの隙間で、効果が2回走る、または幻の結果が再生される 1つのトランザクション(またはトランザクション内の一意制約)。 キーの保持期間がクライアントの再試行窓より短い 古い再試行がもう一度実行される クライアントが再試行しうる最長の時間より長く保持する。 下流のホップがキーを無視する 境界の下で重複が再び現れる キーを転送する。または下流に独自の冪等性の識別子を持たせる。 ジッターなしの再試行 負荷の同期した波 フルジッター。試行回数の上限。Retry-After を尊重。 すべての層で再試行 負荷が掛け算で増える(3 x 3 x 3 = 27) 再試行は1つの層で行い、期限は下に渡す。 一時的な失敗を結果として保存 エラーが永久に再生される 終端の結果だけを保存する。 キーが必要な場面と、不要な場面 冪等キーを使うのは、自然には冪等でない副作用があり、クライアントが再試行しうる操作です。作成、課金、送信、キューへの投入などです。\n次の場合は、要らないかもしれません。\n操作がすでに RFC 9110 §9.2.2 の意味で冪等である場合:リソースを置き換える PUT、識別子で指定する DELETE、読み取り。 操作に自然な一意の識別子がある場合。その識別子への一意制約が、そのまま重複排除のストアになります。 最大1回で許容される場合(ベストエフォートのテレメトリなど):1回送って先へ進む。 効果に再試行の経路がそもそもない場合。ユーザーは待つより、エラーを見たいはずだからです。 動かして、1つずつ変える # Node.js 18 以降(実行したのは Node.js 20)。依存関係なし。 node once.mjs # 数秒かかる。数字は実行ごとに変わる node jitter.mjs # 決定的 python3 atomic_dedupe.py そして、1か所ずつ変えてみてください。once.mjs の seen.set を await の下に動かして、重複が戻ってくるのを見ます。再試行のループの中でキーを生成します。ネットワークの損失率を 50% に上げます。クライアントのタイムアウトを 200 ms にして、応答が失われるケースをなくし、それでも差が出るクライアントを確かめます。\n再試行という賭け 再試行はすべて、「最初の試行は成功しなかった」という賭けです。冪等キーは、その賭けが外れたときに、何も起きないようにします。2回目の試行が最初の試行を見つけて、その結果を返すのです。その代償は、受け手側のメモリ、原子的なコミット1回、そして最初の送信の前にキーを決めるという規律です。得られるのは、「ちょうど1回」がネットワークの性質ではなく、名前を付けられる境界の性質になることです。\n実験で確認したことと、していないこと すべて Linux 6.12、Node.js 20.19、Python 3.13 で実行しました。実験は、単一のプロセス、ローカルのネットワーク、メモリ上の重複排除ストアで行っており、損失率とタイミングは僕が選んだパラメーターで、実際のネットワークの計測値ではありません。once.mjs の件数は、実タイマーを使うため、実行ごとに変わります。ジッターの出力はスケジューリングのモデルで、負荷試験ではありません。PostgreSQL や MySQL 版の原子的な重複排除、キーの有効期限、第三者の下流は試していません。仕様やドキュメントについての記述は、リンク先のページから 2026-10-04 時点で確認したものです。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/exactly-once-delivery-idempotency-and-backoff/","summary":"リクエストがタイムアウトしたとき、クライアントはリクエストが失われたのか、確認応答だけが失われたのかを区別できません。だから「ちょうど1回」の配送は作れず、実際にできるのは「少なくとも1回の配送」と「重複を除く受け手」の組み合わせです。不安定なネットワークでの実験、正しく見えて89件を重複させるバグ、原子的な重複排除ストア、ジッターのシミュレーションで確かめます。","title":"リトライで二重に処理される。「ちょうど1回」は作れない"},{"content":"リアルタイムな UI とは関係のない実験から始めます。パイプに 2 KB のデータが入っています。読み取り可能と通知されたので 1 KB だけ読み、もう一度問い合わせます。カーネルは、まだ読み取り可能だと言うでしょうか。\n答えは、登録の仕方で変わります。そしてその答えは、イベントストリームをクライアントがどう消費すべきかに、ほぼそのまま当てはまります。\nTL;DR epoll のエッジトリガーは「変化」を1度だけ通知します。データを途中まで読んで待つと、永遠に待つことがあります。レベルトリガーは「状態」(まだ読める)を通知し、状態が解消するまで繰り返します。 イベントストリームにも同じ2つの設計があります。差分(「item 7 は X になった」)はエッジです。1つ落とすとクライアントは間違ったままで、それに気づく手段もありません。汚れフラグ(「item 7 は古いかもしれない、見に行って」)はレベルです。重複も順序の入れ替えも畳み込みも問題になりません。クライアントは必ず現在の値を取りに行くからです。 ただし、ヒントだけではまだレベルではありません。epoll が堅牢なのは、カーネルが状態を保持し続けるからです。アプリケーションで同じ役割をするのは、クライアントが読み直して比べられるもの(バージョンやカーソル)と、定期的な照合です。損失のあるチャネルのシミュレーションでは、ヒントだけだと300回中179回で収束し、照合を足すと300回すべてで収束しました。 どの実装も見落としやすい競合が1つあります。再取得の最中に届いたイベントです。答えは「もう一度再取得する」です。それを行う1行を消すと、3つのテストのうち2つが失敗します。 レベルトリガーは取得回数を食います。同じシミュレーションで、きちんと作った差分の消費側は1回の実行あたり約1回の取得で済み、無効化する側は約64回でした。取得のコストより、乱れた配送の下での正しさのほうが重要なとき、あるいは、修復の経路と通常の経路が同じコードであることに価値があるときに、レベルを選びます。 実験1:epoll は部分的な読み取りのあと何を返すか epoll(7) のマニュアルは、まさにこの場面を説明しています。パイプの読み取り側を登録し、書き手が 2 kB 書き、epoll_wait が通知し、読み手が 1 kB 読み、もう一度 epoll_wait を呼びます。エッジトリガーのフラグ EPOLLET の場合、2回目の呼び出しは、入力バッファにデータが残っているにもかかわらず、「おそらくハングする」(will probably hang)と書かれています。約20行で再現できます(Linux のみ)。\nepoll_demo.py # Edge-triggered vs level-triggered epoll: what happens after a partial read? # Linux only. Run: python3 epoll_demo.py import os, select def run(flags, label): r, w = os.pipe() os.set_blocking(r, False) ep = select.epoll() ep.register(r, select.EPOLLIN | flags) os.write(w, b\u0026#34;x\u0026#34; * 2048) # (2) the writer writes 2 kB first = ep.poll(timeout=1) # (3) the fd is reported ready os.read(r, 1024) # (4) the reader consumes only 1 kB second = ep.poll(timeout=0.5) # (5) wait again print(f\u0026#34;{label:15} first={bool(first)} second={bool(second)} (1024 bytes still unread)\u0026#34;) if not second: # The documented fix for edge-triggered use: read until EAGAIN. try: while os.read(r, 1024): pass except BlockingIOError: print(f\u0026#34;{\u0026#39;\u0026#39;:15} drained until EAGAIN\u0026#34;) ep.close(); os.close(r); os.close(w) run(0, \u0026#34;level-triggered\u0026#34;) run(select.EPOLLET, \u0026#34;edge-triggered\u0026#34;) Linux 6.12 での出力です。\nlevel-triggered first=True second=True (1024 bytes still unread) edge-triggered first=True second=False (1024 bytes still unread) drained until EAGAIN エッジトリガーの登録は、データが届いたときに1度だけパイプを通知しました。そのあと状態は変化していないので通知はなく、1024 バイトが残っているのに待ち続けます。マニュアルが示す対処は、ノンブロッキングのディスクリプターを使い、EAGAIN が返るまで読み切ることです。エッジトリガーの消費側は、変化を丸ごと消費する義務を負います。レベルトリガーの消費側にその義務はありません。少し取り、戻ってきて、もう一度通知を受けられます。\nこの区別は epoll より古く(ハードウェアの信号の「レベル」と「遷移」に由来します)、epoll は手で触って実感しやすい例です。エッジトリガーは、「何も取りこぼさない」という負担を消費側に押し付けます。レベルトリガーは、通知を「現在についての主張」にすることで、その負担をなくします。\nイベントストリームでの同じ選択 「パイプ」を「画面に表示している項目の一覧」、「読み取り可能」を「古い」に読み替えます。\nエッジ:差分を送る レベル:「汚れている」を送る イベントの内容 item:7 title = \u0026quot;B\u0026quot; item:7 が変わったかもしれない 消費側の動き ペイロードを適用する item:7 を再取得する イベントの取りこぼし クライアントは間違うが、気づけない 次のイベントか照合まで遅れるだけ イベントの重複 冪等でなければならない 無料:2回汚すのは1回汚すのと同じ 順序の入れ替わり 古い差分が新しい値を上書きしうる 無料:再取得は現在の値を返す 100件のバースト 100回の適用 キーごとに1つの汚れに畳まれる ペイロード データを運ぶ キーを運ぶ Kubernetes のコントローラーも、同じ説明をされています。設計提案のアーカイブによれば、コントローラーは「耐障害性を最大にするために」レベルベースで作られ、「途中の状態更新がどれだけ失われても」、望ましい状態と観測された状態さえあれば正しく動作します。一方で、遅延を減らすために、通知型の watch API も併用します。通知は最適化で、真実は状態のほうにあります。PostgreSQL の NOTIFY のドキュメントも同じ方向を向いています。大量の情報を伝えたいなら、テーブルに置いて、レコードのキーを送るのがよい、と書かれています。\n実験2:乱れたチャネルで、どの消費側が生き残るか 「差分は壊れやすい」という主張は、試す価値があります。下のシミュレーションでは、サーバーが5つのキーを持ち、200ティック(1ティックは仮想時間の 10 ms)の間、2ティックに約1回の頻度で更新します。5つの消費側が、同じ更新を、それぞれ別の乱れたチャネル経由で受け取ります。各メッセージは確率 p で失われ、確率 0.2 で重複し、0〜7ティック遅れます(つまり追い越しが起きます)。消費側の取得(fetch)は、リクエスト時点の値を返し、1〜8ティック後に届くので、取得の応答どうしも追い越しあいます。更新が止まったあとは、サーバーが 200 ms ごとに、各キーの現在のバージョンを載せた小さな「レベル」メッセージを送ります。このメッセージも同じ乱れたチャネルを通るので、失われることがあります。最後に、すべてのキーがサーバーと一致しているかで判定します。\nA:差分を届いた順にそのまま適用する。 B:差分を、保持しているものより新しいバージョンのときだけ適用する(冪等で、順序に影響されない)。 C:ヒントだけ。イベントごとにキーを汚し、畳み込み付きの invalidator が再取得する。 D:C に、定期的なレベルメッセージを足す。サーバーのバージョンがクライアントのものより新しいキーを汚す。 E:B に、同じ定期メッセージを足す。遅れているキーを取得する。 sim.mjs:決定的。設定ごとにシード付きの300回の試行 // Which consumer design converges when the channel drops, duplicates and reorders messages? // Deterministic: a seeded PRNG and a virtual clock (1 tick = 10 ms). Run: node sim.mjs import { createInvalidator } from \u0026#34;./invalidator.mjs\u0026#34;; const KEYS = [\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;, \u0026#34;d\u0026#34;, \u0026#34;e\u0026#34;]; const rng = (a) =\u0026gt; () =\u0026gt; { // mulberry32: small, seedable, good enough for a simulation a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a \u0026gt;\u0026gt;\u0026gt; 15), 1 | a); t = (t + Math.imul(t ^ (t \u0026gt;\u0026gt;\u0026gt; 7), 61 | t)) ^ t; return ((t ^ (t \u0026gt;\u0026gt;\u0026gt; 14)) \u0026gt;\u0026gt;\u0026gt; 0) / 2 ** 32; }; async function trial(seed, { pDrop, pDup, maxDelay }) { const rand = rng(seed), pick = (n) =\u0026gt; Math.floor(rand() * n); let tick = 0, q = []; const at = (d, fn) =\u0026gt; { const h = { t: tick + d, fn, dead: false }; q.push(h); return h; }; const timers = { setTimeout: (fn, ms) =\u0026gt; at(Math.max(1, Math.round(ms / 10)), fn), clearTimeout: (h) =\u0026gt; (h.dead = true) }; const server = Object.fromEntries(KEYS.map((k) =\u0026gt; [k, 0])); // key -\u0026gt; version (the value is the version) const mk = () =\u0026gt; ({ val: Object.fromEntries(KEYS.map((k) =\u0026gt; [k, 0])), fetches: 0 }); const A = mk(), B = mk(), C = mk(), D = mk(), E = mk(); // five consumers, same updates, own channel each const refetch = (c) =\u0026gt; (k) =\u0026gt; new Promise((done) =\u0026gt; { c.fetches++; const answer = server[k]; // the server answers with the value NOW at(1 + pick(maxDelay), () =\u0026gt; { if (answer \u0026gt; c.val[k]) c.val[k] = answer; done(); }); // late answers may arrive out of order }); const invC = createInvalidator({ refetch: refetch(C), timers }); const invD = createInvalidator({ refetch: refetch(D), timers }); const fetchE = refetch(E); const handlers = { A: (m) =\u0026gt; m.type === \u0026#34;event\u0026#34; \u0026amp;\u0026amp; (A.val[m.k] = m.v), // apply payload in arrival order B: (m) =\u0026gt; m.type === \u0026#34;event\u0026#34; \u0026amp;\u0026amp; m.v \u0026gt; B.val[m.k] \u0026amp;\u0026amp; (B.val[m.k] = m.v), // apply payload if newer C: (m) =\u0026gt; m.type === \u0026#34;event\u0026#34; \u0026amp;\u0026amp; invC.invalidate(m.k), // hint only D: (m) =\u0026gt; (m.type === \u0026#34;event\u0026#34; ? invD.invalidate(m.k) : KEYS.forEach((k) =\u0026gt; m.vers[k] \u0026gt; D.val[k] \u0026amp;\u0026amp; invD.invalidate(k))), E: (m) =\u0026gt; (m.type === \u0026#34;event\u0026#34; ? m.v \u0026gt; E.val[m.k] \u0026amp;\u0026amp; (E.val[m.k] = m.v) : KEYS.forEach((k) =\u0026gt; m.vers[k] \u0026gt; E.val[k] \u0026amp;\u0026amp; fetchE(k))), }; const send = (m) =\u0026gt; { for (const h of Object.values(handlers)) { // an independent lossy channel per consumer if (rand() \u0026lt; pDrop) continue; for (let i = 0; i \u0026lt; (rand() \u0026lt; pDup ? 2 : 1); i++) at(pick(maxDelay), () =\u0026gt; h(m)); } }; const END = 200, STOP = 600; for (tick = 0; tick \u0026lt;= STOP; tick++) { if (tick \u0026lt; END \u0026amp;\u0026amp; rand() \u0026lt; 0.5) { const k = KEYS[pick(KEYS.length)]; send({ type: \u0026#34;event\u0026#34;, k, v: ++server[k] }); } if (tick % 20 === 0 \u0026amp;\u0026amp; tick \u0026gt; END) send({ type: \u0026#34;heartbeat\u0026#34;, vers: { ...server } }); // the \u0026#34;level\u0026#34;: cheap, repeated, idempotent for (let moved = true; moved;) { moved = false; for (const h of q.filter((x) =\u0026gt; x.t \u0026lt;= tick \u0026amp;\u0026amp; !x.dead)) { h.dead = true; h.fn(); moved = true; } q = q.filter((x) =\u0026gt; !x.dead); await new Promise(setImmediate); // let promise continuations run } } const ok = (c) =\u0026gt; KEYS.every((k) =\u0026gt; c.val[k] === server[k]); return { A, B, C, D, E, ok }; } for (const cfg of [{ pDrop: 0, pDup: 0.2, maxDelay: 8 }, { pDrop: 0.2, pDup: 0.2, maxDelay: 8 }]) { const N = 300, tally = Object.fromEntries(\u0026#34;ABCDE\u0026#34;.split(\u0026#34;\u0026#34;).map((n) =\u0026gt; [n, { ok: 0, fetches: 0 }])); for (let s = 1; s \u0026lt;= N; s++) { const r = await trial(s, cfg); for (const n of \u0026#34;ABCDE\u0026#34;) { tally[n].ok += r.ok(r[n]) ? 1 : 0; tally[n].fetches += r[n].fetches; } } console.log(`\\ndrop=${cfg.pDrop} dup=${cfg.pDup} reorder\u0026lt;=${cfg.maxDelay} ticks, ${N} trials`); const names = { A: \u0026#34;A delta, apply in arrival order\u0026#34;, B: \u0026#34;B delta, apply if newer\u0026#34;, C: \u0026#34;C hint only (invalidate+refetch)\u0026#34;, D: \u0026#34;D hint + periodic level check\u0026#34;, E: \u0026#34;E delta + periodic level check\u0026#34; }; for (const n of \u0026#34;ABCDE\u0026#34;) console.log(`${names[n].padEnd(36)} converged ${String(tally[n].ok).padStart(3)}/${N} fetches/trial ${(tally[n].fetches / N).toFixed(1)}`); } 出力です(シード 1〜300、Node.js 20.19。実行は決定的なので、同じ数字になります)。\ndrop=0 dup=0.2 reorder\u0026lt;=8 ticks, 300 trials A delta, apply in arrival order converged 203/300 fetches/trial 0.0 B delta, apply if newer converged 300/300 fetches/trial 0.0 C hint only (invalidate+refetch) converged 300/300 fetches/trial 75.2 D hint + periodic level check converged 300/300 fetches/trial 75.2 E delta + periodic level check converged 300/300 fetches/trial 0.0 drop=0.2 dup=0.2 reorder\u0026lt;=8 ticks, 300 trials A delta, apply in arrival order converged 68/300 fetches/trial 0.0 B delta, apply if newer converged 101/300 fetches/trial 0.0 C hint only (invalidate+refetch) converged 179/300 fetches/trial 64.2 D hint + periodic level check converged 300/300 fetches/trial 64.6 E delta + periodic level check converged 300/300 fetches/trial 1.0 目を引く点が4つあり、最後の1つは、ヒントを送る設計にとっていちばん都合が悪いものです。\n重複と順序の入れ替えだけで、素朴な差分の消費側は壊れます(A:300回中203回)。バージョンの確認を入れれば完全に直ります(B)。 損失は、「そのキーの最後のイベント」に頼るものをすべて壊します。 損失20%のとき、B は101回、C は179回しか収束しませんでした。そのキーについての最後のメッセージなら、失われたヒントも、失われた差分と同じく取り返せません。 損失を直すのは、ヒントではなくレベルです。 D も E も毎回収束しました。C と D の差は、定期的な照合だけです。epoll でこの役をするのはカーネルの準備完了フラグですが、アプリケーションは自分で作る必要があります。クライアントがいつでもサーバーと比べられるバージョン、カーソル、ダイジェストです。 修復の経路を持つ、きちんと作った差分の消費側(E)のほうが安い。 1回の実行あたり約1回の取得で、無効化する側の約64回と比べて大きく少ないです。 つまり、レベルトリガーの設計を支持する理由は、「差分では動かない」ではありません。D では、修復の経路と通常の経路が同じコードであることです。E には2つの経路があります。常に動く適用の経路と、何かがおかしくなったあとにしか動かない取得の経路です。後者はいちばん実行されないコードで、初めて必要になったときに壊れている可能性が最も高いコードでもあります。取得が安いなら D を、高いなら E を選び、修復の経路を適用の経路と同じくらい念入りにテストしてください。\ninvalidator を作る シミュレーションの消費側は、下のコードです。4つのことをしていて、それぞれが具体的な間違いに対応しています。\ninvalidator.mjs // A level-triggered consumer: events only say \u0026#34;this key may be stale\u0026#34;. // The consumer owns the question \u0026#34;what is the current value?\u0026#34; and always answers it by refetching. export function createInvalidator({ refetch, // async (key) =\u0026gt; void; must read the CURRENT value and store it windowMs = 50, // coalescing window: fixed, so latency is bounded maxBackoffMs = 5000, timers = { setTimeout, clearTimeout }, }) { const dirty = new Set(); // keys that may be stale and are not being fetched right now const fetching = new Set(); // keys with a refetch in flight const failures = new Map(); // key -\u0026gt; consecutive failures let timer = null; function schedule(ms = windowMs) { if (timer === null) timer = timers.setTimeout(flush, ms); } function flush() { timer = null; for (const key of [...dirty]) { if (fetching.has(key)) continue; // stays dirty; re-armed when that fetch settles dirty.delete(key); fetching.add(key); Promise.resolve(refetch(key)).then( () =\u0026gt; { failures.delete(key); settle(key, windowMs); }, () =\u0026gt; { const n = (failures.get(key) ?? 0) + 1; failures.set(key, n); dirty.add(key); // still stale // full jitter backoff so that many clients do not retry in lockstep settle(key, Math.random() * Math.min(maxBackoffMs, windowMs * 2 ** n)); }, ); } } function settle(key, nextMs) { fetching.delete(key); if (dirty.has(key)) schedule(nextMs); // an event arrived during the fetch, or the fetch failed } return { invalidate(key) { dirty.add(key); schedule(); }, // any order, any number of times invalidateAll(keys) { for (const k of keys) dirty.add(k); schedule(); }, // after (re)connect pending: () =\u0026gt; dirty.size + fetching.size, }; } イベントをキーの集合に畳み込む。 invalidate(key) は dirty に加えて、タイマーを1つ設定します。2つのキーに対する100件のイベントは、2つのエントリーになります。\nデバウンスではなく、固定の窓を使う。 タイマーは最初のイベントで1度設定し、後続のイベントでリセットしません。イベントのたびに再開する本物のデバウンスは、イベントが続く限り発火しません。流れ続けるストリームでは UI が飢餓状態になります。固定の窓なら、遅延の上限は windowMs です。\n取得の最中に届いたイベントを扱う。 これが競合です。k の再取得が走っていて、サーバーをすでに読んだとします。そこへ k のイベントが届きます。そのイベントが告げているのは、取得が返す値より新しいものです。消費側が「いま取得中だから何もしない」とこれを捨てると、無関係なイベントが来るまで、画面は古い値のままです。そこで、取得中も k を dirty に残し、取得が終わったときに、もう一度 flush を予約します。それが if (dirty.has(key)) schedule(nextMs) の行です。消すとテストが落ちます。\ninvalidator.test.mjs import assert from \u0026#34;node:assert/strict\u0026#34;; import { test } from \u0026#34;node:test\u0026#34;; import { createInvalidator } from \u0026#34;./invalidator.mjs\u0026#34;; const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); test(\u0026#34;100 events on 2 keys become 2 fetches\u0026#34;, async () =\u0026gt; { const fetched = []; const inv = createInvalidator({ refetch: async (k) =\u0026gt; void fetched.push(k), windowMs: 20 }); for (let i = 0; i \u0026lt; 100; i++) inv.invalidate(i % 2 ? \u0026#34;list\u0026#34; : \u0026#34;item:1\u0026#34;); await sleep(80); assert.deepEqual(fetched.sort(), [\u0026#34;item:1\u0026#34;, \u0026#34;list\u0026#34;]); }); test(\u0026#34;an event that arrives during a fetch causes a second fetch\u0026#34;, async () =\u0026gt; { let version = 1, shown = 0, calls = 0; const inv = createInvalidator({ windowMs: 10, refetch: async () =\u0026gt; { calls++; const v = version; // the server answers with the value at request time... await sleep(40); // ...and the response is slow shown = v; }, }); inv.invalidate(\u0026#34;k\u0026#34;); // version 1 is announced await sleep(20); // fetch #1 is now in flight, holding version 1 version = 2; inv.invalidate(\u0026#34;k\u0026#34;); // version 2 is announced while fetch #1 is running await sleep(200); assert.equal(calls, 2); assert.equal(shown, 2); // without the re-arm, the UI would stay at 1 }); test(\u0026#34;a failed fetch is retried with backoff instead of being forgotten\u0026#34;, async () =\u0026gt; { let calls = 0; const inv = createInvalidator({ windowMs: 5, maxBackoffMs: 20, refetch: async () =\u0026gt; { if (++calls \u0026lt; 3) throw new Error(\u0026#34;boom\u0026#34;); }, }); inv.invalidate(\u0026#34;k\u0026#34;); await sleep(300); assert.equal(calls, 3); // two failures, then success, then it stops assert.equal(inv.pending(), 0); }); ok 1 - 100 events on 2 keys become 2 fetches ok 2 - an event that arrives during a fetch causes a second fetch ok 3 - a failed fetch is retried with backoff instead of being forgotten この行をコメントアウトすると、2番目と3番目のテストが失敗します(確認しました)。通るのは1番目だけです。\n失敗はジッターつきのバックオフで再試行し、キーごとに同時に取得するのは1つだけにする。 取得に失敗したキーは dirty に戻ります。待ち時間はランダムで(フルジッター。バックオフとジッターの解説を参照)、同時に失敗した多数のクライアントが同時に再試行しないようにします。また、同じキーを同時に2回取得しないので、同じキーの応答が追い越しあうことはありません。\nこの40行が、あえてやっていないことが2つあります。\n再接続。 再接続したあとは、何を取りこぼしたか分からないので、表示しているキーについて invalidateAll(keys) を呼びます。 定期的なレベルの照合。 シミュレーションでは、サーバーからのメッセージでした。実際のシステムでは、レスポンスヘッダーのバージョン、ETag、カーソル、軽いダイジェスト用のエンドポイントなどになるでしょう。仕組みより形が大切です。つまり、クライアントが比べられるもので、イベントが失われても残り続けるものです。 トレードオフ 取得の増幅。 クライアントがイベントから新しい値を計算できる場合でも、汚れたキーは取得を1回消費します。シミュレーションでは、修復の経路を持つ差分の消費側が1回の実行で約1回取得したのに対し、無効化する消費側は約64〜75回取得しました。 サンダリングハード(一斉殺到)。 サーバーが1つのヒントを多数のクライアントに配ると、全員が同時に再取得します。再試行だけでなく最初の窓にもジッターを入れるか、サーバー側でヒントを散らします。 バージョンを運ぶヒント。 中間の安い道があります。{key, version} を送り、クライアントがそのバージョン以上をすでに持っているなら取得を省くのです。自分で作った重複を減らせます。 ベストエフォートなチャネル上のヒント。 プッシュ通知のようにメッセージが落ちたり畳まれたりしうるチャネルは、ヒントを運ぶのには向いています。ただし、レベルを運ぶ唯一の手段にはしないでください。 壊れ方 失敗 症状 防御 再取得中に届いたイベントを無視する 無関係なイベントが来るまで画面が古いまま キーを汚れたままにして、もう一度再取得する(再設定の行)。 イベントのたびに再開するデバウンス 流れ続けるストリームの間、UI が更新されない 最初のイベントからの固定の窓。 あるキーの最後のイベントが失われる 次の変更まで古いまま バージョンの定期的な比較、またはフォーカス時・再接続時の更新。 1つのキーの応答が順序を入れ替わる 古い値が新しい値を置き換える キーごとに同時に1回だけ取得する。または書き込み時にバージョンで守る。 失敗した取得を忘れる 永続的に古いまま キーを dirty に戻し、ジッターつきのバックオフで再試行する。 再同期なしの再接続 すべてが古い可能性がある 表示中のキーに invalidateAll。 1回のブロードキャストで多数のクライアントが再取得 オリジンの負荷スパイク 最初の窓にジッター。バージョンつきのヒント。 使う場面と使わない場面 使うのは、クライアントが表示するものについて、「現在の値を読む」操作が安いとき、配送が乱れやすいとき(再接続、不安定な回線、中継を挟んだファンアウト)、そして修復の経路を通常の経路と同じにしたいときです。\n次のような場合は向きません。\nイベントそのものがデータであるストリーム:監査ログ、台帳、各ステップを順番どおりに見る必要があるもの。これらに必要なのは無効化ではなく、カーソルつきのログです(再開できるストリームを参照)。 大きなオブジェクトの小さな変更:イベントのたびに値全体を再取得するのは無駄です。バージョンつきの差分を使い、フォールバックにヒント方式の再同期を置く形(消費側 E)を検討します。 通知そのものが内容であるもの:何かが起きたと知らせるトーストのようなもの。ヒントを読み直しても同じ効果にはなりません。 実験を動かす # 実験1(Linux のみ) python3 epoll_demo.py # 実験2とテスト(Node.js 18 以降。実行したのは Node.js 20) node --test invalidator.test.mjs node sim.mjs そして、わざと壊してみてください。if (dirty.has(key)) schedule(nextMs) の行を消してテストをやり直します。sim.mjs の pDrop を 0.5 にして、どの消費側が生き残るかを見ます。シミュレーションは、更新が止まったあとにしかレベルメッセージを送りません(tick \u0026gt; END)。この条件を外して更新中にも送り、取得回数がどう変わるかを見ます。\n事実か、ヒントか イベントストリームは、世界についての事実を運ぶこともできますし、世界がいまは違うというヒントを運ぶこともできます。事実のほうは、すべてが1回ずつ順番に届くなら安上がりです。ヒントのほうは、配送が乱れても動き続けます。ヒントは間違いようがなく、遅れるだけだからです。どちらの場合でも、クライアントが自分の状態とサーバーの状態を比べる手段を持たせてください。ストリームは、真実のすべてではないからです。\n確認したことと、モデルにすぎないこと 実験1は Linux 6.12 と Python 3.13 で実行しました。実験2とテストは Node.js 20.19 で実行しました。シミュレーションはモデルです。チャネル(独立した損失・重複・遅延)、ティックの長さ、パラメーターは僕が選んだもので、どのネットワークの計測値でもなく、取得回数はそれらに依存します。実際のブラウザや実際のネットワークでは試しておらず、invalidator のベンチマークも取っていません。epoll の挙動はドキュメントに書かれているもので、この1つのカーネルで再現しました。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/level-triggered-invalidation/","summary":"epoll のエッジトリガーは部分的に読むと止まりますが、レベルトリガーは止まりません。同じ違いが、イベントの喪失・重複・順序入れ替えに耐えられるかを分けます。再現できる epoll の実験、損失のあるチャネル上での5通りのシミュレーション、そして見落とされがちな競合(再取得の最中に届いたイベント)を扱う40行ほどの invalidator を紹介します。","title":"エッジトリガーのepollは部分読みで止まる。イベント配信も、同じ理由で壊れる"},{"content":"TL;DR TCP には「まだいますか?」と尋ねる信号がありません。接続とは、両端がそれぞれ持つ状態にすぎません。相手が黙って消えたとき(NATがマッピングを捨てた、無線リンクが切れた、マシンの電源が落ちた)、手元のカーネルが気づくのは、自分が送ったものへの返事が十分長く来なかったときだけです。 そのため、見え方は自分が何をしているかで変わります。僕の実験環境(Linux 6.12、カーネルの既定値)では、読むだけのソケットは、60秒観察してもエラーなしでブロックしたままでした。書くソケットは write() がすぐ成功し、その後、再送の諦め時間である938秒(約15.6分)で ETIMEDOUT になりました(既定の15回再送について、man ページは13〜30分としています)。 SO_KEEPALIVE はアイドルの接続なら相手の消失を検知できます(2秒/1秒/3回に調整して4.6秒)。しかし、未確認の送信データがあるあいだは何もしません。一番効いてほしい場面です。TCP_USER_TIMEOUT は、その場面に上限を設けます(5秒の設定で5.5秒)。アプリケーションのハートビートは、相手のアプリケーションまで確認できる唯一の仕組みで、ping 間隔1秒、期限3秒の設定で、無音から3.0秒で検知しました。 古典的な「半開」(相手が状態を失ったが、再び到達できる状況)は、次の書き込みで RST が返って自然に解消します。難しいのは、自然には解消しない、黙ったままのブラックホールです。 すべて、107行の Python ファイル1つで再現できます。root は不要で、非特権のユーザー名前空間とネットワーク名前空間を使います。 背景:「接続が死んだ」とは何か 長寿命の TCP 接続(メッセージストリーム、データベースセッション、WebSocket)は、NAT の背後に置かれることが多く、モバイルでは、来たり切れたりする無線リンクの先にもあります。経路上の何かが何も言わずに死ぬと、アプリケーションは接続が健在だと信じ続けます。\nRFC 9293 §3.5.1 は、**半開(half-open)**接続を定義しています。片方の端が相手に知らせずに接続を閉じた、または中断した場合や、障害や再起動でメモリを失い、両端の状態がずれた場合です。そのような接続は、どちらの向きでもデータを送ろうとすると、自動的にリセットされます。状態が残っている側がデータを送り、状態を失った側が RST を返すからです。\nこれは教科書的なケースで、しかも穏やかなほうです。つらいのは黙ったブラックホールです。相手に向かうパケットは捨てられ、FIN も RST も ICMP エラーも、何も返ってきません。マッピングをタイムアウトさせた NAT は、どちらもありえます。RFC 5382 は、実装に委ねています。NAT が生きている接続を放棄するとき、端点に TCP RST を送ってもよく、黙って放棄してもよい(MAY)とされています。マッピングのない後続のパケットについても、黙って捨てるか RST を返すかは実装に任されています。つまり、通知されると仮定してはいけません。\nこの記事の中心の問いは次のとおりです。自分のプログラムは、気づくまでにどれだけかかるのか。誤検知を出さずに、その時間を短くするにはどうするか。\nカーネルが知り得ない理由 TCP の仕事は、確実な配送です。送信側から見ると、パケットロスと相手の死は、どちらも「ACKが来ない」という同じ現象です。筋の通った方針は、指数バックオフで再送し、いずれ諦めることだけです。RFC 9293 §3.8.3 は、2つのしきい値 R1(経路を疑い始める)と R2(接続を閉じる)を定めています。R2 は少なくとも100秒に相当する値にする(SHOULD)ことと、アプリケーションが接続ごとに R2 を設定できなければならない(MUST)ことが書かれています。再送タイマー自体は上限までバックオフします。RFC 6298 §2 は、最大 RTO を置いてもよいが60秒以上とするよう定めており、Linux は上限を120秒(TCP_RTO_MAX_SEC)と定義しています。\nLinux の R2 は、tcp_retries2 という sysctl です。tcp(7) によると、既定値は15で、再送タイムアウトによって約13〜30分に相当します。さらに、RFC 1122 が定める下限の100秒は、「一般に短すぎるとみなされている」とも書かれています。\nデータを待っているだけなら、再送するものがなく、タイマーがありません。これが一番たちの悪いケースで、はしごの最初の段です。\nはしご:仕組みを1段ずつ足す 各段で仕組みを1つずつ足します。段ごとに実験環境のスクリプトのシナリオが1つあり(全文は、あとの実験環境の節に載せます)、以下の時間は計算ではなく計測です。\n段0:何もしない、読むだけ クライアントがリクエストを送り、返事を待ってブロックしています。そこで相手側を切り離します。実験環境では次のとおりです。\n[ 0.00s] peer lost (all packets dropped) [ 60.06s] still blocked after 60 s: no data, no EOF, no error ss -tno で見ると、ソケットは ESTAB で、タイマーがまったくありません。再送するものも、プローブするものもなく、カーネルが起きる理由がないのです。観察は60秒で止めましたが、終わる理由はありません。読み取りだけのクライアントは、他にどんな設定をしていても、自前のタイムアウトが必要です。\n段1:何もしない、ただし書く 今度は、相手が消えた後にクライアントが100バイトを書きます。write() は送信バッファにコピーするだけなので、すぐ成功します。そのあとは、再送タイマーが引き継ぎます。ss -tno では、バックオフが timer:(on,\u0026lt;残り時間\u0026gt;,\u0026lt;再送回数\u0026gt;) として見えます。\n[ 0.00s] peer lost (all packets dropped) [ 0.00s] write() returned: the bytes only reached the send buffer [ 30.01s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,22sec,7) [ 120.02s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min31sec,9) [ 240.04s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min33sec,10) [ 360.05s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min34sec,11) [ 480.07s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min35sec,12) [ 600.08s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min35sec,13) [ 720.10s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min36sec,14) [ 840.11s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,1min37sec,15) [ 930.13s] ss: ESTAB 0 100 10.0.0.1:52164 10.0.0.2:9000 timer:(on,7.464sec,15) [ 938.42s] recv() failed: errno=110 Connection timed out (抜粋です。完全なログから約30秒ごとの ss のサンプルを選び、空白を詰めてあります。)このマシンでは、938秒(約15.6分)後に ETIMEDOUT で失敗しました。man ページの既定の15回再送と整合しますが、正確な値は RTO、つまり経路の往復時間に左右されるので、実際のネットワークでは変わります。カーネルのドキュメントに、照合できる値があります。既定の tcp_retries2 が15のとき、最小 RTO の200msから倍々に増え、上限の120秒で頭打ちになる接続の仮想的なタイムアウトは924.6秒です(倍々に増える10回の待ちが合計204.6秒、上限120秒の待ちが6回)。この値は下限で、TCP はこれを超えた最初の RTO で諦めます(ip-sysctl)。938秒はそのすぐ上にあり、整合します。\n段2:アイドル接続の SO_KEEPALIVE keepalive は、アイドル接続のために設計されました。RFC 9293 §3.8.4 では実装は任意(MAY-5)で、既定はオフ、間隔の既定は「2時間以上」と定められています。Linux の既定値は、アイドル7200秒、プローブ間隔75秒、プローブ9回です(tcp(7))。アイドルで死んだ接続は、2時間に加えて「約11分」後に切れます。この既定値は、素早い失敗ではなく、後始末のためのものです。ソケットごとに TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT で調整できます。実験環境で2秒/1秒/3回にすると、次のようになりました。\n[ 4.59s] recv() failed: errno=110 Connection timed out プローブは、アイドル2秒後から始まります(最後の通信は相手が消える0.5秒前なので、そこから数えます)。返事のないプローブが3回続くと、カーネルは諦めます。つまり、おおよそ「アイドル時間 + プローブ回数 × 間隔 − すでに経過したアイドル時間」です。プローブを3回にする理由も大切です。RFC 9293 は、keepalive の実装が、特定のプローブへの無応答を接続の死と解釈してはならない(MUST NOT)としています。データを含まない ACK は、確実には再送されないからです。\n段3:同じ keepalive、ただし未確認のデータがある場合 まったく同じオプションを設定し、相手が消えた後に100バイトを書きます。keepalive が汎用の生存確認なら、今回も約4.6秒かかるはずです。実際は違います。\n[ 0.00s] peer lost (all packets dropped) [ 0.00s] write() returned: the bytes only reached the send buffer [ 1.01s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,660ms,2) [ 30.11s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,22sec,7) [ 120.47s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,1min30sec,9) [ 300.15s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,33sec,10) [ 600.27s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,1min35sec,13) [ 930.48s] ss: ESTAB 0 100 10.0.0.1:52196 10.0.0.2:9000 timer:(on,7.108sec,15) [ 938.42s] recv() failed: errno=110 Connection timed out keepalive タイマーの出番は来ません。接続を握っているのは再送タイマーで、結果は938秒と、段1と同じです。これは仕様どおりの動作で、癖ではありません。「Keep-alive packets MUST only be sent when no sent data is outstanding」(RFC 9293 §3.8.4)と書かれています。keepalive はアイドル接続をカバーしますが、書き込み中の接続はカバーしません。\n段4:TCP_USER_TIMEOUT TCP_USER_TIMEOUT(Linux 2.6.37以降)は、RFC 9293 が求める接続ごとの R2 にあたります。送信したデータが未確認のまま、あるいはバッファのデータが未送信のまま残ってよい最大時間をミリ秒で指定し、超えると ETIMEDOUT で接続を閉じます。5000msに設定し、同じ相手の消失、同じ書き込みで試します。\n[ 1.01s] ss: ESTAB 0 100 10.0.0.1:57044 10.0.0.2:9000 timer:(on,660ms,2) [ 2.01s] ss: ESTAB 0 100 10.0.0.1:57044 10.0.0.2:9000 timer:(on,1.304sec,3) [ 3.01s] ss: ESTAB 0 100 10.0.0.1:57044 10.0.0.2:9000 timer:(on,300ms,3) [ 4.02s] ss: ESTAB 0 100 10.0.0.1:57044 10.0.0.2:9000 timer:(on,1.400sec,4) [ 5.02s] ss: ESTAB 0 100 10.0.0.1:57044 10.0.0.2:9000 timer:(on,396ms,4) [ 5.46s] recv() failed: errno=110 Connection timed out 再送は通常のスケジュールで行われます(man ページによると、このオプションは再送のタイミングには影響しません)。動くのは、諦める時点だけです。判定は再送のタイミングで行われるので、きっかり5.000秒ではなく、5秒より少し後になります。man ページには、keepalive が有効なときは、TCP_USER_TIMEOUT が keepalive の失敗で接続を閉じるタイミングを上書きする、とも書かれています。\nカバーしないものは、アイドルの読み取り側です。このオプションが対象にするのは、送信したデータが未確認のまま残ることです。実験環境では、5000msのユーザータイムアウトを設定したアイドルの読み取り側は、60秒後もエラーなしでブロックしたままで、段0とまったく同じでした。\n逆の面もあります。短いユーザータイムアウトは、短時間の障害なら生き延びられた接続まで切ってしまいます。man ページも、タイムアウトを長くすると、TCP 接続が「エンドツーエンドの接続性がない長い期間を生き延びられる」と書いています。\n段5:アプリケーションのハートビート カーネルの仕組みはどれも、相手側のプログラムが生きていて応答できるかを確認しません。固まったプロセスの下でも、健全なカーネルはすべてにACKを返します。また、両側で別々に TCP を終端するミドルボックスは、死んだバックエンドの代わりに応答できます。完全な確認は、アプリケーションのプロトコルにしかありません。小さなメッセージを定期的に送り、期限内に何らかの受信バイトがあることを要求します。\n[ 3.00s] peer lost [ 6.01s] 3 s without any inbound byte -\u0026gt; close and reconnect 実験環境では、クライアントが1秒ごとに ping を送り、何かを受信したら生存の証拠とみなします。相手は3.00秒に消え、クライアントは6.01秒、つまり3.0秒後に諦めました。最悪の場合、検知にかかる時間は、無音の期限に ping 間隔1つを足した程度です(ループが間隔ごとに1回しか確認しないため)。この時間はカーネルの再送状態とは無関係です。確認の主体はソケットのイベントではなく、プログラム内のタイマーだからです。\nおまけの段:教科書的な半開 相手が再起動した(あるいは接続を忘れた)あとで経路が復旧すると、最初の書き込みで RST が届きます。実験環境では、パケットを捨てる、サーバーに接続を破棄させる、ネットワークを戻す、という順で再現できます。\n[ 0.00s] peer lost (all packets dropped) [ 0.31s] network is back, but the peer has no such connection any more [ 5.32s] client idle 5 s, nothing noticed: True | ss: ESTAB 0 0 10.0.0.1:37880 10.0.0.2:9000 [ 5.32s] write() returned: the bytes only reached the send buffer [ 5.32s] recv() failed: errno=104 Connection reset by peer アイドルのクライアントは、アイドルである限り何も気づかず、送信した瞬間にすぐ知ります。RFC 9293 §3.5.1 の予告どおりです。ここまでの段が扱ってきたのは、リセットではなく沈黙だということも、この例から分かります。リセットは簡単なほうのケースです。\n仕組みの比較 仕組み 検知できるもの 検知できないもの 検知までの時間(実験環境) コスト 何もしない なし 黙った障害すべて 読み取り:60秒以内には検知せず。書き込み:938秒(約15.6分) なし SO_KEEPALIVE(調整済み) アイドル接続での相手の消失 未確認データのある接続すべて 4.6秒(アイドル2秒、1秒×3回) 間隔ごとに小さなパケット数個。ソケットごとの設定 TCP_USER_TIMEOUT 未確認データがあるときの相手の消失 アイドルの読み取り側 5.5秒(5000ms) 通信量の増加なし。短くすると、短い障害でも接続が切れる アプリケーションのハートビート 相手の消失、相手の停止、ブラックホール。アイドルでも通信中でも 経路上で測れないもの 3.0秒(ping 1秒、無音の期限3秒) 間隔ごとに双方向で小さなメッセージ1つ これらは重ねて使えます。正しさのためにハートビート、書き込みを素早く失敗させるために TCP_USER_TIMEOUT、いなくなったクライアントを回収するためにサーバー側で keepalive、という組み合わせです。\nNAT はどこに関わるか NAT は接続ごとにマッピングを持ち、アイドル期間のあとで捨てます。その期間より長くアイドルだと、次のパケットは、あなたを覚えていない NAT にぶつかります。RFC 5382 REQ-5 は、NAT が接続の生死を判断できない場合、確立済み接続のアイドルタイムアウトを「2時間4分未満にしてはならない(MUST NOT)」としています。その理由は、既定の keepalive の計算です。アプリケーションが既定の頻度(2時間ごと)で keepalive パケットを送れば、NAT は接続が生きていると受動的に判断できます。追加の4分は、飛行中のパケットが NAT を通過するための余裕です。\nこれは RFC に従う NAT への要件です。すべての NAT やキャリアグレードのゲートウェイが従う保証はなく、実際のネットワークのタイムアウトは測っていません。そのため、数字は挙げません。設計への示唆は次のとおりです。\nアイドルタイムアウトは、不明でネットワークごとに異なるものとして扱います。ハートビートの間隔は設定可能にして、経路上の最短のタイムアウトとして想定する値より十分短くします。 TCP_KEEPIDLE の既定値の2時間は、積極的な NAT に対してはまったく守ってくれない値です。keepalive パケットでマッピングを保つなら、アイドル時間を下げてください。 ハートビートは、マッピングを保つための通信も兼ねます。カーネルの機能が使えても、ハートビートを走らせる理由がもう1つ増えます。 実験環境 ここまでの結果は、すべてこのスクリプトが出したものです。2つ目のネットワーク名前空間を作り、veth ペアでつなぎ、相手側の端を down にして相手を「失わせる」ので、相手に向かうパケットはすべて黙って捨てられます。必要なものは、Linux、Python 3、iproute2(ip、ss)、util-linux(unshare、nsenter)、非特権のユーザー名前空間が有効なことです。root は不要です。\n#!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34;deadpeer.py - how long does a TCP client take to notice that its peer is gone? Linux only. Needs unprivileged user namespaces (no real root). Usage: python3 deadpeer.py \u0026lt;scenario\u0026gt; Scenarios: silent-read | keepalive-read | user-timeout-read | keepalive-write | user-timeout-write | default-write | heartbeat | reset-on-write The peer is \u0026#34;lost\u0026#34; by taking its end of a veth pair down, so every packet to it is dropped without any ICMP or RST - what a vanished NAT mapping or a dead radio link looks like from the client. \u0026#34;\u0026#34;\u0026#34; import os, select, signal, socket, struct, subprocess, sys, threading, time PEER = (\u0026#34;10.0.0.2\u0026#34;, 9000) def sh(*cmd, ns=None): pre = [\u0026#34;nsenter\u0026#34;, \u0026#34;-t\u0026#34;, str(ns), \u0026#34;-n\u0026#34;] if ns else [] return subprocess.run(pre + list(cmd), check=True, capture_output=True, text=True).stdout def setup_lab(): \u0026#34;\u0026#34;\u0026#34;Create the second network namespace and the veth pair. Returns the namespace\u0026#39;s pid.\u0026#34;\u0026#34;\u0026#34; holder = subprocess.Popen([\u0026#34;unshare\u0026#34;, \u0026#34;-n\u0026#34;, \u0026#34;sleep\u0026#34;, \u0026#34;3600\u0026#34;]); time.sleep(0.3) sh(\u0026#34;ip\u0026#34;, \u0026#34;link\u0026#34;, \u0026#34;add\u0026#34;, \u0026#34;c0\u0026#34;, \u0026#34;type\u0026#34;, \u0026#34;veth\u0026#34;, \u0026#34;peer\u0026#34;, \u0026#34;name\u0026#34;, \u0026#34;s0\u0026#34;) sh(\u0026#34;ip\u0026#34;, \u0026#34;link\u0026#34;, \u0026#34;set\u0026#34;, \u0026#34;s0\u0026#34;, \u0026#34;netns\u0026#34;, str(holder.pid)) sh(\u0026#34;ip\u0026#34;, \u0026#34;addr\u0026#34;, \u0026#34;add\u0026#34;, \u0026#34;10.0.0.1/24\u0026#34;, \u0026#34;dev\u0026#34;, \u0026#34;c0\u0026#34;); sh(\u0026#34;ip\u0026#34;, \u0026#34;link\u0026#34;, \u0026#34;set\u0026#34;, \u0026#34;c0\u0026#34;, \u0026#34;up\u0026#34;) sh(\u0026#34;ip\u0026#34;, \u0026#34;addr\u0026#34;, \u0026#34;add\u0026#34;, \u0026#34;10.0.0.2/24\u0026#34;, \u0026#34;dev\u0026#34;, \u0026#34;s0\u0026#34;, ns=holder.pid) sh(\u0026#34;ip\u0026#34;, \u0026#34;link\u0026#34;, \u0026#34;set\u0026#34;, \u0026#34;s0\u0026#34;, \u0026#34;up\u0026#34;, ns=holder.pid) mac = sh(\u0026#34;ip\u0026#34;, \u0026#34;-o\u0026#34;, \u0026#34;link\u0026#34;, \u0026#34;show\u0026#34;, \u0026#34;s0\u0026#34;, ns=holder.pid).split(\u0026#34;link/ether \u0026#34;)[1].split()[0] # pin the neighbour entry, so a dead peer is not reported as an ARP failure (\u0026#34;No route to host\u0026#34;) sh(\u0026#34;ip\u0026#34;, \u0026#34;neigh\u0026#34;, \u0026#34;replace\u0026#34;, PEER[0], \u0026#34;lladdr\u0026#34;, mac, \u0026#34;dev\u0026#34;, \u0026#34;c0\u0026#34;, \u0026#34;nud\u0026#34;, \u0026#34;permanent\u0026#34;) return holder SERVER = r\u0026#39;\u0026#39;\u0026#39; import socket, signal, struct, sys echo = sys.argv[1] == \u0026#34;echo\u0026#34; s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((\u0026#34;10.0.0.2\u0026#34;, 9000)); s.listen(); c, _ = s.accept() def forget(*_): # close with RST ... which the lab will drop c.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack(\u0026#34;ii\u0026#34;, 1, 0)); c.close(); raise SystemExit signal.signal(signal.SIGUSR1, forget) while True: data = c.recv(65536) if not data: break if echo: c.sendall(b\u0026#34;PONG\\n\u0026#34;) \u0026#39;\u0026#39;\u0026#39; def main(scenario): holder = setup_lab() srv = subprocess.Popen([\u0026#34;nsenter\u0026#34;, \u0026#34;-t\u0026#34;, str(holder.pid), \u0026#34;-n\u0026#34;, \u0026#34;python3\u0026#34;, \u0026#34;-c\u0026#34;, SERVER, \u0026#34;echo\u0026#34; if scenario == \u0026#34;heartbeat\u0026#34; else \u0026#34;sink\u0026#34;]) time.sleep(0.5) link = lambda state: sh(\u0026#34;ip\u0026#34;, \u0026#34;link\u0026#34;, \u0026#34;set\u0026#34;, \u0026#34;s0\u0026#34;, state, ns=holder.pid) ss = lambda: \u0026#34; | \u0026#34;.join(l.strip() for l in sh(\u0026#34;ss\u0026#34;, \u0026#34;-tno\u0026#34;, \u0026#34;dst\u0026#34;, PEER[0]).splitlines()[1:]) or \u0026#34;(socket gone)\u0026#34; t0 = time.monotonic() def log(msg): print(f\u0026#34;[{time.monotonic() - t0:8.2f}s] {msg}\u0026#34;, flush=True) def err(e): return f\u0026#34;errno={e.errno} {os.strerror(e.errno or 0)}\u0026#34; c = socket.socket() if scenario.startswith(\u0026#34;keepalive\u0026#34;): # probe after 2 s idle, every 1 s, give up after 3 misses c.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) c.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 2) c.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 1) c.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) if scenario.startswith(\u0026#34;user-timeout\u0026#34;): c.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, 5000) # ms c.connect(PEER); c.sendall(b\u0026#34;hello\u0026#34;); time.sleep(0.5) t0 = time.monotonic() if scenario == \u0026#34;heartbeat\u0026#34;: # application-level liveness (see the article) c.setblocking(False); last_rx = time.monotonic(); lost = False while True: if not lost and time.monotonic() - t0 \u0026gt;= 3: link(\u0026#34;down\u0026#34;); lost = True; log(\u0026#34;peer lost\u0026#34;) try: c.send(b\u0026#34;PING\\n\u0026#34;) except BlockingIOError: pass if select.select([c], [], [], 1.0)[0]: try: data = c.recv(100) except OSError: data = b\u0026#34;\u0026#34; if data: last_rx = time.monotonic() if time.monotonic() - last_rx \u0026gt; 3: log(\u0026#34;3 s without any inbound byte -\u0026gt; close and reconnect\u0026#34;); break else: link(\u0026#34;down\u0026#34;); log(\u0026#34;peer lost (all packets dropped)\u0026#34;) if scenario == \u0026#34;reset-on-write\u0026#34;: # the peer also forgets the connection, then comes back srv.send_signal(signal.SIGUSR1); time.sleep(0.3); link(\u0026#34;up\u0026#34;) log(\u0026#34;network is back, but the peer has no such connection any more\u0026#34;) idle = not select.select([c], [], [], 5)[0] log(f\u0026#34;client idle 5 s, nothing noticed: {idle} | ss: {ss()}\u0026#34;) if scenario.endswith(\u0026#34;write\u0026#34;) or scenario == \u0026#34;reset-on-write\u0026#34;: c.sendall(b\u0026#34;x\u0026#34; * 100); log(\u0026#34;write() returned: the bytes only reached the send buffer\u0026#34;) stop = threading.Event() def sample(): while not stop.wait(30 if scenario == \u0026#34;default-write\u0026#34; else 1): log(\u0026#34;ss: \u0026#34; + ss()) if scenario in (\u0026#34;default-write\u0026#34;, \u0026#34;keepalive-write\u0026#34;, \u0026#34;user-timeout-write\u0026#34;): threading.Thread(target=sample, daemon=True).start() wait = 60 if scenario in (\u0026#34;silent-read\u0026#34;, \u0026#34;user-timeout-read\u0026#34;) else 3600 if select.select([c], [], [], wait)[0]: try: c.recv(1); log(\u0026#34;recv() returned\u0026#34;) except OSError as e: log(f\u0026#34;recv() failed: {err(e)}\u0026#34;) else: log(f\u0026#34;still blocked after {wait} s: no data, no EOF, no error\u0026#34;) stop.set() srv.kill(); holder.kill() if __name__ == \u0026#34;__main__\u0026#34;: if os.geteuid() != 0: # become root inside new user + network namespaces os.execvp(\u0026#34;unshare\u0026#34;, [\u0026#34;unshare\u0026#34;, \u0026#34;-Urn\u0026#34;, sys.executable] + sys.argv) main(sys.argv[1]) 知っておくべき点が2つあります。1つ目は、近隣(ARP)エントリを固定していることです。そうしないと、相手の消失が、再送タイムアウトではなく、ARP の失敗による No route to host として報告され、別のものを測ってしまうからです。2つ目は、「相手を失う」ことをパケットの廃棄で表現している点です。これは、消えた NAT のマッピングや死んだ無線リンクの近似であり、実際の NAT の挙動やモバイル無線の状態は再現していません。\n使い分け 多数のアイドルなクライアント接続を抱えるサーバー: いなくなったクライアントを回収できるよう、自分で決めたアイドル時間と間隔で SO_KEEPALIVE を有効にします。RFC 1122 §4.2.3.6 も、まさにこの用途(クライアントがクラッシュしたとき、サーバーアプリケーションが無限に待ち続けて資源を使い続けてしまう場合)を挙げています。 主に読む、長寿命の接続を持つクライアント(購読やプッシュのチャンネル): アプリケーションのハートビートが必須です。NAT の背後の読み取り側は、カーネルの機能ではカバーできず、keepalive の既定値は遅すぎます。 素早く失敗すべき書き込み側: TCP_USER_TIMEOUT を足します。値は、起こりうる一時的な障害より長く選びます。 短命のリクエスト/レスポンス接続: リクエストごとの期限を使います。ここで述べた仕組みは不要です。 長い待ち時間を呼び戻してしまう失敗 keepalive を調整して、書き込み側も守られると思う。 症状: keepalive を積極的にしても、詰まった書き込み側が失敗するまで数分かかる(段3)。対処: TCP_USER_TIMEOUT かアプリケーションの期限を使います。 keepalive のアイドル時間が NAT のアイドルタイムアウトより長い。 症状: しばらく静かだった後に接続が死に、そのままハングする。対処: ハートビートまたは keepalive のアイドル時間を、想定する最短のタイムアウトより短くし、デプロイごとに設定可能にします。 ハートビートを1回落としただけで接続の死と判断する。 TCP は失われたセグメントを再送するので、期限には数往復分の余裕を持たせます。期限のリセットは、期待した返事だけでなく、どんな受信バイトでも行います。 期限が後続のティックで延びてしまう。 起点はプローブを送った時刻です。次のティックで期限をさらに先へ延ばしてはいけません。 中継装置がハートビートに応答する。 プロキシがバックエンドの代わりに返事をすると、証明できたのはプロキシの生存であり、サービスの生存ではありません。エンドポイント自身が応答しなければならないメッセージで確認してください。 ETIMEDOUT をユーザーにとって致命的とみなす。 意味するのは、この接続が失われたことであって、サービスが落ちたことではありません。大量障害がサンダリングハード(一斉再接続)にならないよう、ジッター付きの指数バックオフで再接続します。 ユーザータイムアウトを短くしすぎる。 短時間の障害がすべて再接続に変わります。 自分で試す スクリプトを deadpeer.py として保存し、python3 deadpeer.py silent-read を実行します。1分ほどで「still blocked」の行が出ます。 keepalive-read、続けて keepalive-write を実行して比べます。前者は数秒で終わり、後者は再送を続けます(ここでは ss のサンプルが1秒ごとに出ます)。数分かかるので、そのまま走らせるか、Ctrl-C で止めてください。 user-timeout-write を実行し、5000 を変えて、諦める時点が追従することを確認します。 heartbeat を実行し、1秒の ping 間隔と3秒の期限を変えて、実行前に新しい検知時間を予想してみてください。 reset-on-write を実行し、アイドルのクライアントが書き込むまで何も気づかないことを確認します。 シナリオの実行中に別の端末で ss -tno を実行し、タイマーの列を観察します。 計測したことと、していないこと 時間はすべて、Linux 6.12 のサンドボックス1台、既定の sysctl(tcp_retries2 が15、tcp_keepalive_* が7200/75/9)、RTT がミリ秒未満の仮想リンクでの値です。カーネルやネットワークが違えば値も変わります。遅い2つのケース(既定設定の書き込み側と、積極的な keepalive を設定した書き込み側)は、最終版のスクリプトでどちらも938.4秒で失敗しました。別に実行した旧版のスクリプトでは939.0秒でした。短いケースは各2〜4回実行し、ばらつきは最大で0.2秒ほどでした(TCP_USER_TIMEOUT のケースは5.44〜5.65秒)。 引用した RFC と man ページの該当箇所、include/net/tcp.h のカーネル定数と、カーネルの ip-sysctl ドキュメントにある tcp_retries2 の項目(上で触れた924.6秒の下限)は、原文で確認しました。試していないことは、実際の NAT のアイドルタイムアウト、モバイル無線の挙動、他のOS、IPv6、TLS や WebSocket のプロキシです。ミドルボックスが死んだバックエンドの代わりにハートビートへ応答しうるという点は、プロキシが接続を終端する仕組みからの推論で、測定したものではありません。\n死んだ接続を、どれだけ生きていると信じるか 相手の死について TCP が持つ証拠は沈黙だけで、カーネルがそれを集められるのは、未処理のものがあるときだけです。読み取り側には、シグナルがまったく来ません。 書き込み側は、再送を諦める時間が過ぎてから失敗します。ここでは938秒(約15.6分)で、man ページでは既定で13〜30分です。keepalive では短縮できません。keepalive が動くのは、未確認のデータがないときだけだからです。 書き込み側の上限には TCP_USER_TIMEOUT、サーバー側でアイドルなクライアントを回収するには keepalive、正しさが必要なものすべて(事前には分からない NAT のアイドルタイムアウトを含む)にはアプリケーションのハートビートを使います。 長寿命の接続に問うべき一番大切なことは、「切断をどう検知するか」ではありません。「死んだ接続を生きていると信じてよい最長の時間は?」です。その時間を守らせる仕組みを選んでください。 ","permalink":"https://blog.yusukeikoma.com/ja/posts/tcp-half-open-connection-detection/","summary":"TCP は、相手がいなくなったことを教えてくれません。読み取り側は永遠にブロックし、書き込み側は十数分も再送を続け、SO_KEEPALIVE は未確認のデータがあるあいだ何もしません。この記事では、root 不要の1ファイルの Linux 実験環境を作り、何もしない、keepalive、TCP_USER_TIMEOUT、アプリケーションのハートビートという順に仕組みを積み上げて、気づくまでの時間を測ります。NAT と RFC がどこに関わるかも整理します。","title":"死んだTCPの相手に気づくまで、書く側は約15分、読むだけの側は無期限"},{"content":"HTTP のリクエストは、毎回認証情報を運びます。WebSocket が運ぶのは、ハンドシェイクのときの1回だけです。そのあとサーバーが持つのは「このソケットは alice のもの」という判定結果で、根拠そのものではありません。判定を支えたトークンが失効しても、取り消されても、権限が減っても、開いているソケットは何も変わりません。\nこの記事では、1つのトークンが接続の一生をどう通っていくかを追い、各段階でブラウザに何ができるかを確かめます。そのうえで、更新の3つの方法を比べます。コードはすべて、末尾に書いた環境で実行しています。\nTL;DR 101 Switching Protocols のあとは HTTP リクエストが発生しないので、トークンを再検証する場所がありません。サーバーが自分で期限を強制しない限り、接続は認証情報より長く生きます。 ブラウザのページは、WebSocket にヘッダーを付けられません。ハンドシェイクが失敗した理由も分かりません(HTTP 401 もネットワーク障害も、close コード 1006 として届きます)。トークンの置き場所とエラーの伝え方は、自分で設計することになります。 更新の方法は3つです。閉じて再接続する、同じ接続の上で新しいトークンを送る(in-band)、HTTP で接続を指名して送る(out-of-band)。どれを選んでも、次の4つの規則は共通です。サーバーが期限を強制する、主体(subject)を変えさせない、スコープを広げさせない、期限を縮めさせない。失敗はすべて、普通の再接続に落とします。 タイマーで扱えるのは「期限切れ」だけです。失効(revocation)や権限変更は、有効期間を短くするか、自分のシステムから切断を押し込む必要があります。 1つのトークンの旅 第1駅:コンストラクターにはヘッダーを置く場所がない WHATWG の仕様で、WebSocket は new WebSocket(url, protocols) です。オプション引数はないので、ページから Authorization ヘッダーは付けられません(Node の ws のような非ブラウザのクライアントなら付けられます)。トークンは別の場所に乗せる必要があります。\nトークンの置き場所 ブラウザで使えるか コスト Authorization ヘッダー 使えない 非ブラウザのクライアントだけ。 Cookie 使える(自動で付く) 暗黙の権限(ambient authority)です。SameSite がクロスサイトのリクエストから Cookie を外してくれない限り、別サイトのページが、そのユーザーの Cookie でソケットを開こうとできます。それだけに頼ってはいけません。RFC 6455 §10.2 は、特定のサイトからの入力だけを受け付けるサーバーは Origin を検証し、それ以外には 403 を返すべきだとしています。 クエリ文字列 使える URI は多くの場所に記録され、表示されます(RFC 9110 §17.9)。URL に入れたトークンはログに残ります。 Sec-WebSocket-Protocol 使える 秘密を運ぶために設計された機能ではなく、慣習です。サーバーは、提示された値のどれか1つを選んで返す必要があります。返さないと、ブラウザは接続を失敗させます(後述)。値は HTTP の token でなければならないので、base64url を使います。 open 後の最初のメッセージ 使える まだ誰か分からない接続を、サーバーが受け入れることになります。短い期限内に有効な auth が来なければ閉じます。 Sec-WebSocket-Protocol の行には、鋭い角があります。WHATWG のアルゴリズムでは、ページがサブプロトコルを提示したのにレスポンスがどれも名指ししなければ、接続は失敗します。RFC 6455 §4.1 単体では、クライアントが提示していない値をサーバーが返したときだけ失敗させればよいので、ブラウザのほうが厳しい挙動です。末尾の確認スクリプトで、Chrome でもそうなることを確かめられます。\n第2駅:ハンドシェイクは拒否できるが、ページには理由が聞こえない RFC 6455 §10.5 は、クライアント認証の方法を規定していません。サーバーは、汎用の HTTP サーバーが使えるものなら何でも使えます。つまり、不正なトークンを素の 401 で拒否してもかまいません。\nところが、ページから何が見えるかは別問題です。WHATWG の仕様は、スクリプトが区別できてはいけない失敗のリストを挙げています。その中に、オープニングハンドシェイクを完了しなかったサーバーが含まれており、区別できると、スクリプトがユーザーのローカルネットワークを探れてしまうから、という理由です。これらはすべて error と、コード 1006 の close イベントとして届きます。ヘッドレス Chrome 154 で、ハンドシェイクに 401 を返した場合と、提示したサブプロトコルを無視した場合を試すと、まさにそうなりました。\nプローブ(ヘッドレス Chrome 154) opened close コード reason サーバーが bearer を選び、open 後に 4401 で閉じる yes 4401 token expired サーバーがサブプロトコルを返さない no 1006 (空) サーバーがアップグレードに HTTP 401 を返す no 1006 (空) ここから実務上の帰結が1つ出ます。拒否されたハンドシェイクと、ネットワーク障害は、ページから見て同じです。 クライアントは 1006 を見たら「トークンが悪いのかもしれない」と考え、トークンを1回更新して1回だけ再試行し、それでも駄目なら待ち時間を置くのが現実的です。また、ページに「トークンの期限が切れた」ことを伝えたいなら、いったん接続を受け入れてから、アプリケーション用のコードで閉じる必要があります。RFC 6455 §7.4.2 は 4000〜4999 を私的利用に予約しており、この記事の 4401 はそこから取っています。(1006 自体は、線路の上には流れません。§7.4.1 は「異常終了」を示すために予約しています。)\n第3駅:101 のあと、サーバーが覚えているのは判定結果 サーバーが 101 を返したあとの接続は、フレームが流れるバイト列にすぎません。どのフレームにも認証情報は載りません。サーバーの手元に残るのはハンドシェイク時に保存したもので、たいてい socket.claims = { sub, scope, exp } のような形です。\n第4駅:3種類のずれ トークンとソケットのずれ方は3通りあり、扱いやすさはそれぞれ違います。\n期限切れ。 事前に分かります。サーバーのタイマーで扱えます。 失効(revocation)。 事前には分かりません。誰かが知らせるか、自分で見に行かない限り、サーバーには分かりません。 権限の変更。 同じく事前には分かりません。ロールの変更、スコープの縮小、アカウントの停止などです。 更新プロトコルが主に解けるのは、1つ目だけです。残りの2つに対して更新ができるのは、遅れの上限を決めることまでです。ソケットは現在のトークンより長生きできないので、最悪でも「残りの有効期間」で済みます。それでも長すぎるなら、有効期間を短くするか、自分のシステムから切断を押し込みます(たとえば、ユーザーがログアウトしたら、そのユーザーのソケットをすべて閉じる)。\n更新の3つの方法 選択を左右するのは、「切断のコストはどれくらいか」という1つの問いです。答えが「ほとんどない。再開は安い」なら、いちばん単純な仕組みで足ります。「大きい。再接続すると多くの購読をやり直すことになる」なら、接続を生かし続けたくなります。簡単な設計判断記録にまとめます。\nA. 閉じて再接続 B. in-band の auth メッセージ C. out-of-band の HTTP リクエスト 仕組み サーバーが期限で閉じる(コード 4401)。クライアントが新しいトークンを取り、つなぎ直す サーバーが reauth を送る。クライアントが同じソケットで新しいトークンを返す クライアントが、生きている接続を指名した普通の認証つき HTTP リクエストを送る ストリームの連続性 途切れる。再開の仕組みが要る(カーソルとスナップショット) 保たれる 保たれる トークンを解釈する場所 すでにあるハンドシェイクのコード WebSocket のメッセージハンドラー すでにある HTTP の認証スタック ルーティング どのノードでもよい 構造上、正しいノードに届く ソケットを持つノードに届く必要がある(スティッキーなルーティングか、pub/sub の中継) アプリのメッセージとの順序 該当なし 同じ接続なので、他のメッセージと順序が保たれる 別の経路なので、close と競合しうる 増えるプロトコルの面 なし メッセージ1種類 エンドポイント1つ 失敗したとき 再接続 A に落ちる A に落ちる 切断が高くつかないなら A にします。すでにメッセージのプロトコルがあり、ストリームを保ちたいなら B です。認証やレート制限が HTTP 層にあり、ソケットを持つノードにルーティングできるなら C です。どの場合も、A がフォールバックです。B か C のどの段階が失敗しても、サーバーは期限で閉じ、クライアントは再接続します。この性質があるから、更新は「新しい壊れ方」ではなく「最適化」になります。\n4つの規則を持つ、動くサーバー 新しいトークンをどの経路で運ぶにせよ、サーバーには同じ4つの規則が要ります。小さな規則ですが、それぞれが実際にありがちな間違いを防ぎます。\n期限はサーバーが強制する。 期限の少し前に新しいトークンを求め、来なければ期限で閉じます。クライアントが戻ってくることを当てにしません。 主体を変えさせない。 他人の有効なトークンは、このソケットの有効な更新ではありません。 スコープを広げさせない。 狭めるときはすぐ適用します。権限を増やすには新しいハンドシェイクが要るので、更新が黙って権限昇格にはなりません。 期限を縮めさせない。 古いトークン(有効だが現在のものより古い)は無視します。 規則2〜4は1つの関数です。規則1は別の関数です。\n// Renewal rules: same subject, never widen the scope, never shorten the lifetime. function accept(ws, c) { if (revoked.has(c.jti) || c.sub !== ws.claims.sub) return false; if (!c.scope.every((s) =\u0026gt; ws.claims.scope.includes(s))) return false; // more rights need a fresh handshake ws.claims = { ...ws.claims, scope: c.scope, exp: Math.max(ws.claims.exp, c.exp) }; // narrow now; extend, never shorten arm(ws); return true; } // The server, not the client, enforces the deadline: ask for credentials shortly before, close at expiry. function arm(ws) { clearTimeout(ws.warn); clearTimeout(ws.kill); const left = ws.claims.exp - Date.now(); ws.warn = setTimeout(() =\u0026gt; ws.readyState === 1 \u0026amp;\u0026amp; ws.send(JSON.stringify({ type: \u0026#34;reauth\u0026#34; })), Math.max(0, left - leadMs)); ws.kill = setTimeout(() =\u0026gt; ws.close(CLOSE_EXPIRED, \u0026#34;token expired\u0026#34;), left); } サーバーは、スケジュールをクライアントに任せず、exp - leadMs で自分から促します。クライアントの時計は間違っているかもしれませんが、期限を決めるのはサーバーの時計です。クライアントは、聞かれたときに答えるだけです。\nws.on(\u0026#34;message\u0026#34;, async (raw) =\u0026gt; { const m = JSON.parse(raw); if (m.type === \u0026#34;tick\u0026#34;) stats.ticks++; if (m.type === \u0026#34;reauth\u0026#34; \u0026amp;\u0026amp; strategy === \u0026#34;in-band\u0026#34;) ws.send(JSON.stringify({ type: \u0026#34;auth\u0026#34;, token: await getToken() })); if (m.type === \u0026#34;reauth\u0026#34; \u0026amp;\u0026amp; strategy === \u0026#34;out-of-band\u0026#34;) await fetch(`http://127.0.0.1:${port}/renew/${id}`, { method: \u0026#34;POST\u0026#34;, headers: { authorization: `Bearer ${await getToken()}` } }); }); 戦略 B は in-band の分岐、戦略 C は out-of-band の分岐です(サーバー側は、下のリストの /renew/ ハンドラーです)。戦略 A は、再接続する既定の close ハンドラーです。3つに共通するのは最後の行で、何が起きても、ソケットが閉じたら再接続します。\ntoken.mjs:署名つきトークンの簡易版(説明用) // A toy signed token: base64url(JSON claims) + \u0026#34;.\u0026#34; + HMAC. Illustration only; use a real JWT/PASETO library in production. import { createHmac, randomUUID, timingSafeEqual } from \u0026#34;node:crypto\u0026#34;; const SECRET = \u0026#34;demo-secret\u0026#34;; const b64 = (s) =\u0026gt; Buffer.from(s).toString(\u0026#34;base64url\u0026#34;); const sign = (p) =\u0026gt; createHmac(\u0026#34;sha256\u0026#34;, SECRET).update(p).digest(\u0026#34;base64url\u0026#34;); export const mint = (sub, ttlMs, scope = [\u0026#34;read\u0026#34;]) =\u0026gt; { const p = b64(JSON.stringify({ sub, scope, exp: Date.now() + ttlMs, jti: randomUUID() })); return `${p}.${sign(p)}`; }; export const verify = (token) =\u0026gt; { const [p, s] = String(token).split(\u0026#34;.\u0026#34;); if (!p || !s || s.length !== sign(p).length || !timingSafeEqual(Buffer.from(s), Buffer.from(sign(p)))) return null; const claims = JSON.parse(Buffer.from(p, \u0026#34;base64url\u0026#34;)); return claims.exp \u0026gt; Date.now() ? claims : null; }; server.mjs:ハンドシェイク、3つの戦略、期限の強制 // A WebSocket server that authenticates at the handshake and then keeps authorizing for the life of the connection. import http from \u0026#34;node:http\u0026#34;; import { WebSocketServer } from \u0026#34;ws\u0026#34;; import { verify } from \u0026#34;./token.mjs\u0026#34;; export const CLOSE_EXPIRED = 4401, CLOSE_FORBIDDEN = 4403; // 4000-4999: private use (RFC 6455 section 7.4.2) export function startServer({ leadMs = 150, revoked = new Set() } = {}) { const conns = new Set(); const wss = new WebSocketServer({ noServer: true, handleProtocols: () =\u0026gt; \u0026#34;bearer\u0026#34; }); const server = http.createServer(async (req, res) =\u0026gt; { // Strategy C (out of band): an ordinary authenticated HTTP request that names a live connection. if (req.method === \u0026#34;POST\u0026#34; \u0026amp;\u0026amp; req.url.startsWith(\u0026#34;/renew/\u0026#34;)) { const claims = verify((req.headers.authorization ?? \u0026#34;\u0026#34;).replace(/^Bearer /, \u0026#34;\u0026#34;)); const conn = [...conns].find((c) =\u0026gt; c.id === req.url.slice(7)); if (!claims || !conn) { res.writeHead(401).end(); return; } res.writeHead(accept(conn, claims) ? 204 : 403).end(); return; } res.writeHead(404).end(); }); server.on(\u0026#34;upgrade\u0026#34;, (req, socket, head) =\u0026gt; { const offered = String(req.headers[\u0026#34;sec-websocket-protocol\u0026#34;] ?? \u0026#34;\u0026#34;).split(/,\\s*/); // [\u0026#34;bearer\u0026#34;, \u0026#34;\u0026lt;token\u0026gt;\u0026#34;] const claims = verify(offered[1]); if (!claims || revoked.has(claims.jti)) { socket.end(\u0026#34;HTTP/1.1 401 Unauthorized\\r\\nContent-Length: 0\\r\\n\\r\\n\u0026#34;); return; } wss.handleUpgrade(req, socket, head, (ws) =\u0026gt; { ws.id = new URL(req.url, \u0026#34;http://x\u0026#34;).searchParams.get(\u0026#34;id\u0026#34;); ws.claims = claims; conns.add(ws); ws.on(\u0026#34;close\u0026#34;, () =\u0026gt; { conns.delete(ws); clearTimeout(ws.warn); clearTimeout(ws.kill); }); ws.on(\u0026#34;message\u0026#34;, (raw) =\u0026gt; { // Strategy B (in band) let m; try { m = JSON.parse(raw); } catch { return ws.close(1008); } if (m.type === \u0026#34;auth\u0026#34;) { const c = verify(m.token); if (!c || !accept(ws, c)) ws.close(CLOSE_FORBIDDEN, \u0026#34;renewal rejected\u0026#34;); } }); arm(ws); wss.emit(\u0026#34;ready\u0026#34;, ws); }); }); // Renewal rules: same subject, never widen the scope, never shorten the lifetime. function accept(ws, c) { if (revoked.has(c.jti) || c.sub !== ws.claims.sub) return false; if (!c.scope.every((s) =\u0026gt; ws.claims.scope.includes(s))) return false; // more rights need a fresh handshake ws.claims = { ...ws.claims, scope: c.scope, exp: Math.max(ws.claims.exp, c.exp) }; // narrow now; extend, never shorten arm(ws); return true; } // The server, not the client, enforces the deadline: ask for credentials shortly before, close at expiry. function arm(ws) { clearTimeout(ws.warn); clearTimeout(ws.kill); const left = ws.claims.exp - Date.now(); ws.warn = setTimeout(() =\u0026gt; ws.readyState === 1 \u0026amp;\u0026amp; ws.send(JSON.stringify({ type: \u0026#34;reauth\u0026#34; })), Math.max(0, left - leadMs)); ws.kill = setTimeout(() =\u0026gt; ws.close(CLOSE_EXPIRED, \u0026#34;token expired\u0026#34;), left); } return { server, wss, conns }; } demo.mjs:500 ms のトークンで3つの戦略を実行 // Three ways to keep a connection alive past its token. Run: npm i ws \u0026amp;\u0026amp; node demo.mjs import WebSocket from \u0026#34;ws\u0026#34;; import { mint } from \u0026#34;./token.mjs\u0026#34;; import { startServer, CLOSE_EXPIRED } from \u0026#34;./server.mjs\u0026#34;; const TTL = 500, RUN = 2600; // tiny lifetimes so that the demo takes seconds const { server, wss } = startServer({ leadMs: 150 }); await new Promise((r) =\u0026gt; server.listen(0, \u0026#34;127.0.0.1\u0026#34;, r)); const port = server.address().port; wss.on(\u0026#34;ready\u0026#34;, (ws) =\u0026gt; { let n = 0; ws.tick = setInterval(() =\u0026gt; ws.readyState === 1 \u0026amp;\u0026amp; ws.send(JSON.stringify({ type: \u0026#34;tick\u0026#34;, n: ++n })), 100); ws.on(\u0026#34;close\u0026#34;, () =\u0026gt; clearInterval(ws.tick)); }); const getToken = async () =\u0026gt; mint(\u0026#34;alice\u0026#34;, TTL); // stands in for your token endpoint async function run(strategy) { const stats = { connections: 0, closes: [], ticks: 0 }; let ws, stop = false, id = 0; const open = async () =\u0026gt; { stats.connections++; id++; ws = new WebSocket(`ws://127.0.0.1:${port}/?id=${id}`, [\u0026#34;bearer\u0026#34;, await getToken()]); ws.on(\u0026#34;message\u0026#34;, async (raw) =\u0026gt; { const m = JSON.parse(raw); if (m.type === \u0026#34;tick\u0026#34;) stats.ticks++; if (m.type === \u0026#34;reauth\u0026#34; \u0026amp;\u0026amp; strategy === \u0026#34;in-band\u0026#34;) ws.send(JSON.stringify({ type: \u0026#34;auth\u0026#34;, token: await getToken() })); if (m.type === \u0026#34;reauth\u0026#34; \u0026amp;\u0026amp; strategy === \u0026#34;out-of-band\u0026#34;) await fetch(`http://127.0.0.1:${port}/renew/${id}`, { method: \u0026#34;POST\u0026#34;, headers: { authorization: `Bearer ${await getToken()}` } }); }); ws.on(\u0026#34;close\u0026#34;, (code) =\u0026gt; { stats.closes.push(code); if (!stop) open(); }); // every strategy falls back to reconnect ws.on(\u0026#34;error\u0026#34;, () =\u0026gt; {}); }; await open(); await new Promise((r) =\u0026gt; setTimeout(r, RUN)); stop = true; ws.close(); await new Promise((r) =\u0026gt; setTimeout(r, 50)); return stats; } for (const s of [\u0026#34;reconnect\u0026#34;, \u0026#34;in-band\u0026#34;, \u0026#34;out-of-band\u0026#34;]) { const { connections, closes, ticks } = await run(s); console.log(`${s.padEnd(12)} connections=${connections} server closes with 4401=${closes.filter((c) =\u0026gt; c === CLOSE_EXPIRED).length} ticks received=${ticks}`); } server.close(); server.closeAllConnections(); 実行結果 デモは、トークンの有効期間を 500 ms にして、各戦略を 2.6 秒ずつ走らせます。効果が数秒で見えるようにするためです。1回の実行結果です。\nreconnect connections=6 server closes with 4401=5 ticks received=20 in-band connections=1 server closes with 4401=0 ticks received=25 out-of-band connections=1 server closes with 4401=0 ticks received=25 数字はこの簡易な環境のもので、手元では変わります。見てほしいのは形です。再接続では、サーバーが 4401 で5回閉じ、クライアントは6本の接続を開きました。in-band と out-of-band は、1本の接続のままでした。再接続の実行では tick の受信数も少なくなっています。接続と接続の間にいた時間は、受信していない時間だからです。実際のシステムでは、その間にサーバーが作ったものを再生するのが再開の仕組みの仕事で、戦略 A にそれが要る理由です。\n性質をテストで固定する 上の性質は、1つずつテストで固定しています。下の環境で9つのテストが通りました。\nreauth.test.mjs:規則ごとに1つのテスト import assert from \u0026#34;node:assert/strict\u0026#34;; import { test, before, after } from \u0026#34;node:test\u0026#34;; import WebSocket from \u0026#34;ws\u0026#34;; import { mint, verify } from \u0026#34;./token.mjs\u0026#34;; import { startServer, CLOSE_EXPIRED, CLOSE_FORBIDDEN } from \u0026#34;./server.mjs\u0026#34;; const revoked = new Set(); const { server, conns } = startServer({ leadMs: 100, revoked }); let port; before(async () =\u0026gt; { await new Promise((r) =\u0026gt; server.listen(0, \u0026#34;127.0.0.1\u0026#34;, r)); port = server.address().port; }); after(() =\u0026gt; { server.close(); server.closeAllConnections(); }); const sleep = (ms) =\u0026gt; new Promise((r) =\u0026gt; setTimeout(r, ms)); const connect = (token) =\u0026gt; new Promise((resolve, reject) =\u0026gt; { const ws = new WebSocket(`ws://127.0.0.1:${port}/?id=${Math.random()}`, [\u0026#34;bearer\u0026#34;, token]); ws.closed = new Promise((r) =\u0026gt; ws.on(\u0026#34;close\u0026#34;, (code) =\u0026gt; r(code))); ws.on(\u0026#34;open\u0026#34;, () =\u0026gt; resolve(ws)); ws.on(\u0026#34;error\u0026#34;, reject); }); test(\u0026#34;without renewal the server closes with 4401 at expiry\u0026#34;, async () =\u0026gt; { const ws = await connect(mint(\u0026#34;alice\u0026#34;, 300)); assert.equal(await ws.closed, CLOSE_EXPIRED); }); test(\u0026#34;a valid in-band renewal keeps the connection past the original expiry\u0026#34;, async () =\u0026gt; { const ws = await connect(mint(\u0026#34;alice\u0026#34;, 300)); ws.send(JSON.stringify({ type: \u0026#34;auth\u0026#34;, token: mint(\u0026#34;alice\u0026#34;, 2000) })); await sleep(500); assert.equal(ws.readyState, WebSocket.OPEN); ws.close(); }); test(\u0026#34;a token for another subject is refused\u0026#34;, async () =\u0026gt; { const ws = await connect(mint(\u0026#34;alice\u0026#34;, 2000)); ws.send(JSON.stringify({ type: \u0026#34;auth\u0026#34;, token: mint(\u0026#34;bob\u0026#34;, 4000) })); assert.equal(await ws.closed, CLOSE_FORBIDDEN); }); test(\u0026#34;a renewal cannot widen the scope\u0026#34;, async () =\u0026gt; { const ws = await connect(mint(\u0026#34;alice\u0026#34;, 2000, [\u0026#34;read\u0026#34;])); ws.send(JSON.stringify({ type: \u0026#34;auth\u0026#34;, token: mint(\u0026#34;alice\u0026#34;, 4000, [\u0026#34;read\u0026#34;, \u0026#34;write\u0026#34;]) })); assert.equal(await ws.closed, CLOSE_FORBIDDEN); }); test(\u0026#34;a narrower scope is applied immediately\u0026#34;, async () =\u0026gt; { const ws = await connect(mint(\u0026#34;alice\u0026#34;, 2000, [\u0026#34;read\u0026#34;, \u0026#34;write\u0026#34;])); ws.send(JSON.stringify({ type: \u0026#34;auth\u0026#34;, token: mint(\u0026#34;alice\u0026#34;, 100, [\u0026#34;read\u0026#34;]) })); // narrower and older await sleep(100); const server_side = [...conns].find((c) =\u0026gt; c.claims.sub === \u0026#34;alice\u0026#34; \u0026amp;\u0026amp; c.claims.scope.length === 1); assert.ok(server_side, \u0026#34;the server now holds the narrower scope\u0026#34;); assert.equal(ws.readyState, WebSocket.OPEN); ws.close(); }); test(\u0026#34;a stale token cannot shorten the lifetime\u0026#34;, async () =\u0026gt; { const ws = await connect(mint(\u0026#34;alice\u0026#34;, 2000)); ws.send(JSON.stringify({ type: \u0026#34;auth\u0026#34;, token: mint(\u0026#34;alice\u0026#34;, 100) })); // valid, but older than the current expiry await sleep(400); assert.equal(ws.readyState, WebSocket.OPEN); ws.close(); }); test(\u0026#34;revocation does not cut off an open connection before it expires\u0026#34;, async () =\u0026gt; { const old = mint(\u0026#34;alice\u0026#34;, 600); const ws = await connect(old); revoked.add(verify(old).jti); // revoke the token the connection was admitted with await sleep(200); assert.equal(ws.readyState, WebSocket.OPEN); // still open: nothing re-checks revocation by itself assert.equal(await ws.closed, CLOSE_EXPIRED); // it ends at expiry, the worst-case revocation delay }); test(\u0026#34;a revoked token is refused as a renewal\u0026#34;, async () =\u0026gt; { const ws = await connect(mint(\u0026#34;alice\u0026#34;, 2000)); const renewal = mint(\u0026#34;alice\u0026#34;, 4000); revoked.add(verify(renewal).jti); ws.send(JSON.stringify({ type: \u0026#34;auth\u0026#34;, token: renewal })); assert.equal(await ws.closed, CLOSE_FORBIDDEN); }); test(\u0026#34;a revoked token cannot be admitted\u0026#34;, async () =\u0026gt; { const t = mint(\u0026#34;alice\u0026#34;, 2000); revoked.add(verify(t).jti); await assert.rejects(connect(t), /401/); }); ドキュメントとして読む価値があるのは、次の2つです。\na stale token cannot shorten the lifetime:サーバーは有効なトークンを受け付けますが、期限を前に戻しません。 revocation does not cut off an open connection before it expires:このテストは、あえて悪い挙動を確認しています。トークンを失効させても、期限が来るまで開いているソケットは何も変わりません。これは、この記事のどの更新戦略でも買うことになる失効の遅れです。あとで気づくのではなく、最初から要件として書いておきます。 失敗モード 失敗 何が起きるか どうするか reauth が来たときにトークンのエンドポイントが遅い、または落ちている 更新が届かず、サーバーが期限で 4401 を返して閉じる クライアントが再接続する。leadMs は、トークンのエンドポイントのタイムアウトに往復時間を足した値より大きくする。 クライアントの時計がずれている クライアントが更新時刻を自分で決めると、遅れたり、短い間隔で繰り返したりする 上のように、サーバーに促させる。 ソケットが開いている間にトークンを失効させた ソケットは期限まで生き続ける 有効期間を短くする。または、失効を行うシステムから、主体を指定してソケットを閉じる。 権限を狭めた 古いスコープがソケットに残る 次の更新で狭いスコープを適用する。待てないなら切断を押し込む。 別の主体の更新 チェックがなければ、あるユーザーが別のユーザーのソケットを延長できる sub を比べ、4403 で閉じる(テスト:a token for another subject is refused)。 更新でスコープが広がる 黙った権限昇格 拒否し、新しいハンドシェイクを要求する(テスト:a renewal cannot widen the scope)。 古い更新の再送 期限を縮めたり、リセットしたりしうる exp を延ばさないトークンは無視する。 クエリ文字列のトークン アクセスログやプロキシに残る サブプロトコル、最初のメッセージ、Origin 検証つきの Cookie のどれかにする。 Cookie 認証 SameSite が止めない限り、別サイトのページが、ユーザーの Cookie でソケットを開ける ハンドシェイクで Origin を検証する(RFC 6455 §10.2)。Origin はブラウザに対する防御で、認証の代わりにはなりません。 最初のメッセージで認証する方式 未認証のソケットが資源を占める 短い期限内に有効な auth が来なければ閉じる。 ハンドシェイクを 401 で拒否した ページには 1006 が見え、ネットワークエラーと区別できない トークンを1回更新して1回だけ再試行してから、待ち時間を置く。 どの更新方式を使うか、使わないか in-band か out-of-band の更新を使うのは、ソケットがトークンより長生きするのが普通で、再接続が高くつくときです。 素の再接続を使うのは、ソケットを張り直すのが安い、またはすでに再開の経路があるときです。いちばん単純で、いつでもフォールバックになります。 更新の仕組みを作らないのは、接続が数分で終わるときです。ハンドシェイクで認証し、ソケットは終わらせ、クライアントに新しいトークンで再接続させます。 更新を失効の代わりにしないでください。「すべての端末からログアウト」を数秒で効かせたいなら、必要なのは長い更新プロトコルではなく、切断を押し込む経路です。 試してみる 以下はすべて Node.js 20 で実行しました。18 以降なら動くはずです。所要時間は10分ほどです。\nmkdir reauth \u0026amp;\u0026amp; cd reauth npm init -y \u0026amp;\u0026amp; npm i ws@8 # 上のリストから token.mjs, server.mjs, demo.mjs, reauth.test.mjs, browser_check.mjs を保存する node demo.mjs # 3つの戦略。接続数を比べる node --test reauth.test.mjs # 9つのテスト node browser_check.mjs # Chrome か Chromium が必要 実際のブラウザが何を報告するかは、下のスクリプトで確かめられます。3つのエンドポイント(bearer を選んであとで 4401 で閉じるもの、サブプロトコルを選ばないもの、アップグレードに 401 を返すもの)を持つサーバーを起動し、ヘッドレス Chrome のページから開いて、ページに見えた code、reason、wasClean を表示します。google-chrome が PATH にない場合は、CHROME=/path/to/chrome を設定してください。\nbrowser_check.mjs:ハンドシェイクを拒否されたとき、ページには何が見えるか // What can a page\u0026#39;s script see when a WebSocket handshake or connection is rejected? // Run: npm i ws \u0026amp;\u0026amp; node browser_check.mjs (needs Chrome/Chromium; set CHROME=/path if needed) import http from \u0026#34;node:http\u0026#34;; import { spawn } from \u0026#34;node:child_process\u0026#34;; import { WebSocketServer } from \u0026#34;ws\u0026#34;; const page = `\u0026lt;script\u0026gt; const results = {}; function probe(name, url, protocols) { return new Promise((resolve) =\u0026gt; { const info = { opened: false, error: false, close: null }; const ws = protocols ? new WebSocket(url, protocols) : new WebSocket(url); ws.onopen = () =\u0026gt; { info.opened = true; info.protocol = ws.protocol; }; ws.onerror = () =\u0026gt; { info.error = true; }; ws.onclose = (e) =\u0026gt; { info.close = { code: e.code, reason: e.reason, wasClean: e.wasClean }; results[name] = info; resolve(); }; }); } (async () =\u0026gt; { const base = \u0026#34;ws://\u0026#34; + location.host; await probe(\u0026#34;subprotocol echoed, then server closes 4401\u0026#34;, base + \u0026#34;/ok\u0026#34;, [\u0026#34;bearer\u0026#34;, \u0026#34;tok\u0026#34;]); await probe(\u0026#34;subprotocol offered but not selected\u0026#34;, base + \u0026#34;/noproto\u0026#34;, [\u0026#34;bearer\u0026#34;, \u0026#34;tok\u0026#34;]); await probe(\u0026#34;HTTP 401 at the upgrade\u0026#34;, base + \u0026#34;/unauth\u0026#34;); await fetch(\u0026#34;/report\u0026#34;, { method: \u0026#34;POST\u0026#34;, body: JSON.stringify(results, null, 1) }); })(); \u0026lt;/script\u0026gt;`; const http_ = http.createServer(async (req, res) =\u0026gt; { if (req.url === \u0026#34;/\u0026#34;) { res.writeHead(200, { \u0026#34;content-type\u0026#34;: \u0026#34;text/html\u0026#34; }); return res.end(page); } if (req.url === \u0026#34;/report\u0026#34;) { let b = \u0026#34;\u0026#34;; for await (const c of req) b += c; console.log(b); res.end(\u0026#34;ok\u0026#34;); clearTimeout(guard); chrome.kill(); http_.close(); http_.closeAllConnections(); } }); const wss = new WebSocketServer({ noServer: true, handleProtocols: (set, req) =\u0026gt; (req.url === \u0026#34;/noproto\u0026#34; ? false : \u0026#34;bearer\u0026#34;) }); http_.on(\u0026#34;upgrade\u0026#34;, (req, socket, head) =\u0026gt; { if (req.url === \u0026#34;/unauth\u0026#34;) { socket.end(\u0026#34;HTTP/1.1 401 Unauthorized\\r\\nContent-Length: 0\\r\\nConnection: close\\r\\n\\r\\n\u0026#34;); return; } wss.handleUpgrade(req, socket, head, (ws) =\u0026gt; setTimeout(() =\u0026gt; ws.close(4401, \u0026#34;token expired\u0026#34;), 100)); }); await new Promise((r) =\u0026gt; http_.listen(0, \u0026#34;127.0.0.1\u0026#34;, r)); const guard = setTimeout(() =\u0026gt; { console.log(\u0026#34;timeout\u0026#34;); chrome.kill(); process.exit(1); }, 20000); const chrome = spawn(process.env.CHROME ?? \u0026#34;google-chrome\u0026#34;, [ \u0026#34;--headless=new\u0026#34;, \u0026#34;--no-sandbox\u0026#34;, \u0026#34;--disable-gpu\u0026#34;, \u0026#34;--user-data-dir=./ws-check-profile\u0026#34;, `http://127.0.0.1:${http_.address().port}/`, ], { stdio: \u0026#34;ignore\u0026#34; }); 開いている間は、信頼され続ける WebSocket は、ある一瞬に認証され、開いている間ずっと信頼されます。それが許容できるかは、3つで決まります。ソケットがトークンより長生きできる時間(あなたが決める期限で決まる)、失効をどれだけ速く効かせたいか(有効期間か、切断を押し込む経路で決まる)、再接続がどれだけ高いか(更新の経路を決める)。期限での再接続はいつも正しく、ときにそれで十分です。in-band や out-of-band の更新はその上の最適化で、あらゆる失敗が最後に再接続へ落ちる限り、安全なままでいられます。\n環境と、確認していないこと コードはすべて、Linux 6.12(x86-64)、Node.js 20.19、ws 8.22、ヘッドレスの Google Chrome 154 で実行しました。第2駅の表のブラウザの挙動は、その Chrome のバージョンで観察したものです。仕様についての記述は、リンク先のドキュメントから 2026-10-04 時点で引用しています。トークンの形式は、説明用に作った HMAC の簡易な構成です。デモは短い有効期間、単一プロセス、TLS なしで動かしており、どの数字もベンチマークではありません。戦略 C のマルチノードのルーティング、アイドル接続を閉じるプロキシ、Chrome 以外のブラウザは確認していません。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/websocket-reauthentication-on-long-lived-connections/","summary":"101 レスポンスのあと、どのリクエストにも認証情報は載りません。サーバーが覚えているのは証拠ではなく判定結果だけです。1つのトークンをブラウザのハンドシェイクから追いかけ、ページから何が見えて何が見えないかを確かめたうえで、再接続・帯域内(in-band)・帯域外(out-of-band)の3つの更新方法を、動くサーバーとテストとヘッドレス Chrome の確認つきで比べます。","title":"トークンが切れても生き続けるWebSocketを、どう扱うか"},{"content":"モバイルクライアントが、リレーを介して遠隔マシン上の常駐プロセスと通信しています。リレーは Cloudflare Durable Objects 上の薄い Worker で、マシンとの WebSocket と各クライアントとの WebSocket を保持し、メッセージを転送するだけです。複数の画面が「何が変わったか」を知る必要があります。最初の実装はタイマーで答えていました。会話一覧を数秒ごとに読み直し、マシンがオンラインかどうかをコントロールプレーンに数秒おきに問い合わせ、作業ツリーの diff はタブを切り替えるたびに読み直します。\nこれは動きますし、最初の実装として正しい選択です。ただ、通信がメッセージ単位で課金されるようになると、正しいとは言えなくなります。\nコストモデルが強いること Durable Objects では、WebSocket の受信メッセージが課金対象になり、送信メッセージは対象になりません（料金ドキュメント）。リレー越しのポーリングは、クライアントからのリクエストとマシンからのレスポンスの組なので、受信メッセージ2件に当たります。1画面で T 秒ごとにポーリングすると、1時間あたり 7200 / T 件です。ユーザーが机の上に開いたまま置いているだけの画面でも、この数になります。受信メッセージのたびにオブジェクトのハンドラが動くため、ポーリングを続けるとオブジェクトがハイバネートできなくなります（WebSocket のベストプラクティスを参照）。\n実機での削減量は測っていません。以下に出てくる数字は、実行されなくなった定期リクエストの数から出した計算であり、観測した割合ではありません。\n自然な置き換えは変更通知の購読です。会話が変化したことはマシン側がすでに知っているので、問い合わせを待たずに upsert イベントを push できます。ただし、この置き換えが本当に改善になるかどうかは、次の3つのトレードオフで決まります。\n「live」の正しさ。 ストリームが存在するだけでポーリングを止めると、ストリームが存在しても何も流れてこない状況で、遅延の問題が正しさの問題になります。 共有。 5つのフックがそれぞれ自分のストリームを開くのは、5つのポーリングより悪くなります。 ストリームが何の証拠になるか。 ストリームは別のシグナル（ここではプレゼンス）の代わりになることがありますが、それは理由を説明できる場合に限ります。 仕組み: キーごとに1本の共有ストリームと、正直な live フラグ キーごとにストリームを1本だけ持ち、参照カウントで共有します。ストリームはまずスナップショットを1回送り、その後は変更のたびに upsert を送ります。利用側は { live, items } を受け取ります。保険のポーリングが参照してよいのは live だけです。\nexport interface Item { id: string; updatedAt: string; } export interface FeedState\u0026lt;T extends Item\u0026gt; { /** The stream has delivered data, so the fallback poll may stand down. */ live: boolean; /** Last known list, newest first. null until the first snapshot. */ items: T[] | null; } export interface Handlers\u0026lt;T extends Item\u0026gt; { snapshot(items: T[]): void; upsert(item: T): void; closed(): void; } /** Opens the stream; resolves to a function that stops it. */ export type Open\u0026lt;T extends Item\u0026gt; = (h: Handlers\u0026lt;T\u0026gt;) =\u0026gt; Promise\u0026lt;() =\u0026gt; void\u0026gt;; const RETRY_BASE_MS = 1_000; const RETRY_MAX_MS = 30_000; const HEALTHY_MS = 30_000; フィード本体は小さなステートマシンです。重要な点は publish、connect、closed ハンドラの3か所にあります。\nclass Feed\u0026lt;T extends Item\u0026gt; { state: FeedState\u0026lt;T\u0026gt; = { live: false, items: null }; refs = 0; listeners = new Set\u0026lt;(s: FeedState\u0026lt;T\u0026gt;) =\u0026gt; void\u0026gt;(); private byId = new Map\u0026lt;string, T\u0026gt;(); private stopStream: (() =\u0026gt; void) | null = null; private retry: ReturnType\u0026lt;typeof setTimeout\u0026gt; | null = null; private attempts = 0; private openedAt = 0; private disposed = false; constructor(private open: Open\u0026lt;T\u0026gt;) { this.connect(); } private publish(live: boolean) { // The last known list outlives a drop: stale rows beat an empty pane, // and the fallback poll refreshes them while `live` is false. const items = this.byId.size === 0 \u0026amp;\u0026amp; this.state.items === null ? null : [...this.byId.values()].sort((a, b) =\u0026gt; b.updatedAt.localeCompare(a.updatedAt)); this.state = { live, items }; for (const listener of this.listeners) listener(this.state); } private connect() { let ended = false; const drop = () =\u0026gt; { if (ended || this.disposed) return; ended = true; this.stopStream = null; // A connection that stayed healthy earns a fresh backoff. if (this.openedAt \u0026amp;\u0026amp; Date.now() - this.openedAt \u0026gt;= HEALTHY_MS) this.attempts = 0; this.openedAt = 0; this.publish(false); const delay = Math.min(RETRY_BASE_MS * 2 ** this.attempts, RETRY_MAX_MS); this.attempts += 1; this.retry = setTimeout(() =\u0026gt; { this.retry = null; this.connect(); }, delay); }; this.open({ // `live` is published by the first frame, never by \u0026#34;the socket opened\u0026#34;. snapshot: items =\u0026gt; { if (ended || this.disposed) return; this.byId = new Map(items.map(i =\u0026gt; [i.id, i])); this.publish(true); }, upsert: item =\u0026gt; { if (ended || this.disposed) return; this.byId.set(item.id, item); this.publish(true); }, closed: drop, }).then( stop =\u0026gt; { if (ended || this.disposed) return stop(); this.stopStream = stop; this.openedAt = Date.now(); }, drop, // failing to open is a drop like any other ); } dispose() { this.disposed = true; if (this.retry) clearTimeout(this.retry); this.stopStream?.(); } } live は open ではなく最初のフレームから決めます。 open() が解決しても、購読が受理されたことしか分かりません。リレーが受理してもマシンが応答しないことはありますし、プロキシがバッファしていることもあります。open の時点で live を立てると、開いたのに何も届かないストリームが、保険のポーリングを永久に黙らせてしまいます。live をスナップショットから公開すれば、「live」は「データが届いた」という意味になります。保険のポーリングが知りたいのはこの性質だけです。\n最後に分かっていた一覧は、切断後も残します。 空の画面より古い行のほうがましです。live が false の間は、保険のポーリングがそれを更新します。そのため、コードは drop() をまたいで byId を保持しています。\nバックオフは、接続が健全だったと確認できたときだけリセットします。 open に成功するたびに試行回数を戻すと、接続が不安定に切れ続ける場合に、最短の間隔で永久に再試行します。ここでは、直前の接続が HEALTHY_MS 以上続いたときにだけカウンタを戻します。\nopen の失敗も切断として扱います。 Promise の reject 経路も同じ drop を呼びます。ストリームを開けない状況（トランスポートがまだない、バインディングがない）では、描画経路に例外を投げるのではなく、ポーリングに劣化させるべきです。\n共有は参照カウント付きのレジストリで行います。最後の解放でストリームを止めます。\nconst feeds = new Map\u0026lt;string, Feed\u0026lt;any\u0026gt;\u0026gt;(); /** Share one stream per key; the last release stops it. */ export function acquire\u0026lt;T extends Item\u0026gt;( key: string, open: Open\u0026lt;T\u0026gt;, listener: (s: FeedState\u0026lt;T\u0026gt;) =\u0026gt; void, ): () =\u0026gt; void { let feed = feeds.get(key) as Feed\u0026lt;T\u0026gt; | undefined; if (!feed) { feed = new Feed(open); feeds.set(key, feed); } feed.refs += 1; feed.listeners.add(listener); listener(feed.state); const held = feed; return () =\u0026gt; { held.listeners.delete(listener); if (--held.refs \u0026gt; 0) return; held.dispose(); feeds.delete(key); }; } アプリがバックグラウンドにある間は、すべての購読を停止します。React Native の AppState に従う形です。iOS はバックグラウンドのアプリを suspend するので、ソケットを保持し続けることは期待できません。中途半端に死んだ接続より、復帰時にきれいに再接続するほうが安全です。\n保険のポーリングは、ストリームの関数にする ポーリングが動く理由は1つだけです。ストリームがデータを届けていないときです。それ以外ではすべて止めます。\nconst STEPS_MS = [5_000, 10_000, 20_000, 30_000]; /** Fallback poll cadence: widen while nothing changes, snap back on change. */ export class FallbackSchedule { private step = 0; next(changed: boolean): number { this.step = changed ? 0 : Math.min(this.step + 1, STEPS_MS.length - 1); return STEPS_MS[this.step]!; } reset() { this.step = 0; } } /** false = do not poll at all. */ export function pollInterval(o: { live: boolean; foreground: boolean; schedule: FallbackSchedule; }): number | false { if (!o.foreground) return false; // nothing is on screen if (o.live) return false; // the stream is the source of truth return STEPS_MS[0]!; // caller widens it with schedule.next(changed) } このファイルには3つのルールがあります。非表示ならポーリングしません。live ならポーリングしません。live でないならポーリングし、変化がないあいだは間隔を広げます。間隔を広げる仕組みは、購読がそもそも使えない環境（古いホスト、制限されたネットワーク）で効きます。これがないと、購読できないクライアントは最速の周期で永久にポーリングし続けます。代償は、検知の遅れが最大の間隔までに伸びることです。\nテストには fake timer を使いました（Node の node:test の mock.timers で、Date もモックされます）。残す価値があったケースは次のとおりです。スナップショットのない open は live ではない。切断すると一覧は残り、live が false になり、1秒後、2秒後に再試行する。健全なまま続いた接続は遅延をリセットする。2つの利用側が1本のストリームを共有し、最後の解放で止まる。\nストリームが別のシグナルの代わりになるとき クライアントは、マシンがオンラインか、現在のユーザーにまだ紐づいているかを知るために、コントロールプレーンへも定期的にポーリングしていました。ストリームは代替に見えます。マシンがオンラインで、権限が有効なときにしか流れないからです。しかし、このシステムではストリームが「オフラインになったこと」を通知することはできません。オンライン状態は「最終確認時刻」から計算していて、マシンが消えても何も書き込まれないため、push すべきイベントがないのです。\nそこでルールをこうしました。ストリームが流れている間は、ストリーム自体がプレゼンスの証拠になるので、コントロールプレーンへのポーリングは止めます。ストリームが切れたら live が false になり、ポーリングが再開し、オフライン状態はこれまでどおり1ポーリング間隔以内に現れます。プレゼンスが変わったときにデータベースから通知を出す案も検討しましたが、削減できる量に対してマイグレーションとトリガーのリスクが見合わないため、見送りました。\nポーリングがあったから隠れていたバグ ポーリングをなくしたことで、その副作用への隠れた依存が見つかりました。トランスポートは、見たことのある会話を覚えておき、それを使って会話ごとの購読を始めます。一覧ストリームで届いた会話は覚えられていませんでした。ポーリングが動いている間は気づけず、ポーリングを止めると、新しく始まった会話はストリームで届くのに購読できなくなりました。直し方は、ストリームで届いた項目もトランスポートに記録することです。回帰テストでは、ストリーム経由でしか届かなかった項目をトランスポートに渡します。\nこれは一般的な危険です。冗長なポーリングは、状態機械のレビューされていない2つ目の実装でもあります。消すときは、それが偶然何を埋めていたかを grep してください。\n通知で「古い」かどうかを決める 同じ考え方は、キャッシュした読み取りにも使えます。作業ツリーの diff 画面は、以前は表示するたびに、一定の鮮度期間を過ぎていれば再取得していました。いまは画面が見えている間、ホストのファイル変更通知を購読します。通知ストリームが生きている間は、キャッシュした diff を古いとは見なしません。生きていないときは、通常の時間ベースの鮮度判定に戻ります。\nTanStack Query で書くと、staleTime: live ? Infinity : undefined に加えて、通知が来たときの invalidate です。画面を切り替えても再取得が走らなくなり、ユーザーが画面を見ている間に行った編集が反映されるようになりました。ポーリング版では反映されませんでした。\nこの変更でもう1つ。購読が拒否された場合は、アプリの前面復帰やネットワーク復旧のような「今すぐ再試行」の合図があっても、一定時間待ってから再試行します。こうした合図は一時的な失敗のためのものです。拒否は一時的ではないので、合図に従うと密なループになります。\n使わないほうがよい場合 「ストリームが落ちている」と「何も起きていない」を区別できない。 スナップショットを先に送るプロトコルやハートビートがなければ、無音のストリームは静かなストリームと見分けがつかないので、ポーリングを残す必要があります。 イベントの発生源が、表示している内容をカバーしていない。 ここでプレゼンスが成り立ったのは、ストリームが何を意味するかという論証があったからです。そうした論証がないなら、そのシグナルはポーリングし続けてください。 再開用のカーソルがなく、イベントを取りこぼしうる。 購読は、真のデータ源に対する最適化です。再接続時に真実を読み直せないなら、ポーリングをバグに置き換えただけです。この側面は、関連記事『リアルタイム通知による取り直しをまとめる: キーの絞り込みと先頭・末尾の窓』で扱います。 ポーリングがもともと安い。 メッセージ課金のないトランスポートなら、5行の interval のほうが、参照カウント付きの共有レジストリより優れています。 検証したことと、していないこと 上のユニットテストは通っています。ストリームが開いたのに何も届かない失敗ケースも含みます。端末でのメッセージ数やバッテリーは測っていません。ここで述べた削減は、なくなった定期リクエストの数から導いたものです。\nまとめ 購読がポーリングの代わりになるのは、live が「ソケットが開いた」ではなく「データが届いた」を意味するときだけです。 キーごとに1本のストリームを参照カウントで共有し、切断後も最後の一覧を残し、バックオフは健全な接続を確認できたときだけリセットします。 保険のポーリングは、live と表示状態だけの関数にして、変化がないあいだは間隔を広げます。 ストリームを別のシグナルの代わりにしてよいのは、理由を説明できるときだけです。検知の遅れが弱くなることは、受け入れたうえで書き残します。 ポーリングを消すと、システムのほかの部分が頼っていた副作用も消えます。出荷前に探してください。 ","permalink":"https://blog.yusukeikoma.com/ja/posts/replace-polling-with-subscriptions-keep-poll-as-fallback/","summary":"メッセージ単位で課金されるリレー越しに、モバイルクライアントがタイマー駆動のポーリングから共有の変更通知へ移った経緯を扱います。難しいのはストリームそのものではなく、保険のポーリングをいつ止めてよいかを正直に決めることです。","title":"ポーリングを変更通知の購読に置き換え、保険として残す"},{"content":"ユーザーのマシン上の Go プロセスが、リレーを介してブラウザやスマートフォンにイベントを流します。リレーは Cloudflare Durable Objects 上の JavaScript の Worker で、マシンとの間に1本のコントロール用 WebSocket を持ち、クライアントへフレームを転送します。ここを通るものには2つの制約があります。\nメッセージは従量課金です。 Durable Objects では WebSocket の受信メッセージが課金対象になり（リクエスト数に対して20:1 の比率が適用されます）、送信メッセージは課金されません。イベントごとに1フレームを送る送信側は、イベントごとに支払います。 フレームには上限があります。 接続の両端は最大フレームサイズに合意しています。それより大きな応答は分割しなければならず、分割には上限が必要です。ないと、不正な相手のせいで再構成側のメモリ確保が際限なく増えます。 さらに3つ目があります。Worker、マシン側のプロセス、ブラウザのクライアントは、それぞれ別にデプロイされます。どの瞬間にも、どれかは古いバージョンです。新しいフレーム形式は、前提にするのではなく、ネゴシエーションしなければなりません。\nこの記事では4つの仕組みと、本当の教訓になった3つのバグを順に見ます。\n1. 独立した3つのフラッシュ条件でイベントをバッチにする 小さなイベント（トークンの差分、状態変化）の流れは、メッセージ単位の課金にとって最悪のケースです。送信側はイベントをためておき、1フレームにまとめて送れます。引き換えに遅延が増えるので、窓で上限を決めます。\n次の3つのうちどれか1つで、フラッシュします。\n時間: 空のバッチに最初のイベントが入ったときに窓が開き、短い固定の遅延の後に閉じます。これが遅延の上限です。 件数: 1フレームあたりの最大イベント数です。受信側が検証すべき量の上限にもなります。 バイト数: フレーム上限よりずっと小さいバイト予算です。次のイベントで予算を超えるなら、先にフラッシュして、そのイベントから新しいバッチを始めます。 // Frame is one message on the shared control socket. type Frame struct { Type string `json:\u0026#34;type\u0026#34;` StreamID string `json:\u0026#34;stream_id\u0026#34;` Event string `json:\u0026#34;event,omitempty\u0026#34;` // single-event form (older peers) Data string `json:\u0026#34;data,omitempty\u0026#34;` Events []Event `json:\u0026#34;events,omitempty\u0026#34;` // batched form, 2+ events } 動作が決まるのは転送ループです。窓、件数、バイト数の上限は呼び出し側が渡す設定から来ます。適切な値は、フレーム上限と許容できる遅延によって変わります。\n// Forward batches events from in until it closes or ctx ends. Whatever was // accepted is flushed before returning, on every exit path. func Forward(ctx context.Context, cfg Config, streamID string, in \u0026lt;-chan Event, write WriteFunc) (err error) { var batch []Event size := 0 timer := time.NewTimer(cfg.Window) timer.Stop() defer timer.Stop() flush := func(wctx context.Context) error { if len(batch) == 0 { return nil } f := Frame{Type: \u0026#34;stream_data\u0026#34;, StreamID: streamID} if len(batch) == 1 { // a batch of one stays wire-compatible with old peers f.Event, f.Data = batch[0].Name, batch[0].Data } else { f.Events = batch } batch, size = nil, 0 timer.Stop() return write(wctx, f) } // The terminal frame must follow every accepted event, even when ctx has // been cancelled. A write on a cancelled context would also tear down the // shared socket, so use a detached context with its own deadline. defer func() { wctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second) defer cancel() err = errors.Join(err, flush(wctx)) }() for { select { case \u0026lt;-ctx.Done(): return ctx.Err() case \u0026lt;-timer.C: if err := flush(context.WithoutCancel(ctx)); err != nil { return err } case ev, ok := \u0026lt;-in: if !ok { return nil } n := len(ev.Name) + len(ev.Data) if size+n \u0026gt; cfg.MaxBytes { // would overflow: ship what we have first if err := flush(context.WithoutCancel(ctx)); err != nil { return err } } if len(batch) == 0 { timer.Reset(cfg.Window) // the window opens with the first event } batch = append(batch, ev) size += n if size \u0026gt;= cfg.MaxBytes || len(batch) \u0026gt;= cfg.MaxEvents { if err := flush(context.WithoutCancel(ctx)); err != nil { return err } } } } } このループには、間違えやすい点が3つあります。\n1件のバッチは、バッチにせずに送ります。 ちょうど1件のフレームは、従来の単一イベントの形式を使います。バッチというものを知らない相手でも読めるので、静かなストリームではバッチ化が見えません。新しい形式が現れるのは、節約できるときだけです。\n窓は ticker ではなく、最初のイベントで開きます。 常時動く ticker だと、tick の直後に届いたイベントに窓1つ分の遅延が丸ごとのってしまいますし、何も起きていないときにも goroutine が起きます。バッチが空から非空になったときにだけリセットするタイマーなら、アイドル中のコストはゼロです。\n終端フレームは、受理したすべてのイベントの後に来なければなりません。 ストリームが終わるとき（ソースが閉じた、資格情報が切れた、クライアントがキャンセルした）、受信側が最後に見るのは、すでに受理したバッチ、その次に終端マーカーであるべきです。defer したフラッシュはすべての終了経路で動きます。そこでは context.WithoutCancel と専用の短いデッドラインを使います。この切り離したコンテキストは飾りではありません。ここで使っている WebSocket ライブラリでは、キャンセル済みのコンテキストで書き込みを行うと接続が閉じられ、しかもこの接続はすべてのストリームで共有されています。キャンセル済みのコンテキストでフラッシュすると、バッチが落ちるうえ、ほかのストリームまで巻き込まれます。回帰テストは、2件が保留されている状態でキャンセルし、両方が1フレームで届いたこと、そして write に渡されたコンテキストがキャンセルされていなかったことを確かめます。\nfunc TestCancelFlushesAcceptedEventsOnADetachedContext(t *testing.T) { s := \u0026amp;sink{} in := make(chan Event) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done \u0026lt;- Forward(ctx, Config{Window: time.Hour, MaxEvents: 100, MaxBytes: 1 \u0026lt;\u0026lt; 20}, \u0026#34;s1\u0026#34;, in, s.write) }() in \u0026lt;- Event{Data: \u0026#34;a\u0026#34;} in \u0026lt;- Event{Data: \u0026#34;b\u0026#34;} cancel() err := \u0026lt;-done if !errors.Is(err, context.Canceled) { t.Fatalf(\u0026#34;err = %v\u0026#34;, err) } if len(s.frames) != 1 || len(s.frames[0].Events) != 2 { t.Fatalf(\u0026#34;accepted events were dropped: %+v\u0026#34;, s.frames) } if s.errs[0] != nil { t.Fatalf(\u0026#34;flush ran on a cancelled context: %v\u0026#34;, s.errs[0]) } } 2. 収まるものは分割しない 小さなチャンクサイズを超えるものをすべて分割する設計は、最初に思いつく案ですが、無駄が多くなります。チャンクはそれぞれ課金対象のメッセージですし、数百キロバイト程度の中くらいのペイロードはよくあります。よりよいルールは、リレー自身のメタデータ用の予約を引いた上限に収まるなら、1フレームで送り、それを超える場合だけ分割することです。\n// ShouldChunk keeps medium payloads in one frame: only fragment what cannot // fit under the hard cap once the relay\u0026#39;s own metadata is reserved. func ShouldChunk(payload, hardCap, reserve int, peerChunks bool) (bool, error) { if payload \u0026lt;= hardCap-reserve || !peerChunks { if payload \u0026gt; hardCap { return false, errors.New(\u0026#34;frame too large and peer cannot reassemble\u0026#34;) } return false, nil } return true, nil } 最後の分岐は、混在バージョンで重要です。再構成できない相手には、ハード上限に収まるなら単一フレームを送り、収まらないならエラーにします。黙って捨てられる断片は送りません。\n3. 送信側だけでなく、再構成側にも上限をかける 分割はリスクを受信側に移します。受信側は、チャンクを敵対的な入力として扱わなければなりません。接続ごとに課す上限は、1メッセージあたりのチャンク数、0から始まる厳密な増加順、再構成後の合計バイト数、そして最初のチャンクから測ったデッドラインです。\nfunc Split(id string, payload []byte, size int) []Chunk { count := (len(payload) + size - 1) / size out := make([]Chunk, 0, count) for i := 0; i \u0026lt; count; i++ { end := min((i+1)*size, len(payload)) out = append(out, Chunk{ID: id, Index: i, Count: count, Data: payload[i*size : end]}) } return out } // Assembler enforces bounds per connection: chunk count, strict order, // reassembled bytes, and a deadline from the first chunk. type Assembler struct { MaxChunks, MaxBytes int Deadline time.Duration Now func() time.Time id string next int buf []byte started time.Time } テストのケースは、上限があることの意味そのものを突きます。インデックス0から始まらなければならない、欠番を拒否する、デッドラインを過ぎたら拒否する、バイトの上限を超えたら拒否する、の4つです。\n4. 能力をネゴシエーションし、リプレイをまたいで持ち運ぶ 新しいフレーム形式は、受信側がそれを理解すると宣言したときにだけ使います。受信側は購読リクエストで accept_batches（分割の場合は accept_chunks）を立てます。このフィールドを知らない送信側は無視して、単一イベントのフレームを送り続けます。難しいのは中間のリレーです。Durable Objects はハイバネートするので、メモリ上の状態には頼れません。そのため、購読をソケットに紐づけて永続化し、マシン側が再接続したときにそれを再生します。保存された購読が受信側のフラグを忘れていると、再生のたびにストリームが黙ってバッチなしに劣化します。何も壊れないので誰も気づきませんが、請求書が来るまで、コストの悪化は見えません。\n// Worker side (JavaScript on Durable Objects): the peer\u0026#39;s capabilities are // stored with the subscription, so a replay after hibernation or a reconnect // asks the producer for exactly what the consumer can read. export function rememberSubscription(attachment, value, limit = 16) { const subscriptions = (attachment.subscriptions || []).filter(s =\u0026gt; s.requestID !== value.request_id); if (subscriptions.length \u0026lt; limit) { subscriptions.push({ requestID: value.request_id, path: value.path, acceptChunks: value.accept_chunks === true, acceptBatches: value.accept_batches === true, }); } return { ...attachment, subscriptions }; } export function replayFrames(attachment, connectionID) { return (attachment.subscriptions || []).map(s =\u0026gt; ({ type: \u0026#39;subscribe\u0026#39;, connection_id: connectionID, request_id: s.requestID, path: s.path, accept_chunks: s.acceptChunks === true, accept_batches: s.acceptBatches === true, })); } 再生される subscribe メッセージにフラグが入っているので、送信側は受信側が読める形式を保ったまま再開します。アタッチメントにも上限があります。小さく保ち、購読の数にも上限を置きます。受信側は、受け取ったものを信用する前に検証します。\nconst MAX_EVENTS = 32; /** Normalise single and batched frames into an ordered list of events. */ export function streamFrames(message: StreamMessage) { const { events } = message; if ( events !== undefined \u0026amp;\u0026amp; (!Array.isArray(events) || events.length \u0026lt; 1 || events.length \u0026gt; MAX_EVENTS || events.some(e =\u0026gt; !e || typeof e.data !== \u0026#39;string\u0026#39; || (e.event !== undefined \u0026amp;\u0026amp; typeof e.event !== \u0026#39;string\u0026#39;))) ) { throw new Error(\u0026#39;invalid stream batch\u0026#39;); // caller closes the socket } const list: Array\u0026lt;{ event?: string; data?: string }\u0026gt; = events ?? [message]; // The terminal flag rides on the frame, so it applies after the last event only. return list.map((e, i) =\u0026gt; { const last = i === list.length - 1; return { event: e.event, data: e.data, closed: last \u0026amp;\u0026amp; (message.closed ?? false), error: last ? (message.error ?? null) : null, }; }); } 不正なバッチは、一部だけ適用せず、ソケットを閉じます。終端フラグとエラーは、フレームの最後のイベントにだけ適用します。これで、1. で述べた順序の保証が受信側でも保たれます。（このサンプルの初期版では、フラグをバッチのすべてのイベントに付けていました。型検査は通りましたが、このために書いたテストで間違いに気づきました。）\ntest(\u0026#39;the terminal flag applies after the last event of a batch only\u0026#39;, () =\u0026gt; { const out = streamFrames({ events: [{ data: \u0026#39;a\u0026#39; }, { data: \u0026#39;b\u0026#39; }], closed: true }); assert.deepEqual(out.map(e =\u0026gt; e.closed), [false, true]); }); この方式なら、ロールアウトの順序は関係ありません。経路上のどこかがフラグより古いなら、フラグは届かず、送信側はバッチを使わず、受信側のノーマライザは単一イベントのフレームを1件のバッチとして扱います。\n3つのバグ 消えたゼロ。 チャンクには chunk_index が付きます。最初の版では、Go の構造体でこれに omitempty を付けていました。int では omitempty はゼロを落とします（encoding/json）。そのため、分割されたすべてのメッセージの最初のチャンクが、インデックスなしで送られました。ブラウザは再構成できず、大きなペイロードはずっと読み込み中のままでした。小さなペイロードでは起きないので、バグは生き延びました。直し方は、インデックスとカウントに omitempty を付けない専用のワイヤ用構造体にすることと、生の JSON をデコードしてキーが存在することを確かめるテストです。同じ構造体を通して往復するテストでは、欠落を検出できません。サンプルのテストでは、素朴な構造体でバグを再現してから、修正を確かめます。\nfunc TestChunkIndexZeroIsOnTheWire(t *testing.T) { if wireHasIndex(naiveChunk{ID: \u0026#34;x\u0026#34;, Index: 0, Count: 2}) { t.Fatal(\u0026#34;expected omitempty to drop index 0 (this is the bug)\u0026#34;) } if !wireHasIndex(Chunk{ID: \u0026#34;x\u0026#34;, Index: 0, Count: 2}) { t.Fatal(\u0026#34;explicit tag must keep index 0\u0026#34;) } } 制御チャネルに載る巨大なペイロード。 チャンク化すると、巨大な diff もリレー経由で送れてしまいます。そこから得る教訓として、それは間違いです。レビュー画面のよりよい直し方は、ペイロードを送らないことでした。変更されたファイルをメタデータだけで一覧にし、ファイル1件の本文は必要になったときに取得し、diff を作る子プロセスの出力は、全部バッファしてからではなく、読み取りながら上限をかけます（上限に達したら kill して回収します）。チャネルの容量は、使ってよいという許可ではありません。\n古いホスト。 分割に対応する前のホストは、大きすぎる読み取りに response_too_large のエラーで答えます。履歴をページングする呼び出し側は、より小さいページで再試行します。この互換経路は昔からあり、意図的に不格好なままです。新しい経路を厳格にできるのは、この経路があるからです。\n使わないほうがよい場合 遅延に厳しい単一イベントのストリーム。 バッチ化は最大で窓1つ分の遅延を足します。入力のエコーでは、退行になります。 コストより、ストリームをまたいだ順序のほうが重要。 バッチが保つのは、1つのストリームの中の順序です。ストリームをまたいで特定の順に並べる必要があるものは、別の設計が必要です。 すべての相手が一斉にアップグレードされる。 送信側、リレー、受信側を1つの単位としてデプロイするなら、ネゴシエーションは省けます。ネゴシエーションはロールアウトのためにあり、フラグはどれもテストすべき対象が増えるということです。 課金が問題ではない。 バッチ化と分割は複雑さです。メッセージが無料のトランスポートに足してはいけません。 検証したことと、していないこと サンプルは go test -race（繰り返し実行）で動かし、TypeScript と JavaScript の部分は node:test で動かしています。本番の変更には独自のユニットテストがあり、終端フレームの順序についての回帰テストも含まれます。そのテストは修正前には失敗していました。実際のプラットフォーム上での挙動は検証していませんし、バッチ化でメッセージがどれだけ減るかも測っていません。言えるのは構造上のことだけです。以前はイベントごとに課金対象のメッセージを1件送っていた送信側が、いまは窓、件数、バイト予算のいずれかにつき最大1件を送ります。バッチが足す遅延は、最大でも窓1つ分です。\nまとめ バッチは独立した3つの条件（時間、件数、バイト数）で行い、超過するイベントの前にフラッシュします。 単一イベントはバッチにせず、上限から予約分を引いた値に収まるなら単一フレームを保ちます。 受理したイベントは、すべての終了経路で、切り離した上限付きのコンテキストを使って終端フレームの前にフラッシュします。 再構成側に上限を置きます。件数、順序、バイト数、デッドラインです。 リプレイをまたいで残るフラグでネゴシエーションし、ロールアウトの順序に依存しないようにします。 ゼロ値は明示的にシリアライズし、ワイヤ上の JSON をテストします。 ","permalink":"https://blog.yusukeikoma.com/ja/posts/batching-and-bounded-frames-on-a-metered-relay/","summary":"WebSocket のメッセージごとに課金され、フレームにも上限がある環境では、独立したフラッシュ条件を持つバッチ送信、上限より低い単一フレームのしきい値、上限付きの分割、混在バージョンのロールアウトを生き延びる能力フラグが必要です。難所は、終了時の順序と、ワイヤ上から消えるゼロ値です。","title":"従量課金のリレーでのバッチ送信とフレーム上限"},{"content":"keepalive は、通信が従量課金でクライアントがスマートフォンになるまで、解決済みの問題に見えます。そこには2つのコストが隠れています。1つ目は、一定間隔の ping が、すでにトラフィックを運んでいて生存を証明する必要のない接続でも、双方向に1メッセージずつ送ることです。2つ目は、誰も見ていない接続でも、念のため相手側のプロセスが接続を開き続けるので、ping を延々と送り続けることです。\nこの記事では両側を扱います。クライアントがソケットを死んだと判断する方法と、常駐するホストがそもそもソケットを必要としないと判断する方法です。\nクライアントの keepalive は何のためか 相手が FIN を送らずに消えた TCP 接続（ネットワークを切り替えたスマートフォン、マッピングを捨てた NAT、スリープしたノートPC）は、書き込みが失敗するかタイマーが発火するまで、アプリケーションからは開いたままに見えます。クライアントには自前のタイマーが必要です。要件は次の2つです。\n死んだ相手を、上限のある時間内に検知する。 接続が健全な間は、できるだけ安く済ませる。 ブラウザや React Native が提供する WebSocket には、プロトコルレベルの ping を送る API がありません。RFC 6455 §5.5.2 は Ping 制御フレームを定義していて、相手のスタックが自動で応答しますが、スクリプトからは送れません。したがって、スクリプトレベルの生存確認は、相手が応答しなければならないアプリケーションメッセージになります。それは実在するメッセージです。受信メッセージが課金されるリレー（Durable Objects の料金）では、課金対象です。\n（Durable Object を自分で制御できるなら、setWebSocketAutoResponse で、ハイバネートから起こさずに固定の文字列へ応答させられます。これで応答のコストは下がります。問い合わせ自体が無料になるわけではなく、相手が Durable Object でないなら何の助けにもなりません。）\nすべてのフレームが生存の証拠 設計の原則は、ping が証明するのは「バイトがまだ届いている」ことだけ、ということです。データフレームも pong と同様にそれを証明します。そこで次のようにします。\nどんな種類でも、フレームを受信したら、静止タイマーをリセットし、未解決の確認を解除する。 確認を送るのは、接続が少なくとも間隔の分だけ静かだったときだけ。 確認のデッドラインは、送った時点から測る。以降の tick で延ばさない。 ポリシー全体は、タイムスタンプの純粋関数です。そのため fake timer なしでテストできます。\nexport type Action = \u0026#39;wait\u0026#39; | \u0026#39;ping\u0026#39; | \u0026#39;close\u0026#39;; /** * Called from a fixed-interval tick. Any application frame counts as proof of * life, so only a quiet connection is probed, and a probe\u0026#39;s deadline is fixed * at the moment it was sent. */ export function heartbeatAction( lastReceivedAt: number, pendingPingAt: number | null, now: number, intervalMs: number, timeoutMs: number, ): Action { if (pendingPingAt !== null) { // A second probe never extends the first one\u0026#39;s deadline. return now - pendingPingAt \u0026gt;= timeoutMs ? \u0026#39;close\u0026#39; : \u0026#39;wait\u0026#39;; } return now - lastReceivedAt \u0026gt;= intervalMs ? \u0026#39;ping\u0026#39; : \u0026#39;wait\u0026#39;; } 数秒おきに何かが届くストリームは確認されないので、忙しい接続は何も支払いません。そのテストは、あえて地味にしてあります。間隔より速くフレームを流し、ping が1つも送られなかったことを確かめます。\ntick の算術 チェックは一定間隔の tick で動き、そこから、あまり書かれない帰結が出てきます。間隔が10秒、タイムアウトが25秒だとします（説明用の値で、推奨値ではありません）。tick の1ミリ秒後にフレームが届きます。次の tick では、静止時間が9.999秒で、間隔未満なので答えは wait です。その次の tick では19.999秒になり、答えは ping です。25秒のデッドラインはそこから始まります。\n最後のフレームから close の判断までの最悪ケースは、したがって間隔2つ分とタイムアウトです。この値なら45秒です。もっと短くしたいなら、間隔より細かく tick を回してください。確認を送るタイミングを決めるのは、tick の頻度ではなく静止のしきい値です。テストでは、20秒に送った確認がちょうど45秒で close になり、44.999秒では close にならないことを固定しています。\ntest(\u0026#39;tick granularity: traffic just after a tick delays the ping, not the deadline\u0026#39;, () =\u0026gt; { const received = 1; assert.equal(act(received, null, 10_000, I, T), \u0026#39;wait\u0026#39;); // only 9.999 s quiet assert.equal(act(received, null, 20_000, I, T), \u0026#39;ping\u0026#39;); // next tick pings const pingAt = 20_000; for (const now of [30_000, 40_000, 44_999]) assert.equal(act(received, pingAt, now, I, T), \u0026#39;wait\u0026#39;); assert.equal(act(received, pingAt, 45_000, I, T), \u0026#39;close\u0026#39;); // exactly timeout after send }); \u0026gt;= の比較も決定の1つです。厳密な \u0026gt; にすると、時計が粗い刻みで進む環境で、理由なく1 tick 遅れることがあります。\n遅れて届いた pong も pong 状態を持つラッパーでは、受信したすべてのフレームが onFrame を呼び、保留中の確認を解除します。デッドラインの後、次の tick より前に届いた pong も、普通のデータフレームも、どちらも接続を救います。確認が保留中の間に次の tick が来ても、2つ目は送らず、デッドラインも動かしません。\nexport class Heartbeat { private lastReceivedAt: number; private pendingPingAt: number | null = null; constructor( private now: () =\u0026gt; number, private intervalMs: number, private timeoutMs: number, ) { this.lastReceivedAt = now(); } /** Every parsed frame from the peer, not only pongs. */ onFrame() { this.lastReceivedAt = this.now(); this.pendingPingAt = null; } /** Run on a setInterval(intervalMs). */ tick(send: () =\u0026gt; void): Action { const now = this.now(); const action = heartbeatAction(this.lastReceivedAt, this.pendingPingAt, now, this.intervalMs, this.timeoutMs); if (action === \u0026#39;ping\u0026#39;) { this.pendingPingAt = now; send(); } return action; } } 実際のコードでは、2点を調整してください。now には壁時計ではなく単調増加するクロック（performance.now()）を使い、時刻の調整でタイムアウトが偽装されないようにします。また、モバイルのライフサイクルを考えます。アプリが suspend されている間は JavaScript のタイマーが動かないので、復帰後の最初の tick が、ずっと前に送った確認を見つけることがあります。このポリシーでは、その場合ソケットをすぐに閉じます。これは正しい結果です。suspend をまたいだ接続は、生きているより死んでいる可能性が高く、再接続の経路はすでにあります。\nアイドル時のスリープ: 誰にも必要とされない接続 もう半分は、ユーザーのマシン上で動くエージェントのような、常駐するホストプロセスです。クライアントが到達できるように、リレーへのアウトバウンドの WebSocket を持ち続けます。1日の大半は誰も見ていません。それでも接続は存在し、keepalive が必要で、リレーのリソースを占有します。\n目標は、誰かが必要としている間だけ接続を保持することです。仕組みには3つの要件があります。\nすでにある信号を使う。 ホストは、生きていることを伝えるために、コントロールプレーンを一定間隔で呼んでいます。その返信に、接続がいま必要かどうかのフィールドを1つ足します。そうすればスリープのためにポーリングも新しいエンドポイントも増えず、同じ呼び出しがホストを起こします。答えが元に戻るだけだからです。\n3値の答え。 「不要」と「不明」を区別できなければなりません。Go ではポインタにします。\n// HeartbeatReply is what the control plane answers on the heartbeat that // already exists. Needed is a pointer so an older server that does not send // the field is distinguishable from one that says \u0026#34;no\u0026#34;. type HeartbeatReply struct { Needed *bool } このフィールドが導入される前のサーバーは、フィールドを返さないので Needed が nil になり、ホストは従来どおり接続を保ちます。ハートビートの失敗も、誰もホストを必要としていない証拠ではないので、やはり接続を保ちます。切断するのは、明示的に「不要」と言われたときだけです。\n// Run keeps a connection only while the control plane says someone needs it. // The heartbeat doubles as the wake-up call, so sleeping costs no extra traffic. func Run(ctx context.Context, d Deps) { var cur *session defer func() { if cur != nil { cur.stop() } }() ticker := time.NewTicker(d.Every) defer ticker.Stop() for { reply, err := d.Heartbeat(ctx) // Unknown stays connected: an old server never sent the field, and a // failed heartbeat is not evidence that nobody needs us. needed := err != nil || reply.Needed == nil || *reply.Needed switch { case needed \u0026amp;\u0026amp; (cur == nil || !cur.running()): if cur != nil { cur.stop() } cur = start(ctx, d.Serve) case !needed \u0026amp;\u0026amp; cur != nil: cur.stop() cur = nil } select { case \u0026lt;-ctx.Done(): return case \u0026lt;-ticker.C: } } } セッションのコンテキストをキャンセルし、goroutine の終了を待つので（stop は done でブロックします）、スリープ中のホストには接続用の goroutine が残りません。接続がまだ必要なのに切れた場合は、次のハートビートで再開します。\nfunc TestOlderServerWithoutTheFieldKeepsTheConnection(t *testing.T) { starts, stops := runScript(t, []HeartbeatReply{{}, {}, {}}, nil, 3) if starts != 1 || stops != 1 { t.Fatalf(\u0026#34;starts=%d stops=%d\u0026#34;, starts, stops) } } 正直なコストモデル。 スリープは、遅延とトラフィックを交換します。スリープ中のホスト宛てに届いた仕事は、次のハートビートで答えが切り替わり、ホストが再接続するまで待たされます。遅延は最大でハートビート1回分に接続確立の時間を足したものです。スリープ中のホストに対して再試行する処理は、それより長い再試行の猶予が必要です。そうでないと、静かな期間の後の最初のリクエストが、少し待てば成功したのに失敗します。実際の変更でも、まさにこの理由で再試行の猶予を延ばす必要がありました。\nサンプルにはヒステリシスがありません。「必要」がハートビートごとに反転すると、ハートビートごとに接続と切断を繰り返し、つなぎっぱなしより悪くなります。復帰後に最低限の起動時間を設けるのが、よくある対処です。\n使わないほうがよい場合 両端がプロトコルレベルの ping/pong を話せる。 サーバー同士や Durable Object 同士なら、トランスポート自身の ping を使ってください。安く、アプリケーションコードも要りません。この設計は、ブラウザのスクリプトからは送れないために存在します。 短命な接続。 ソケットが数秒しか生きないなら、節約できるものがありません。 最初のバイトの遅延が要件。 スリープは、アイドル後の最初のリクエストに、最大でハートビート1回分の遅延を足します。許容できないなら、つないだままにして、コストを受け入れてください。 相乗りできる既存のハートビートがない。 スリープを実現するためだけにポーリングを足すと、節約分を使い切ります。 検証したことと、していないこと ハートビートのポリシーとスリープのループは、注入したクロックとスクリプト化した返信を使ったユニットテストで確かめています。長時間アイドルのままにした実際の接続のテストはなく、このしくみでトラフィックがどれだけ減るかも測っていません。ここでの主張は構造上のものです。忙しい接続は確認を送らず、コントロールプレーンが不要と言ったホストは接続を持ちません。\nまとめ スクリプトからプロトコルの ping は送れないので、生存確認はアプリケーションのメッセージになります。頻度を下げます。 受信したフレームをすべて生存の証拠とし、確認は静かな期間の後にだけ送り、デッドラインは送った時点から測ります。 間隔と同じ周期で tick すると、検知の最悪値は間隔2つ分とタイムアウトです。縮めたいなら tick を速くします。 既存のハートビートに「接続が必要か」を載せ、不明とエラーは「つないだまま」とします。 スリープの代償は、上限のある復帰の遅延です。再試行の猶予をそれに合わせて確保します。 ","permalink":"https://blog.yusukeikoma.com/ja/posts/liveness-without-pings-and-idle-sleep/","summary":"一定間隔の keepalive は、接続が忙しいときでも双方向に1メッセージずつ使います。受信したフレームをすべて生存の証拠として扱い、静かな接続だけを確認し、既存のハートビートで待機中のホストに切断してよいかを伝えれば、その大半を減らせます。代償は、上限のある復帰の遅延です。","title":"pingなしの生存確認とアイドル時のスリープ"},{"content":"症状は説明しやすく、原因は見つけにくいものでした。接続の相手側で資格情報が更新された後、モバイルクライアントが返信を受け取らなくなります。エラーも、ログも、クラッシュもありません。画面を開き直すと直ります。こういう回避策は、バグを長いあいだ隠します。\n原因は、クライアントのライフサイクルにあった1つの前提でした。自分で閉じたときでも、ソケットは閉じたことを教えてくれる、という前提です。\n失敗の流れ クライアントは WebSocket を1本持ち、その上で複数の論理ストリームを多重化していました。後始末は1か所、ソケットの close イベントハンドラにありました。\nアクティブなすべてのストリームに終了（closed）を伝え、持ち主が再購読できるようにする。 返信を待っていたすべてのリクエストを reject する。 接続オブジェクトを解放する。 相手側が資格情報をローテーションしてストリームを終えると、クライアントは設計どおり自分のソケットを閉じ、後始末が動くように close を待ちました。問題になった React Native のケースでは、アプリケーション自身が閉じたソケットがこのイベントを届けませんでした。そのため手順1が実行されず、ストリームの持ち主には伝わらず、再購読も起きず、返信は行き場を失いました。状態は正常に見えます。接続オブジェクトは存在し、ストリームは登録されたままで、何も届かなくなっただけです。\n確かに言えることを、慎重に整理します。仕様に沿った実装では、close イベントは接続が閉じたときに発火します。自分から閉じた場合は、クロージングハンドシェイクが完了するか、接続が破棄された後です（WHATWG の WebSocket 仕様、MDN の close イベント）。これは「即座」ではありませんし、相手が消えていれば長くかかることがあります。問題の実行環境については、観測された挙動と、それをモデル化したテストに頼っていて、文書化された保証に基づいているわけではありません。それでも、設計上の結論はどちらの場合でも成り立ちます。\n原則: イベントは報告し、決定は確定させる イベントハンドラは、自分に起きたことを知る場所としては正しい場所です。相手が閉じた、ネットワークが落ちた、プロトコルエラーが起きた。一方で、自分が下した決定を記録する場所としては適切ではありません。決定は、下した瞬間に状態を更新すべきです。「閉じてください」とお願いして、それが起きたという通知を待つ設計は、自分の制御フローを、自分で制御できないコールバックが届くかどうかに依存させます。\n変更は小さなものです。\n後始末を settle という1つの関数に移します。何度呼んでも安全で、実際の処理は1回しか行いません。 自分が始めていない切断のために、イベントハンドラからそれを呼びます。 自分で接続を終えると決めたときに必ず使う唯一のメソッド end() から、ソケットに close を依頼した直後に呼びます。 export interface SocketLike { readyState: number; close(code?: number, reason?: string): void; addEventListener(type: \u0026#39;close\u0026#39; | \u0026#39;error\u0026#39;, listener: () =\u0026gt; void): void; } export type StreamFrame = { closed: true; error: string } | { data: string }; export class Connection { readonly pending = new Map\u0026lt;string, { reject(e: Error): void }\u0026gt;(); readonly streams = new Map\u0026lt;string, (f: StreamFrame) =\u0026gt; void\u0026gt;(); private settled = false; constructor( private socket: SocketLike, private onListenerError: (e: unknown) =\u0026gt; void = e =\u0026gt; console.error(e), ) { // Still the path for closes we did not start: peer, network, server. socket.addEventListener(\u0026#39;close\u0026#39;, this.settle); socket.addEventListener(\u0026#39;error\u0026#39;, this.settle); } /** Idempotent: every path may call it, only the first does anything. */ private settle = () =\u0026gt; { if (this.settled) return; this.settled = true; // Take the work out of the tables first, then notify. A listener that // throws must not stop the others from hearing about it. const pending = [...this.pending.values()]; const streams = [...this.streams.values()]; this.pending.clear(); this.streams.clear(); for (const p of pending) this.guard(() =\u0026gt; p.reject(new Error(\u0026#39;connection closed\u0026#39;))); for (const onFrame of streams) this.guard(() =\u0026gt; onFrame({ closed: true, error: \u0026#39;connection closed\u0026#39; })); }; private guard(fn: () =\u0026gt; void) { try { fn(); } catch (e) { this.onListenerError(e); } } /** * Close the socket ourselves AND settle what rode it, now. The socket\u0026#39;s * close event is a notification we may or may not get; it is not the * source of truth for what we just decided to do. */ end(code: number, reason: string): void { try { this.socket.close(code, reason); } finally { this.settle(); } } } 正しさを支えているのは、次の3点です。\nsettle は冪等です。 イベントを届けるランタイムでは、end() の後でイベントがやはり届くことがあります。2回目の呼び出しは何もしてはいけません。そうでないと、すべてのストリームの持ち主が2回通知され、2回再購読するかもしれません。FakeSocket(true) を使うテストは、イベントを遅れて届けて、通知がちょうど1回であることを確かめます。\n「後」ではなく finally。 close() は例外を投げることがあります（仕様上、不正なコードや長すぎる理由に対して投げますし、ラッパーが独自の理由で投げることもあります）。投げる呼び出しの後ろに後始末を並べると、デバッグしているまさにその経路で後始末が飛ばされます。try { close } finally { settle } なら後始末は無条件に実行され、例外は呼び出し元に伝わります。\n通知する前に、テーブルから取り出す。 リスナーは任意のコードを実行します。持ち主は closed に反応して、新しい接続を始めたり、何かを登録したりするかもしれません。settle が生きているマップを反復していたら、それに触るリスナーは、中途半端に後始末された状態を見ることになります。先にエントリを取り出してテーブルをクリアすれば、リスナーが見るのは、空になって終わった接続です。リスナーはそれぞれ自分の try/catch の中で実行します。そうしないと、例外を投げる1つのバグったリスナーが、その後ろに登録されたすべてのリスナーにとって同じ「黙って止まる」状態になります。これも、形を変えた同じバグです。\n直す前に再現する テスト用のダブルは、close を絶対に発火しないように設定できるソケットです。最初のテストは、元のバグを実行可能な記述として残したものです。\nclass FakeSocket implements SocketLike { readyState = 1; private listeners: Record\u0026lt;string, Array\u0026lt;() =\u0026gt; void\u0026gt;\u0026gt; = {}; constructor(private emitsCloseEvent: boolean) {} addEventListener(type: \u0026#39;close\u0026#39; | \u0026#39;error\u0026#39;, l: () =\u0026gt; void) { (this.listeners[type] ??= []).push(l); } close() { this.readyState = 3; if (this.emitsCloseEvent) queueMicrotask(() =\u0026gt; this.listeners[\u0026#39;close\u0026#39;]?.forEach(l =\u0026gt; l())); } fire(type: \u0026#39;close\u0026#39; | \u0026#39;error\u0026#39;) { this.listeners[type]?.forEach(l =\u0026gt; l()); } } test(\u0026#39;relying on the event alone loses the frame (the original bug, reproduced)\u0026#39;, async () =\u0026gt; { const sock = new FakeSocket(false); const conn = new Connection(sock); const frames = subscribe(conn); sock.close(); // what the old code did: close and wait for the event await new Promise(r =\u0026gt; setTimeout(r, 20)); assert.deepEqual(frames, []); // nobody was told; nothing resubscribes }); このテストは、あえて間違った挙動を表明しています。ソケットを直接閉じて待つと、ストリームにはフレームが1つも届きません。その隣には、修正がなければ失敗し、修正があれば通るテストがあります。\ntest(\u0026#39;a self-initiated close settles streams even if the socket never fires close\u0026#39;, () =\u0026gt; { const conn = new Connection(new FakeSocket(false)); const frames = subscribe(conn); conn.end(1000, \u0026#39;renew\u0026#39;); assert.deepEqual(frames, [{ closed: true, error: \u0026#39;connection closed\u0026#39; }]); }); 修正は「end() を足す」だけではありません。接続を終えるすべてのコード経路が end() を通ることが修正です。更新、期限切れ、アイドルタイムアウト、ハートビートのタイムアウト、認証のタイムアウト、拒否、不正なフレームです。どこか1か所でも迂回すると、その原因に対してこの停止が戻ってきます。生の socket.close() をクラスの外から呼べないようにしておくと、将来の変更がこれを飛ばせなくなります。\nこの種のバグが生き延びる理由 黙って止まる障害は、普段のシグナルでは捕まりません。例外は出ず、タイムアウトも起きず、エラーを見張る監視には、静かで健全な接続に見えます。状態機械には、設計上は close の後に到達できないはずなのに、実際には到達できてしまう状態（「接続済みで、ストリームが登録されている」）があり、その不変条件を確かめるものがありませんでした。2つの習慣が役に立ちます。\n不変条件を書き出します。接続が終わったら、その上に登録されたすべてのリクエストとストリームに、ちょうど1回、通知される。 そして、前提を1つずつ破るソケットのダブルで、その不変条件をテストします。close イベントがない、遅れる、重複する、close なしの error、close() が例外を投げる、の5つです。 通知が失われうる場所で、「持ち主が通知を待つ」設計は臭いと考えます。終わったことを伝えられない持ち主は、永遠に待ちます。通知を確実にできないなら、待つ側にデッドラインを与えてください。 使わないほうがよい場合 原因によって後始末が変わる。 ユーザー起点の close とネットワーク障害で扱いを変える必要がある場合（たとえば障害のときだけ再接続する）は、settle に理由を渡し、最初の原因を優先します。遅れて届いた error イベントで、意図した close を上書きしてはいけません。 クロージングハンドシェイクの完了を待つ必要がある。 相手が close を確認した後でしか行えない処理、たとえば相手が close を見るまで保持している資源の解放があるなら、やはりイベントが必要です。早めに確定させるのは、自分側の帳簿のためであり、相手が知っていることの証明ではありません。 新しい接続が古い接続の状態を再利用する。 置き換えの接続をすぐに作り、古い接続と可変のマップを共有していると、古いソケットから遅れて届いたイベントが新しい状態を壊します。サンプルのように状態を接続オブジェクトごとに持つか、もう現在のものではないソケットからのイベントは無視してください。 検証したことと、していないこと 挙動は、上のソケットのダブルに対して検証しています。回帰テストは、イベントだけに頼る版では失敗し、end() を使う版では通ります。修正後に実機で長時間のソークは行っていません。したがって、実機で数分間の更新がすべてきれいになったとは主張せず、言えるのは、後始末が、届いていなかったイベントに依存しなくなったということだけです。\nまとめ イベントは、自分で下した決定の根拠としては弱い情報源です。決めた時点で状態を確定させます。 冪等な settle を、end() とイベントハンドラの両方から呼べば、遅れたイベント、重複したイベント、来ないイベントのすべてを扱えます。 close() が例外を投げても後始末が動くよう try/finally を使い、通知の前に状態を切り離して、リスナーが中途半端な接続を見ないようにします。 接続を終えるすべてのコード経路を end() に通します。 前提を1つずつ破るダブルで不変条件をテストし、元の失敗はテストとして残します。 ","permalink":"https://blog.yusukeikoma.com/ja/posts/when-the-close-event-never-comes/","summary":"自分のソケットの close イベントを待ってから後始末するクライアントは、そのイベントが届かないと、黙って止まることがあります。接続を終えると決めた時点で状態を確定させ、後始末を冪等にし、イベントは自分が始めていない切断のために残します。","title":"close イベントが来ないとき"},{"content":"一人がその文書を開き、書き終わってから保存するなら、編集の実体と保存は同じものでよいです。分かれ始めるのは、複数人が同時に開き、それぞれの入力が相手へ届き、あとから開く人は保存されたものを読むときです。保存の行を編集の正本にすると、後から書いた方が先の入力を消します。開いた瞬間の空の文書を正本として書くと、同期が終わる前の空白が、すでにあった中身を消します。開いている人へ更新が届く経路と、落ちたあとに読み返すための保存は、失敗の条件が違います。\nこの構成では、開いているあいだの実体は、競合なく合流する文書です。保存は、その文書の写しを別に置きます。同期が終わる前は、空の文書を見せませんし、空のまま書きません。更新は、その文書を開いている接続のすべてに届けます。\n課題 同時に開いている人が二人いると、保存の一行を取り合います。それぞれの手元が全文を書き戻すと、後の書き込みが先の編集を上書きします。片方が保存に失敗すると、その人の手元には残っていて、次に開いた人の文書にはありません。\n開いた直後の手元の文書は空です。空をそのまま見せると、読む人は文書が空だったと思います。そこへ入力すると、空から始まった編集が、すでにあった中身と合流します。合流の結果が空に近いものになることもあります。見せなくても、裏でその空を保存すれば、保存してあった中身は空白で置き換わります。同期の相手に届かないときにこれを行うと、相手が持っていた文書を、届かなかった側の空が消します。\n更新を保存経由で配ると、開いている人は、保存が終わるまで相手の入力を見ません。保存が遅いと、編集が遅いように見えます。保存が失敗すると、編集も失敗したように見えます。見たいのは、開いている接続のあいだで更新が届いたかであり、写しが残ったかではありません。\n逆に、メモリの上だけに文書を持ち、保存を持たないと、プロセスが落ちた瞬間に合流の結果が消えます。平文から作り直すと、構造や、まだ平文に落ちていない合流が落ちます。毎回平文から組み直すと、同時に開いていた人の状態と、次に開いた人の状態が別の文書になります。\n書けない人を、接続から外すと、その人は開いているあいだの更新を見ません。接続は許して、その人の更新も保存まで届かせると、書けない権限が保存の側で穴になります。見ることと、書くことは、同じ接続の上で別の事実です。\n切断は、最後の保存の待ちの途中で起きます。待ちだけに頼ると、閉じた直後の入力が写しに残りません。待ちを無くして入力のたびに保存すると、保存が再び編集の経路になります。\n制約 この構成が置いた制約は、次のとおりです。\n共同編集の正本は、同期を受け持っている側のメモリにある文書です。文書ごとに一つの名前を持ち、その名前を開いている接続は、同じメモリ上の文書を見ます。合流はそこで行います。保存の行では行いません。\n保存は別の操作です。符号化された状態と、平文の写しを、最後の変更から短い待ちのあとで書きます。同期の処理は、保存の完了を待ちません。開いている接続への配送も、保存の成否を待ちません。\n同期が終わる前の文書は、保存しません。閉じるときも、その場を離れるときも、同じです。空の手元の文書を、届かない同期の代わりに正本へ書いてはいけません。\n見せ方も分けます。符号化された状態をすでに持っているときは、接続の前にそれを文書へ適用し、最初の描画を空にしません。持っていないときは、同期が終わるまで平文の写しを見せ、空の共同編集面を文書として出しません。写しが無い、本当に空の文書だけが、空として見えます。\n更新は、その文書を開いている接続のすべてに送ります。保存されたあとに取りに来る人へ届ければよい、にはしません。\n書けない接続も、開いていれば更新を受け取ります。その接続から来た更新は捨てます。捨てた更新は、メモリ上の文書にも、保存にも届かせません。\n符号化された状態がまだ無いときだけ、平文の写しから一度作ります。状態がある文書を、開くたびに平文から作り直しません。\n切断のときには、待ちの途中でも、メモリ上の文書を保存しようとします。保存の失敗は記録し、編集の接続をその失敗で切りません。\n採らなかった方式 保存の行を正本にする 開いている人が、全文を保存の行へ書き戻す方式です。後の書き込みが先の編集を上書きします。同時に開いていても、相手の入力は、次に行を読み直すまで見えません。保存が競合の解決を兼ねるので、保存の遅さと、編集の正しさが同じになります。\n平文の写しは、開いていない人が読むためと、同期の前に見せるために残します。合流の基板にはしません。\n同期の前に空の編集面を出す 接続が終わるまで、空の編集面を文書として見せる方式です。読む人は、空だったと思います。入力や、自動の保存が、その空を正本にします。同期の相手に届かないとき、この保存は、あった中身を空白で置き換えます。\nすでに符号化された状態を持っているのに、それを適用する前に空を描く方式も同じです。持っている状態は、接続の前に適用します。持っていないときだけ、平文の写しを代わりに見せます。空は、写しも状態も無いときだけです。\n見せることと、書かないことは、別のガードにします。空を隠しても、裏で保存すれば消えます。同期が終わるまで、保存の経路はすべて止めます。\n保存と配送を同じ書き込みにする 入力のたびに保存し、他の人は保存を読みに来る方式です。開いている接続の更新が、保存の往復を待ちます。保存が失敗すると、相手の手元にも届きません。編集できたことと、写しが残ったことが、同じ成功になります。\nこの構成では、更新はメモリ上の文書に適用した時点で、開いている接続へ送ります。保存は短い待ちのあとで、別に行います。保存が遅れても、開いている人の見える文書は遅れません。保存が失敗しても、開いている文書は残ります。\n一人だけが書くようにロックする 開いているあいだ他の人を読み取り専用にする方式です。上書きは起きません。同時の編集も起きません。届けるべきは、開いている全員の入力が同じ文書へ合流し、その結果が全員に見えることです。ロックは、その問題を避けています。\n書けない人を接続から外す方式も採りませんでした。外すと、その人は開いているあいだの更新を見ません。接続は許します。更新の適用だけを捨てます。捨てたものは保存にも届きません。\n落ちたら平文から作り直す 符号化された状態を残さず、プロセスが落ちたあとは平文の写しから文書を作る方式です。平文に落ちない構造と、写しより新しい合流が消えます。次に開いた人は、落ちる前に開いていた人と別の文書を見ます。\n状態がまだ一度も無い文書だけ、平文から一度作ります。それ以降の正本は、符号化された状態です。開くたびに平文へ戻しません。\n採用した形 文書は二つあります。開いているあいだのメモリ上の文書と、あとから読むための保存です。名前は文書につき一つで、接続はその名前に着きます。\n更新は、メモリ上の文書から、それを開いている接続のすべてへ流れます。保存の完了は待ちません。同期のあと、写しを保存へ書きます。あとから開く人は、その写しを読みます。同期が終わる前の空には、保存へ向かう矢印がありません。\n同期が終わるまでは空を出さない 手元は、接続の前に文書を作ります。符号化された状態をすでに受け取っているなら、接続より前にそれを適用します。最初に見えるものが空になりません。状態を持っていないなら、同期が終わるまで平文の写しを見せます。共同編集の面は、そのあいだ文書として出しません。写しも状態も無いときだけ、空の文書として扱います。\n同期が終わった、という通知が来るまで、保存はしません。入力に対する待ちの保存も、閉じるときの保存も、その場を離れるときの保存も、同じ旗を見ます。旗が立っていない文書は、空である可能性があります。空を正本へ書きません。\nこの旗は、見せるかどうかとは別に持ちます。写しを見せて空を隠していても、保存が先に走れば、隠した意味がありません。届かない同期に対して空を書いたことが、あった中身を消す条件でした。\n更新は、開いている接続へ送る 接続が付くと、その文書の今の状態を受け取ります。その後の更新は、メモリ上の文書に適用し、同じ名前を開いている他の接続へ送ります。保存が終わるのを待ちません。開いていない人は、次に開いたときに、保存された状態を読みます。開いている人の経路と、あとから開く人の経路は別です。\n書けない接続も、更新は受け取ります。だから、開いているあいだの文書は見えます。その接続から来た更新は、捨てます。メモリ上の文書にも、保存の写しにも届きません。\n書けない接続も、同じ文書に着けます。他の接続の更新は受け取るので、開いているあいだの文書は見えます。その接続から来た更新は、適用する前に捨てます。メモリ上の文書が変わらないので、他の接続へも、保存へも届きません。書けないことは、見えないことではありません。\n認証は、文書と保存を触る前に済ませます。通らない接続は、文書の中身を受け取りません。通ったあとに、書いてよいかを決めます。ここでも、接続できたことと、書いてよいことは別です。\n保存は写しです 最後の変更から短い待ちを置き、符号化された状態と、平文の写しを書きます。待ちは、入力のたびに保存しないためです。同期の処理は、この書き込みを待ちません。書き込みが遅いときに、開いている文書まで遅くしません。\n切断のときは、待ちを待たずに、今のメモリ上の文書を保存しようとします。閉じた直後の入力を、待ちの中に置き忘れないためです。この保存が失敗しても、開いている他の接続の文書は消えません。失敗は記録し、次の保存で再び書きます。\n平文の写しは、開いていない人が読むもの、同期の前に見せるもの、全文を必要とする他の処理のためのものです。合流には使いません。符号化された状態がすでにある文書を、写しから上書きして開きません。\n状態が一度も無い文書だけ、写しから符号化された状態を一度作ります。作ったあとは、メモリ上の文書が正本であり、保存はその写しです。写しの方が新しいように見えても、開いている接続が持っている合流を、写しで取り消しません。\n読み取り専用の接続は、受け取った更新を保存し直しません。保存は書き込みです。見るために開いている接続が、見るたびに正本を書くと、書けない権限が保存の側で通ります。\n何が解けるようになったか 複数人が同時に開いていても、後の全文の書き込みが先の編集を消しません。合流はメモリ上の文書で行い、開いている接続へはその更新を送ります。保存が遅れていても、開いている人は相手の入力を受け取れます。\n同期が終わる前の空は、文書として見せません。状態を持っているときはそれを先に適用し、持っていないときは平文の写しを見せます。同じあいだ、保存もしません。届かない同期に対して、空の手元の文書が正本を消すことはありません。\n落ちたあとは、符号化された状態から開きます。平文への作り直しは、状態が一度も無いときだけです。切断のときの保存が、待ちの途中の入力を写しに残します。保存が失敗しても、開いている文書そのものは残ります。\n書けない人は、開いているあいだの更新を見られます。その人の更新は、文書にも保存にも届きません。接続できたことと、書いてよいことが、分かれます。\n共同編集と保存を一つにすると、同時の入力、空の初期状態、開いている人への配送、落ちたあとの復元が、同じ書き込みの上で同時に満たせなくなります。分けたのは、編集が失敗する条件と、写しが失敗する条件が違うからです。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/live-document-and-saved-copy/","summary":"共同編集の文書同期とスナップショット保存を分離し、初回同期前の空の文書による上書きを防ぐ設計を説明します。","title":"共同編集の実体と残す保存を分ける"},{"content":"作業を始める入口が一つなら、意図がどこへ着地するかは迷いません。分かれ始めるのは、手元から始める入口と、待ち行列へ積む入口と、すでに開いている作業へ次の入力を足す入口が、同時にあるときです。それぞれが宛先を自分で決めると、同じ意図が別の機械に着地します。応答が届かなかった再送は、二本目の実行になります。機械が沈黙していると、空いている別の機械へ移したくなります。移した先には、置いてある作業の文脈がありません。\nこの構成では、未終了の開始許可は、同じ場所を使う作業面につき一つです。宛先は投入したときに許可へ書き、沈黙しているあいだは別の機械へ移しません。\n課題 困り始めるのは、開始が「実行した」という事実になる前に、入口が複数あるときです。\n手元の操作は、人が見ている機械で始めようとします。待ち行列は、積んだときに見えていた機械へ届けようとします。古い取得の経路が残っていると、それも同じ作業の所有者になろうとします。三者が別々に成功すると、同じ作業面で実行が二つになります。ファイルも、作業の続きも、分かれます。\n応答が落ちたとき、呼び出し側は同じ開始をやり直します。サーバが一つ目を許可したあとに応答だけが失われると、やり直しは新しい開始に見えます。許可を記憶していないと、二つ目を出してしまいます。記憶していても、中身が違う依頼を同じ識別子で受け付けると、前の許可の再送と、別の意図が区別できません。\n宛先を、取りに来たときに決める方式は、沈黙に弱いです。生きている機械が取得した者勝ちになります。人が選んだ機械と、実際に走った機械が違います。沈黙は、その機械が死んだことと同じではありません。眠っているだけ、ネットワークが止まっているだけ、のどちらでも、外からは同じ静けさに見えます。静けさを理由に移すと、戻ってきた機械の上の作業と、移した先の作業が両方残ります。\n許可を人につき一つにすると、別の場所の作業まで止まります。機械につき一つにすると、同じディレクトリを使う二つの作業が互いを見ません。一つにする単位を誤ると、拒否が多すぎるか、所有者が二人になります。\n配置は、許可のあとでも変わります。置き場が変わり、作業の所属が変わります。開いている許可の宛先をその場で書き換えると、走っている側は古い場所のまま、許可だけが新しい場所を名乗ります。黙って揃えたことになり、どちらが真かが残りません。\n機械の行を消したときに許可も消すと、不確実な実行の記録が消えます。遅れて届いた同じ依頼は、一つ目が無かったことになり、二本目の許可を得られます。\n短い期限で付随の資格を切ることと、作業面の所有者を解放することは、別の操作です。期限だけを見ていると、付随の資格が切れた機械から、別の機械が同じ面を取りに行けます。\n制約 この構成が置いた制約は、次のとおりです。\n未終了の開始許可は、作業面につき一つだけ置けます。終わった許可は履歴として残り、次の開始を妨げません。開いている許可は、機械と場所を必ず持ちます。場所の無い開いた許可は作れません。\n同じ依頼の識別子、同じ試行、同じ中身のダイジェストは、同じ許可の再送です。中身が違えば衝突であり、新しい許可ではありません。応答を落とした再送は、所有者を増やしません。再送のときも、してよいことの評価はやり直します。許可の行が残っていることは、権限が今も有効であることの代わりになりません。\n宛先は、投入したときの配置です。機械、場所、その配置の世代を、許可に書きます。開始の時点で配置が変わっていたら拒否します。開いている許可の宛先は、別の機械へ書き換えません。\n沈黙は移動の条件にしません。付随の資格は短く切れてよいです。切れても、作業面の所有者は解放しません。解放するのは、開始が終わったと記録したときだけです。長く届かない未実行の依頼は、別の機械へ渡さず、期限切れとして残します。半日より古いものは、その機械が次に取りに来たときに期限切れになります。\n待ち行列の行は、積んだときに宛先を名乗ります。取得は、その宛先が、今取りに来ている機械のものであることだけを確認します。空いている機械を探しません。\n古い取得の経路が残っているあいだは、同じ作業の行をロックし、未終了の許可がある面では古い経路が所有者になれないようにします。どちらの経路も、相手の確定を待たずに所有者を名乗れません。\n人の入力は、許可が受理されるまで手元に残します。拒否された入力は、送られなかったものとして残ります。\n機械の行や作業の行を消しても、不確実な許可は消しません。消すと、遅れた依頼が二本目を得られます。\n採らなかった方式 入口ごとに始める 手元は手元で実行を開き、待ち行列は取得した機械で開き、開いている作業への送信はその接続の先で開く方式です。それぞれが成功を自分で覚え、あとからサーバが突き合わせます。突き合わせの前に、同じ意図が二つの場所でファイルを書き始めます。\n入口を操作の並びの上で一つにしても、別の経路が残っていると同じことが起きます。一つの開始許可は、操作の並びの制約ではなく、未終了の行を一つしか置けないことです。\n取得のときに宛先を決める 積むときには機械を書かず、取りに来た機械の中から選ぶ方式です。生きている機械が勝ちます。沈黙している機械は、選ばれません。人が見ていた場所と、実行が走った場所が違います。取得の処理が経路の選択を持つと、選択の規則と、人が固定した宛先が、別の真実になります。\nこの構成では、取得は選択をしません。行に書いてある宛先が、取りに来た機械のものでなければ、その機械は取れません。\n沈黙を期限切れにして空ける 付随の資格が切れたら、作業面の所有者も解放する方式です。遅い機械と、死んだ機械が、外からは同じに見えます。解放した瞬間に別の機械が許可を得ると、戻ってきた機械の続きと、新しい機械の開始が両方あります。\n付随の資格が短いのは、実行に渡す権限を長く持たせないためです。所有者の解放は、開始が終わった記録だけが行います。半日を超えても取りに来ない未実行は、移動ではなく期限切れです。期限切れは、その機械の上の事実として残ります。\n許可を人につき一つにする 人が同時に始められる作業を一つにする方式です。関係の無い場所の作業まで止まります。逆に、機械につき一つにすると、同じディレクトリを使う作業が互いの存在を見ません。\nこの構成が一つにしたのは、実行が同じ場所を使う作業面です。同じ場所を使ってよい別の作業は、面の鍵を分けます。分け忘れると、方針が許す開始まで拒否されます。広くしすぎると、所有者が二人になります。索引は、その鍵の上で未終了を一つにしています。\n応答が落ちたら新しい許可を出す 一つ目の応答が呼び出し側に届かなかったとき、サーバが新しい許可を作る方式です。呼び出し側は同じ依頼のつもりで、サーバは二本目を始めます。\n同じ識別子と試行は、先に行をロックしてから見ます。行があれば、ダイジェストが一致するときだけ、その許可を返します。一致しなければ衝突です。返る付随の資格は、その時点の評価で作り直します。所有者の行は増やしません。\n機械を消すと許可も消す 機械の行を削除したとき、開始許可を一緒に消す方式です。実行がどう終わったかが見えなくなり、遅れて届いた同じ依頼は、許可が無かったものとして二本目を得られます。削除が、不明な実行を無かったことにします。\nこの構成では、許可は機械の行にぶら下げて消しません。不明な実行は、閉じるまで残ります。\n採用した形 開始は、一つの許可を通ります。手元から始めるときも、待ち行列から取るときも、実行の前に同じ許可を求めます。\n手元からの開始と、待ち行列は、一つの未終了の許可へ入る二本の矢印です。宛先は、投入したときにその許可へ書きます。書くのは、機械と、場所と、世代です。別の機械がそれを取る矢印はありません。沈黙は、宛先を動かしません。\n投入したときに宛先を書く 許可を作るとき、機械、場所、配置の世代、依頼のダイジェストを一緒に書きます。世代は、場所や作業の所属が変わったことを区別するためのものです。開始の処理は、今の配置が許可に書いたものと一致するかを見ます。一致しなければ拒否します。別の機械へ書き換えて成功にはしません。\n待ち行列に積むときも、同じです。行は宛先を持ってからしか、取得の対象になりません。宛先がまだ無い行は、待ち行列の外に置いたままにします。取得は、表面から、その機械の生きている場所へ辿り、取りに来た機械と一致することだけを確かめます。別の機械を候補にしません。\n配置をあとから変えたときは、開いている許可を移しません。停止を依頼し、古い作業は入力を受け付けなくなります。次に始めるなら、新しい配置に対する新しい許可です。沈黙のあいだに、この書き換えは起きません。\n未終了は一つ 同じ作業面の、終わっていない許可は一つしか置けません。二つ目は、すでに所有者がいる、として拒否します。終わった許可は枠を空け、履歴として残ります。\n試行を重ねるときは、一つ前の試行が閉じていることを見ます。閉じていない前の許可があるあいだは、次の試行を出しません。閉じ方のうち、開始前の失敗や中断のように、もう一度始めてよいものだけが次の試行を許します。成功した実行の再送が、新しい実行になってはいけません。\n古い取得の経路は、許可を作る経路と同じ行をロックします。未終了の許可があるあいだ、古い経路は所有者になれません。許可を作る側も、古い経路が先に所有者を書いたあとは、その面を取れません。\n再送は同じ許可を返す 同じ識別子、同じ試行、同じダイジェストの再送は、同じ許可を返します。二本目は開きません。ダイジェストが違えば衝突であり、新しい許可ではありません。\n依頼の識別子と試行で、先にロックします。行が無く、配置が一致し、所有者が空なら、許可を書きます。行があり、ダイジェストが一致するなら、その行を返します。ダイジェストが違うなら衝突です。取り消されたあとの再送は、受理しません。\n受理の応答に載せる、実行へ渡す付随の資格は、再送のときも作り直します。所属や方針が変わっていれば、古い付随の資格をそのまま渡しません。所有者の行は、その確認が成功するまで確定させません。確認が失敗したら、新しい許可も、作業面を使っている印も、一緒に戻します。\n付随の資格には短い期限を付けます。期限が切れても許可の行は開いたままです。機械は同じ許可で付け直します。別の機械が、期限を理由にその面を取ることはできません。\n入力は、この許可を求める前に手元に残します。受理されてから実行へ渡します。拒否の理由は、許可を出した側が持ちます。機械の上だけに理由があると、どの作業がどの理由で始まれなかったかが、機械が沈黙しているあいだ見えなくなります。拒否された入力は、手元に残ります。\n沈黙は、その機械の上で待つ 宛先を書いた機械が取りに来ないあいだ、行はその機械を指したままです。別の機械の取得では見えません。半日より古い未実行は、その機械が次に取りに来たとき、期限切れとして記録します。記録は残し、別の宛先へは書き換えません。\n新しい未実行が、同じ面の古い未実行を置き換えることはあります。置き換わるのは依頼の中身であり、機械ではありません。置き換えも、積んだときの同じ宛先の上で起きます。\n機械が戻ってきたときは、開いている許可の続きを見ます。許可が別の機械へ移っていることはありません。配置が変わっていたときだけ、その許可はもう開始できません。変わったことは拒否として残り、静かな移動にはしません。\n終わったことの記録が、枠を空けます。開始前に失敗した、中断した、所有者を解放すると決めた、という閉じ方だけが、次の許可を可能にします。不明のまま行を消す閉じ方は、採りません。\n何が解けるようになったか 入口が複数あっても、同じ作業面の未終了の開始は一つになります。手元から始めた実行と、待ち行列が届けた実行が、同時にその場所の所有者になることはありません。古い取得の経路が残っていても、開いている許可と同時には所有者になれません。\n応答が落ちた再送は、同じ許可を返します。二本目の実行になりません。中身が違う依頼を同じ識別子で送ると、衝突として拒否します。再送でも、してよいことの評価は最新のものになります。\n宛先は投入したときに固定されます。沈黙しているあいだ、別の機械へは移りません。半日を超えた未実行は、期限切れとしてその機械の上に残ります。配置を変えたときは、許可を書き換えず、停止と、次の新しい許可に分かれます。\n機械や作業の行を消しても、不確実な許可は残ります。遅れた同じ依頼が、記録が消えたことを理由に二本目を得ることはありません。\n人の入力は、受理されるまで手元に残ります。拒否は、送られなかったものとして残ります。どの作業がなぜ始まれなかったかは、許可を出した側に残ります。\n開始を入口ごとの成功にすると、宛先、再送、沈黙、配置の変更が、同じ成功の上で同時に正しくいられなくなります。一つに束ねたのは、未終了の所有者だけです。それ以外は、その所有者に書いた事実として残します。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/one-open-admission/","summary":"分散タスクの同時実行制御として、作業面ごとの開始許可・投入時の宛先固定・マシン停止時の冪等な再送を設計する方法を説明します。","title":"開始の許可を未終了のあいだ一つにする"},{"content":"一人で、すべての経路を自分のセッションで叩くなら、認証できたことは、してよいことと同じに見えます。分かれ始めるのは、機械が自分の資格情報で同じ経路を叩き、人のセッションと機械の鍵が同じ入口に並ぶときです。認証は、誰であるかを決めます。認可は、その誰かが、この操作をしてよいかを決めます。経路の中でこの二つを同時に書くと、権限を足しただけで、それまで通っていた機械が通らなくなります。\nこの構成では、経路は必要な権限だけを宣言します。誰がそれを持つかは、ロールから導きます。人の鍵は、機械の資格にしません。土台を入れる変更と、強制を始める変更は、同じ変更にしません。\n課題 知りたいことは、鍵が正しいかではありません。その鍵で、この操作をしてよいかです。\n認証だけを見ている経路は、人のセッションも、機械の資格情報も、通ったあとに同じ扱いになります。してよいことの差は、経路ごとの分岐に散ります。ある経路は資格情報の種類で人だけを通し、別の経路は会員であることを見て、さらに別の経路は何も見ません。権限を一つ足すと、種類で弾いていた分岐と、権限で弾く分岐が同じ場所で動き、呼び出し側の失敗がどちらから来たかが消えます。\n機械は、人の代理ではありません。人に許されている書き込みを、機械の鍵が持っているとは限りません。逆に、機械にだけ必要な、作業の取得や生存の報告を、人のセッションが持っている必要もありません。種類で「これは機械だから拒否」と書くと、その文は権限の表と二重になります。表を直しても、種類の分岐が古い答えを残します。\n人の鍵を機械に置くと、別の問題になります。一台を失効させても、人の鍵が残ります。人のセッションを切ると、生きている機械まで止まります。機械の上にある鍵が、新しい機械を認める権限を持っていると、その機械が次の機械を自分で入れてしまいます。承認は、人が押す操作の側に残したいのです。\n作業場の指定も、認証とは別の事実です。経路に含まれる作業場、資格情報に結び付いた作業場、依頼に添えた作業場が、同時に出てきます。どれか一つを黙って採用すると、認可は一方を見て、処理はもう一方の行を書きます。\n未宣言の経路は、レビューの抜けとして残ります。一覧が頭の中にあるあいだは、新しい経路を足した人が宣言を忘れます。忘れることが起動の失敗にならないと、穴は次の変更まで見えません。\n公開してよい経路もあります。署名の検証、短い同意の往復、生死の探査です。これを「認証が無い」とだけ記録すると、強制の側では開いているのに、外へ出す契約では鍵が要るように見えます。逆に、契約では開いているのに、実装は鍵を読んでいる、というのも残ります。どちらか一方の一覧では、もう一方の嘘を捕まえられません。\n制約 この構成が置いた制約は、次のとおりです。\n認証は、資格情報を主体に変えるところまでで終わります。主体は種類と識別子だけを持ちます。権限はこの段では見ません。人のセッションは人の主体になります。機械に発行した資格情報は、機械の主体になります。一つの操作だけを委譲する鍵は、その鍵自身を主体にします。\n経路が宣言するのは、必要な権限だけです。誰が呼んでよいかは書きません。それを持つかは、ロールが権限を束ねた結果から計算します。人向けだと経路に書いた文は置きません。その文は、ロールを編集した瞬間に古くなります。\n機械のロールは、人の権限を持ちません。新しい機械を認める権限は、人のロールにだけ置きます。機械の上に置いてある資格情報が、その承認を自分で通せるようにしません。一つの操作だけを渡す鍵には、広い書き込みを持たせません。広い権限を持たせて、経路ごとの依存が覚えているかどうかで境界を作ると、宣言の付け忘れが穴になります。\n作業場が経路に含まれているなら、その値を権威にします。資格情報に結び付いた作業場や、依頼に添えた指定と食い違うときは拒否します。黙って片方を選びません。本体が別の作業場を名指しているときも拒否します。\n評価は、許すものの和です。所属が無くても、明示の許可があれば通せる余地を残します。一つも許可が無いときが拒否です。評価の途中で起きた障害も拒否にします。障害を許可にしません。権限の結果はキャッシュしません。失効は、次の依頼から効きます。\n人を外したとき、機械の権限が次の依頼から効かなくなるのは、鍵を共有しているからではありません。機械の主体が、所有者の生きている所属に結び付いているからです。結び付きは依頼のたびに見ます。後始末のジョブでは消しません。\n強制を始める前に、すべての経路が、宣言、まだ古い認証のまま、どちらでもない、のどれかに分類できることです。どちらでもない経路は、公開すると決めて理由が書いてあるもの以外、置けません。古い認証のままの数は、減る一方にします。\nクライアントは、ロールから権限を計算し直しません。サーバが返した、今効いている権限の集合だけを見ます。表示の出し分けは、強制ではありません。\n採らなかった方式 資格情報の種類で経路を分ける 人のセッションなら通し、機械の鍵なら拒否する、という分岐を経路に置く方式です。認証と認可が同じ場所にあり、権限を変えると、通る資格情報の種類まで動きます。機械の鍵が一部の経路で急に通らなくなったのは、この融合の症状でした。拒否の理由は権限なのに、呼び出し側には認証の失敗に見えます。\nこの構成では、種類の判定は主体を作るところまでで終わります。その主体が操作をしてよいかは、ロールが持っている権限だけが決めます。機械が拒否されるのは、機械のロールがその権限を持たないからです。\n人の鍵を機械の資格にする 人のセッションや、人が持つ鍵を、機械が提示する方式です。機械は人として認証されるので、人に許されていることが機械にも許されます。一台を失効させても同じ鍵が残ります。人のセッションを切ると、作業を受けている機械まで止まります。\n機械の上の鍵が、新しい機械を認める権限を持っていると、侵害された一台が次の一台を入れます。承認は人の操作に残し、機械のロールからは外します。鍵の世代を回しても、許されていることの集合は動きません。人の鍵を回すのとは別の操作です。\n一つの操作だけを委譲する鍵を、広い書き込み権限つきの人の鍵で代用する方式も採りませんでした。名義上は面の全体が開き、実際の境界は経路がどの依存を付けたかになります。狭いロールにしておけば、経路が権限を宣言するだけで届きません。\n土台と強制を同じ変更にする カタログ、ロール、評価、経路への宣言を入れたのと同時に、宣言の無い経路をすべて拒否し始める方式です。起動できない原因が、モデルの不備なのか、呼び出し側が権限を失ったのかが、同時に起きます。戻すときも、土台ごと戻るか、強制だけ戻るかが分けられません。\n未宣言を一度に失敗へすると、移し忘れと、意図した拒否が同じ失敗になります。減る一方の期間が無いと、古い認証の経路を新しく足したことと、まだ移していないことが区別できません。\nこの構成では、最初の変更は土台だけです。既存の経路の挙動は変えません。古い認証のままの経路は数えて、その数が増える変更は受け付けません。次の変更で、宣言の無い経路と、古い認証のまま残った経路を、起動の失敗にします。ここで初めて、権限が拒否を始めます。\nしてよいことを経路ごとに書き下ろす 「この経路は人だけ」「この経路は機械も可」と、到達できる主体を経路の横に書く方式です。ロールを編集しても、その文は自分では直りません。人にだけあった権限を機械のロールへ足したとき、機械の資格情報だけで作業場を特定できるようになる経路が出ます。依頼の形が変わります。導出していれば、ロールの編集が到達範囲と依頼の形を一緒に動かし、契約の差分として見えます。手書きの文は、その差分に出てきません。\nクライアントがロールを解釈する 操作する側がロールの名前を見て、操作の可否を自分で決める方式です。サーバの束ね方と、操作する側の束ね方がずれます。明示の許可は、ロールの名前だけでは見えません。操作する側が許可して、サーバが拒否する、で済めばまだ穴ではありません。逆は穴になります。\nこの構成では、操作する側はサーバが返した権限の集合だけを見ます。集合が古くても、サーバの拒否が残ります。表示の分岐は、その拒否を先に見せるためのものです。\n採用した形 処理は四段に分けました。資格情報から主体を作ること、作業場を一つに決めること、主体と権限と作業場を評価すること、そして通過した主体だけを経路の処理に渡すことです。\n依頼は資格情報として入り、その段を出るときは主体です。主体が持つのは、種類と識別子です。認証はそこで終わります。権限は見ません。次の段で作業場を一つに決め、使うのは経路の値です。評価は、ロールが許すものを尋ねます。通過した主体だけを、経路へ渡します。資格情報の種類は経路を選ばず、経路は誰が呼ぶかを書きません。\n認証は主体まで 受け付ける資格情報は、ここで一度だけ種類を見ます。人のセッションは人になります。機械に発行した資格情報は、その機械の主体になります。鍵の世代は主体に結び、人のセッションとは別の秘密です。一つの操作だけを委譲する鍵は、鍵自体を主体にし、その鍵を捨てたときに委譲も死にます。\n主体は小さいです。種類と識別子があれば、ロールを引く場所が決まります。拒否の記録も、成功した操作の記録も、この対を鍵にします。この段では権限を見ません。認証できたことは、まだ何も許していません。\n経路は権限だけを宣言する 経路に書くのは、必要な権限です。宣言は依存として付き、起動時にすべての経路を歩きます。宣言が無い経路は、公開の理由が書いてあるもの以外、起動できません。同じ歩きを、変更の検証でも行います。忘れは、レビューの指摘ではなく、起動の失敗になります。\n誰が持つかは、ロールの束ねから計算します。人のロールと機械のロールは別の束ねであり、機械の束ねに人の権限は入れません。人にだけあった権限を機械へ移すと、その権限を宣言している経路では、機械の資格情報が作業場を供給できるようになり、依頼の形が変わります。ロールの一行が、多くの経路の契約を動かします。だから契約の差分は、権限の編集と同じ変更で見ます。\n作業場が経路に含まれている経路は、その値で認可します。別の指定と食い違えば拒否します。処理が書く行と、認可が見た作業場を、別の値にしません。\n呼び出し側だけの経路、つまり作業場を持たず、本人の行だけに触る経路は、評価に入れません。閉じ込めは、処理の問い合わせが本人に限っていることだけになります。だから、なぜ作業場の権限ではないのかを、経路ごとに一文で残します。文が書けない経路は、作業場の権限が要ります。\n鍵を要求しない経路は、二箇所で理由を残します。強制の側で、認証の依存が意図して無いこと。外へ出す契約の側で、鍵が要らないと見えること。片方だけでは、実装は開いているのに契約は鍵を要求する、または契約は開いているのに実装は鍵を読む、が残ります。理由の一覧は正確にします。使われなくなった文も、まだ要る文の欠落も、失敗にします。開くと決めること自体が、変更の本文に残る操作です。\n評価はロールから導く 保存してあるのはロールの名前です。名前から権限への展開は、実行時の集合演算にします。許可の源は、人の所属、機械の主体を所有者の生きている所属に結んだもの、委譲の鍵を同じく結んだもの、明示の許可、です。和なので、所属が無い主体でも明示の許可は通せます。何も無ければ拒否します。\n評価が投げた障害は、拒否と同じにします。保存が止まっているときに許可へ倒しません。結果は覚えません。所属を外した次の依頼から、機械も含めて効きます。\n拒否と、成功した操作の記録は分けます。成功の記録は、処理が終わったと知っている側が書きます。依存は、処理が成功したかを知りません。拒否は、権限の拒否も、主体が作られる前の資格情報の問題も、同じ流れに載せます。一部の拒否だけを記録すると、集まって見える数が静かになり、静かなことが嘘になります。\nオブジェクトの一行に触ってよいかは、ロールの外に残します。ロールが答えるのは、この種類の操作をしてよいかです。どの行かは、処理の問い合わせと、その行の所有です。権限を宣言しただけでは、識別子を知っている呼び出しが隣の行へ届くのを止められません。\n強制は、土台の次の変更で始める 最初の変更は土台を入れ、それまでの挙動はそのままにします。強制が始まるのは、次の変更です。宣言の無い経路は、そこで起動に失敗します。\n最初の変更で入るのは、権限のカタログ、ロール、評価、経路が宣言を付けられる依存、すべての経路を分類する歩き、です。既存の経路は、それまでの認証のまま動きます。挙動は変えません。古い認証のままの経路は数えます。その数が増える変更は受け付けません。減る一方にしておけば、新しい経路を古い認証で足したことが、数の差分で分かります。\n次の変更で、古い認証の印を起動の失敗にします。宣言が無く、公開の理由も無い経路も失敗にします。移行の段ではなく、戻ったこと自体が退行になります。権限による拒否が始まるのは、この変更からです。\n呼び出し側が作業場をどこから得ていたかは、宣言を足す前に見ます。経路に含まれているなら、それを権威にします。依頼の本体の欄だけが作業場を持っていた経路と、そもそも作業場が無く本人にだけ作用する経路は、宣言の足し方を変えないと、それまで通っていた依頼が、権限の中身とは別の理由で落ちます。落ちた理由が権限に見えると、ロールの編集に見えます。実際は、作業場の供給元を移しただけです。\n何が解けるようになったか 認証できたことと、操作をしてよいことが、別の事実として残ります。機械の鍵が拒否されるのは、認証に失敗したからではなく、機械のロールがその権限を持たないからです。人のセッションが通る経路を、機械の鍵が種類の特例で通ることはありません。\n経路を足したときに宣言を忘れると、起動しません。公開する経路は、強制と契約の両方に理由が要ります。どちらかが古いと、変更の検証が失敗します。\n人の鍵を機械に置かないので、一台の失効が人のセッションを巻き込みません。人を外した次の依頼から機械も止まります。新しい機械を認める操作は、機械の上の鍵ではできません。一つの操作だけを渡した鍵は、その操作の権限しか持ちません。\n作業場は、経路に含まれている値を権威にします。別の指定との食い違いは、黙って解消しません。\n土台と強制を分けたので、モデルを入れた変更では呼び出し側の挙動が変わりません。強制を始めた変更では、未宣言と、古い認証の残りが、起動の失敗として見えます。ロールを編集すると、到達できる主体と、依頼の形は、手書きの一覧を直さなくても、同じ導出から変わります。\n権限を経路の記憶に置いたままにすると、ロール、資格情報の種類、作業場の指定が、同じ分岐の上で同時に正しくいられなくなります。分けたのは、それぞれが古くなる条件が違うからです。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/separate-authentication-and-authorization/","summary":"API の認証とロールベース認可を、主体・経路ごとの権限・人とマシンの認証情報に分けて設計する方法を説明します。","title":"認証としてよいことを分ける"},{"content":"モデルが過去を全部見ていれば、新しい出来事が新規なのか、既存の続きなのかは分かるように思えます。入力は、その前提に耐えません。記録は増えます。窓は、関係の薄い行で先に埋まります。検索の前に作成と決めると、同じ作業がもう一行できます。検索の前に更新と決めると、当たっていない行を書き換えます。\nこの構成では、正本の記録と、検索して渡す記憶を分けます。モデルの入力は、その場で取り出した切片だけにします。新しい出来事を作成にするか更新にするかは、記憶を検索してから決めます。\n課題 正本は、作業の行、出来事の行、人の行です。これが消えると、作業が消えます。モデルに渡す文章は、その複製ではありません。複製を正にすると、言い換え、切り捨て、書きそこないが、作業そのものになります。\n新しい出来事は、どちらにも読めます。初めての依頼かもしれません。昨日の作業への追記かもしれません。完了の報告かもしれません。作業ではない雑談かもしれません。文面だけ見て操作を選ぶと、重複と、誤った更新が同じ入力から出ます。\n検索にも穴があります。直前に書いた行は、索引にまだありません。索引が空なのは、世界に何も無いことと同じではありません。空を「新規」と読むと、同じ流れの二通目がもう一件の作業になります。空を「判断できない」と読んで処理を止めると、索引の遅れが沈黙になります。\n記憶を一枚の要約に畳む方式は、別の圧力を受けます。未処理の出来事を一回の呼び出しに積むと、呼び出しは大きくなり、失敗しやすくなります。失敗した位置でカーソルを止めると、次の回は同じ塊がさらに大きくなります。止めたままは、凍ります。失敗した分を、それらしい文章で埋めると、無かった経緯が正本の隣に残ります。\n開いている作業を毎回全部読む方式は、少ないうちは動きます。増えると、関係の無い行が、当たっている一行を押し出します。先に全部読むと、検索する理由が消えます。\n制約 この構成が置いた制約は、次のとおりです。\n正本への作成と更新は、記憶を検索したあとの操作だけです。新しい出来事の文面は、操作の種類をまだ持ちません。\nモデルに渡すのは、検索した切片です。正本の全件、全文、全履歴は渡しません。問い合わせの数も、各切片の長さも、上限を持ちます。上限を超えた分は、黙って末尾を足しません。\n検索が空であることは、既存が無いことではありません。有界の控えを、正本から別に渡してよいです。控えは、最近の行と、その出来事に関係する人の行に限ります。全件の読み直しではありません。操作は、検索の切片と、その控えを見たあとに決めます。\n同じ流れの中で直前に書いた行は、索引を待ちません。発生源の同一性で、正本を直接引きます。これは検索の代わりに履歴を足すことではありません。その流れの中の行だけを見ます。\n要約は、記憶の一種です。正本ではありません。作業の配送のキューでもありません。要約の失敗は、正本の行を消しません。失敗した塊を、捏造した文章で埋めません。カーソルは、再試行の上限のあと、その塊を越えて進みます。進めたあとに戻る手段はありません。だから要約に、個別の作業の到達保証を持たせません。\nモデルが書いた文章は、正本の本文を置き換えません。検索結果は入力です。書き戻すときは、正本の行に対する作成か更新か、何もしないかです。\n採らなかった方式 履歴を入力に連結する 未処理の出来事と、開いている作業と、人の一覧を、一回の入力へ積む方式です。小さなうちは、検索より単純に見えます。増えると、窓の中の位置が結果を決めます。関係する一行が、新しい出来事から遠い位置に落ちます。同じ呼び出しが要約と操作の両方を兼ねると、要約の失敗が操作の失敗になります。この構成では、正本を入力に展開しません。\n開いている作業を、先に全部読む 検索の前に、未完了を全部載せる方式です。重複を防ぐつもりが、無関係な題名で入力を埋めます。会議のように長い文字を同時に渡すと、未完了の一覧が文字を圧迫し、末尾の引き受けが見えなくなります。先に読む量を固定すると、古い重要な行が、新しい薄い行に負けます。検索してから渡す、に戻しました。\n文面だけで、作成か更新かを決める 新しい出来事を読んだ時点で、作成か更新かを決めてから、当たる行を探す方式です。作成と決めたあとの検索は、重複の確認にしかなりません。更新と決めたあとの検索は、先に選んだ操作に合う行を探しにいきます。どちらも、検索が操作を決めていません。空だったときに作成へ倒すと、索引の穴が新しい行になります。この構成では、操作の種類は検索の結果が揃ってから書きます。\n要約を正本にする 一枚の文章を、作業場の過去そのものにする方式です。人が読めて、モデルにも渡しやすいです。ただし要約は欠けます。切り捨ての位置は、モデルの窓と、失敗した呼び出しで決まります。その文章を作業の行だとみなすと、要約されなかった引き受けは存在しなかったことになります。人がその文章を編集し、モデルも同じ文章を書き換えると、最後の書き込みが過去を決めます。この構成では、要約は渡してよい切片の一つに留めます。作業が存在するかどうかは、正本の行だけが決めます。\n記録ごとに文書を作り、その集まりを入力にする 正本の行を、モデルが読む文書へ投影し、判断のたびに集まりを展開する方式です。名前は変わります。経路を入力に焼くと、改名が過去の参照を壊します。集まりを毎回全部渡すと、履歴を連結したのと同じ窓の問題に戻ります。その文章を正本に書き戻すと、モデルの言い換えが作業の本文になります。この構成では、投影した文書を正本にしません。読むときは、検索か、明示した一件の読みです。\n検索の失敗を、無いにする 問い合わせが失敗した、上限で切った、索引が遅れた、を同じ「該当なし」にする方式です。該当なしは、作成してよい、に近くなります。失敗は、見えていないだけです。この構成では、失敗、空、上限で切った、を同じ成功にしません。空のときは有界の控えを足し、それでも操作は見たあとに決めます。失敗のまま操作を確定しません。\n索引が追いつくまで待つ 直前の行が検索に出るまで、次の出来事を止める方式です。索引の遅れが、作業の遅れになります。同じ流れの二通目は、発生源が分かっています。待つ必要は、その流れの中にはありません。待ちを外の検索にだけ残し、同じ流れは正本を直接引きます。\n採用した形 記憶は三層に分けました。正本の行、検索して渡す切片、操作の決定、です。\n検索は、正本から切片を取り、その切片を渡します。作成か、更新か、何もしないかは、切片を見てから決めます。決定が書くとき、矢印は正本へ戻ります。切片へは書きません。文面は、検索の前に操作を決めません。検索が空であることは、それだけでは作成になりません。同じ流れで直前に書いた行は、索引を待たず、正本から直接読みます。\n正本は、行のまま置く 作業、出来事、人は、それぞれの行です。モデルの入力に展開した時点の写しを、最新だとみなしません。行を消す、行を更新する、行を作る、は正本への操作です。検索結果への追記ではありません。\n要約を更新する処理は、この行を書き換えません。要約の呼び出しが失敗しても、行は残ります。再試行の上限を超えた塊は、要約には寄与しないままカーソルを進めます。その欠落は、要約が欠けることであり、作業が消えることではありません。未分類の失敗は、カーソルを進めません。知らない失敗で、見ていた範囲を捨てません。\n渡すのは、検索した切片だけです 判断の入力は、その出来事に対して検索した切片です。問い合わせは、出来事を見たうえで計画します。固定の「未完了を全部」ではありません。問い合わせの数には上限を置きます。一件が広すぎる問いを、扇のように増やしません。\n各切片は短く切ります。題名、状態、期日、本文の先頭、で足ります。本文の全文を、候補の数だけ繰り返しません。検索した切片は、控えより先に置きます。控えが窓を埋めて、当たった一行が落ちる、を避けます。\n要約を渡すときは、それも切片です。全文の保証はありません。正本の行が言うことと、要約が言うことが食い違ったときは、行を信じます。\n読む側が複数の文書を持っている場合も、その集まりは入力に展開しません。一覧、検索、一件の読みで、必要なものだけを取ります。ある処理が特定の文書に依存するなら、その文書を名指しで読みます。集まりをまとめて渡すことは、その名指しの代わりにしません。\n作成か更新かは、切片のあとで決める 新しい出来事は、検索が終わるまで操作を持ちません。当たった行が、同じ作業の続きなら更新です。当たる行が無く、依頼として立つなら作成です。報告、相槌、既に終わっていることの繰り返しなら、何もしません。三つは、同じ入力を見たあとの選択です。\n更新は、検索で見えた行に対してだけ行います。識別子を、文面から推測して埋めません。見えていない行は、更新の対象にできません。作成は、何もしないを検討したあとに残します。検索が空だったことだけでは、作成にしません。控えを見たあとも当たる行が無い、というところまでいって、作成にします。\n同じ流れで直前にできた行は、検索の切片に加えて、発生源の照合で渡します。二通目は、その行を見たうえで、更新か、何もしないかを選びます。索引が遅れていても、もう一件の作成に倒れません。\n一回の出来事が複数の作業に触れるときは、操作を分けて書きます。一つの作成に、関係の無い引き受けを混ぜません。各操作は、どの切片を根拠にしたかを残します。根拠が無い更新は、実行しません。\n正本へ書くのは、決定のあとだけです 決定は、作成、更新、何もしない、のどれかです。何もしないは、失敗ではありません。既存に当たったから何もしない、と、作業ではないから何もしない、は分けて残します。前者は当たった行を指します。後者は行を指しません。\n書くときは正本の行へ書きます。検索の切片へは書きません。要約へは書きません。モデルが返した文章を、作業の本文として丸ごと置き換えません。更新が変えてよいのは、決定が名指しした項目だけです。\n何が解けるようになったか 過去が増えても、判断の入力は切片のままです。窓の位置ではなく、検索が、どの行を見せるかを決めます。無関係な未完了が、新しい引き受けを押し出しません。\n同じ出来事が、あるときは作成になり、あるときは更新になります。どちらになるかは、検索してから決まります。文面が似ていることだけで、新しい行は増えません。見えていない行は、更新されません。\n直前の行は、索引を待たなくても、同じ流れの中では見えます。二通目が、もう一件の作業になりません。\n要約が失敗しても、作業の行は残ります。要約が凍って、それ以降の出来事を拒み続けることもありません。要約に入らなかったことは、要約の欠落として残り、正本の欠落にはなりません。\n正本と、検索して渡す記憶を一つにすると、言い換え、切り捨て、索引の遅れ、操作の早さが、同じ文章の上で同時に効きます。分けたのは、記憶が欠けても作業は残り、作業を書く前に、何が見えていたかを残せるからです。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/search-before-write/","summary":"AI エージェントのメモリ設計として、検索した文脈とタスクの正本を分離し、検索インデックスの遅延を考慮して作成・更新を判断する方法を説明します。","title":"過去をモデルの入力に全ては載せない"},{"content":"短い依頼を一文で受け取れるなら、発話と作業は同じものに見えます。誰かが「これをやって」と言い、その文が作業になります。困り始めるのは、会議です。一時間の発話のほとんどは作業ではありません。状況の共有、相槌、寄り道、決めたことの確認、何も決めずに棚上げした話題が、同じ列に並びます。その列を作業の一覧だと読むと、雑談が作業になり、引き受けが雑談に埋もれます。\nこの構成では、発話の流れを作業にしません。文字起こし、要約、作業の抽出は、成功も失敗も共有しない別の段階にします。相対的な期日は、処理が走った日ではなく、会議の時刻から解きます。録画の持ち主は、録音を始めたアカウントです。発話から作業を起こすかどうかは、そのアカウントとは別の判断にします。\n課題 会議が終わった瞬間に、言葉はまだありません。録音の完了と、文字の完了は別の出来事です。文字は遅れ、失敗し、空の参加だけで終わることもあります。文字が無い状態を作業の不在と読むと、認識の失敗が「何も決まらなかった」になります。\n文字が届いても、それは作業ではありません。決定、引き受け、懸念、未解決の問い、ただの経過が混ざります。モデルに「この文字から作業を作れ」とだけ渡すと、報告まで作業になり、逆に根拠の弱い文を落としすぎると、明示の引き受けまで落ちます。\n期日は発話の中では相対です。「金曜」「明日」「来週のはじめ」。文字起こしと抽出は会議のあとで、日付をまたぎます。処理が走った日を今日にすると、翌朝の再試行だけで一日ずれます。サーバがいる地域の今日に固定すると、会議がその地域の外で行われたとき、暦の日がずれます。\n録画には持ち主がいます。人が手元で録ったものは、最初はその人のものです。作業場へ共有される前に、作業場の判断を走らせると、私有の録音が他人の作業になります。共有のあとでも、誰が録ったかと、どの文を作業にするかは別です。話者の名前がメンバーに解けないことを理由に引き受けを捨てると、作業場の外にいる人の「やります」が消えます。名簿に写った自動記録の主体を人だと読むと、持ち主や作業場を取り違えます。\n要約を作業リストにすると、別の失敗が重なります。要約は短いです。失敗します。短い会議では作りません。要約の欠落が、作業の欠落になります。要約が言い換えた文を根拠にすると、録音にあった引き受けが、一致しない引用として捨てられます。\n制約 この構成が置いた制約は、次のとおりです。\n録音が終わった処理は、文字起こしを依頼して終わります。文字の中身を待ちません。文字起こしの失敗は、録音の行を消しません。\n文字は、先に保存します。保存が済むまで、要約も作業の抽出も走りません。要約の失敗は、保存した文字を消しません。短すぎて要約を作らない判断も、文字の破棄ではありません。\n作業の抽出は、保存した文字を読みます。要約だけを読んで、作業の中身を確定しません。どの作業場が後で文字を読むかを決める安い判断は、要約を見てよいです。その判断は、作業を作りません。\n相対的な期日の基準は、会議の時刻です。処理を実行した時刻でも、固定の地域の今日でもありません。暦の日が要るときは、その時刻を作業場の地域で読みます。\n録画の持ち主は、録音を始めたアカウントです。話者の推定、名簿の表示名、自動記録の主体から導きません。持ち主が作業場へ共有するまで、作業場に対する要約と作業の判断は走りません。文字起こし自体は、私有のままで完了してよいです。共有された瞬間に、同じ文字へ、共有先の判断を足します。\n話者が作業場のメンバーに解けないことは、作業を起こさない理由にしません。担当が空の作業は正しく、作業が無いことは正しくありません。自動記録の主体は参加者名簿から除きます。人でも持ち主でもありません。\n抽出が根拠として残す引用は、保存した文字に対するものです。モデルが清書した本文を正にしません。長い会議は切りません。次の手は、末尾に寄ります。\n採らなかった方式 一つの呼び出しにまとめる 文字起こし、要約、作業の抽出を、同じ成功で終える方式です。認識が遅いと作業が無く、要約が失敗すると文字まで失敗に見え、抽出が拒否すると録音が無かったことになります。三つの失敗は、外から直す場所が違います。この構成では、録音の完了、文字の保存、要約、作業の抽出を、別の段階にしました。\n文字を清書してから抽出する フィラーを落とす、音声認識の誤りを直す、長い文字を窓に切って言い換える、という前処理です。抽出は渡された文を引用します。その文がモデルの言い換えだと、引用が録音と一致しません。一致しない引用は根拠なしになり、実際の引き受けが捨てられます。名前を直す仕事は、会議全体が見えている抽出の側に残しました。前処理がしてよいのは、話者の印で区切る、連続する行を繋ぐ、空白を揃える、中身の無い行を落とす、までです。発話は書き換えません。何が雑談かは、言語ごとの感動詞の一覧では決めません。一つの言語の相槌だけを落とすと、別の言語の相槌が残ります。\n要約を作業リストにする 要約は、人が読むための短い記録です。決定、引き受け、懸念、未解決が、数個の文に畳まれます。これを作業の生成だとみなすと、畳み損ねが作業の却下になります。逆に、要約が作業の形をしていない文まで作業にすると、状況報告が作業になります。\nどの作業場が後で読むかの判断まで文字全体を渡す方式も採りませんでした。その判断が重いと、何も起きなかった会議まで、作業の抽出と同じコストになります。安い判断は要約を読み、見る価値が無いと言えます。作業の中身は、起こされた側が文字全体を読んで決めます。\n文字を汎用の出来事列へ流して、そこで作業を起こす 文字起こしの完了を、他のメッセージと同じ列に入れ、その還元で作業を作る方式です。列は「何かが記録された」を運びます。引き受けの判断ではありません。他の種類の出来事と混ざると、期日の基準が会議の時刻から離れ、処理が走った日や、列の中の別の時計になります。この構成では、文字の存在を記録することと、作業を起こすことを分けました。抽出は、その会議の時刻と、保存した文字を持って行います。\n期日を処理時刻で解く 「明日」を、抽出が走った日の翌日にする方式です。文字起こしは遅れ、失敗した段階は再試行します。会議の翌朝に走っただけで、相対的な期日が一日ずれます。サーバの地域の今日に固定する方式も、同じ種類のずれを地域の差として起こします。基準にする時刻は、会議の時刻だけです。\n話者の解決で、作業を起こすかを決める 名簿の名前と発話の名前が一致した人だけを作業にし、解けない話者の引き受けは外部だから捨てる、という方式です。一致は揺れます。部分一致を外して完全一致にすると、解けない話者が常態になります。その状態で捨てる規則を残すと、明示の引き受けが残らなくなります。担当を特定できないことは、未割当で残す理由です。作業を作らない理由ではありません。\n持ち主を名簿から推定する方式も採りませんでした。自動記録の表示名が名簿に入ると、その名前が作業場や持ち主に見えます。持ち主は、録音を始めたアカウントとして、話者の解決とは別に渡します。\n採用した形 段階は四つに分けました。文字の保存、要約、どの作業場が読むかの振り分け、文字を読んでの作業の抽出、です。\n保存した文字が、あとの段階が読む元です。要約はその文字を読み、振り分けは要約を読みます。振り分けから作業を作る矢印はありません。作業の抽出が読むのは保存した文字であり、要約ではありません。抽出は、振り分けのあとに走ります。\n文字起こしが失敗しても、録音の行は消えません。要約が失敗しても、保存した文字は消えません。振り分けが止まっても、作業は作られず、文字は残ります。\n文字は、先に保存する 録音の完了は、文字起こしの依頼です。文字が届いた処理は、まず文字を保存します。私有の録音で、作業場がまだ無いなら、そこで止まります。要約も、作業の判断も、作業場の中身です。持ち主が共有したときに、同じ保存済みの文字へ、その判断を足します。共有の前に文字起こしが終わっていても、やり直しません。\n一本のマイクで録ったものは、話者が一人に見えます。話者の切り分けは、要約の前の別の試みにします。失敗しても、文字は届いたときのまま残します。切り分けが推定で書いた名前を、事実の名簿にはしません。\n会議の名簿と、会議の中で交わされた短い文は、文字の行に添えて残します。要約も、後の抽出も、同じものを読みます。処理が終わったあとに、別の取得へ依存しません。自動記録の主体は、この名簿に入れません。\n要約は、読むための別段階 要約は、保存した文字に対する別のモデル呼び出しです。短すぎる文字には作りません。失敗しても、文字の保存は戻しません。人が後から読む概観であり、作業の行ではありません。\n出力の言語は、作業場が読む言語に合わせます。文字そのものを扱う段階は、会議の言語を保ちます。要約の指示に「読め」と「訳せ」を同時に書くと、引用の元になる文字まで訳されます。\n題名が機械的な仮の名前のときだけ、要約が提案した題名を採用します。人が付けた題名は、要約が取り消しません。\n作業場への振り分けは、作業を作らない 文字が作業場に属したあと、どの作業場が後で読むかと、いつ読むかを決めます。入力は要約です。文字全体ではありません。見る価値が無い、と言えます。状況の共有だけで、誰も何も引き受けていない会議は、ここで止めてよいです。迷ったときに今すぐ見るのは、この振り分けの側の規則です。作業を何件作るかの規則ではありません。\nこの振り分けが失敗しても、文字は残ります。再試行は、同じ文字に対して同じ判断を重ねません。入力が変わったときだけ、やり直します。\n作業は、文字を読み、会議の時刻で期日を解く 作業の抽出は、振り分けのあとで、保存した文字全体を読みます。要約の文を作業に転写しません。根拠の引用は、保存した文字に一致するものだけを残します。末尾は切りません。\n相対的な期日は、会議の時刻を今日の位置にして解きます。「金曜」は、処理日の次の金曜ではなく、その会議から見た金曜です。暦の日へ落とすときは、作業場の地域でその時刻を読みます。明示が無ければ、期日は空のまま残します。推測で日付を埋めません。\n作成するか、既存の作業を更新するか、何もしないかは、この抽出の出力です。文字起こしの成功は、そのどれでもありません。話者がメンバーに解けなければ、担当は空にします。行は残します。\n録画の持ち主は、この判断の入力です。録音を始めたアカウントを、確定した値として渡します。話者の解決結果で上書きしません。持ち主であることは、その人の発話を全部作業にすることではありません。\n何が解けるようになったか 録音が終わったこと、文字が残ったこと、人が読める要約があること、作業が起きたことは、別々に見られます。認識が遅れても、要約が失敗しても、文字は残ります。作業を起こさない判断は、録音が無かったことにはなりません。\n「明日」は、抽出が走った日に引きずられません。会議の時刻が基準なので、再試行が翌朝でも、相対的な期日の位置は変わりません。\n手元の録音は、持ち主が共有するまで作業場の判断を受けません。共有したあとは、同じ文字から作業を起こせます。誰が録ったかはアカウントで残り、どの文を作業にするかは別の判断で残ります。名簿に解けない話者の引き受けは、担当が空の作業として残せます。自動記録の主体は、持ち主にも担当にもなりません。\n発話の流れを一つの処理で作業にすると、認識の遅れ、要約の短さ、期日の相対性、持ち主の強さが、同じ成功条件の上に載ります。分けたのは、それぞれが失敗する条件が違うからです。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/transcript-is-not-a-task/","summary":"会議 AI の処理を文字起こし・要約・タスク抽出に分け、発話の根拠を保持し、会議時刻から期限を解決する設計を説明します。","title":"発話の流れを作業にしない"},{"content":"エージェントを手元の一台だけで動かすなら、通信はほとんど設計になりません。プロセスは同じマシンにあり、操作する人もそこにいます。困り始めるのは、作業の置き場が別のマシンで、そのマシンが NAT や組織の境界の向こうにあり、外からポートを開けないときです。そのマシンのネットワークは、こちらが管理できません。アドレスを控えても、そのアドレスへ制御プレーンから届く保証はありません。\nこの構成では、そのマシンを常時の管理対象にしません。人が入るのは導入のときだけで、その後の生存と身元は、マシン自身が外へ張る接続で保ちます。対話的なデータの通り道は、それとは別の、人がその場にいるときだけの経路に残します。\n課題 知りたいことは単純です。届かせる手段がありません。\n制御プレーンは、そのインストールが今生きているか、作業を受けられる状態か、意図して止めたのか、それとも消えたのかを区別したいのです。作業の宛先を一台に固定したなら、沈黙しているあいだはそこで待たせます。別のマシンへ黙って移すと、置いてある作業の文脈が分かれます。\n同時に、作業そのもののバイト列はマシンの上にあります。ファイルの読み書きや、実行中のプロセスの入出力です。制御プレーンがそれを代理で運ぶと、プレーンがデータ面になります。接続を特定のインスタンスに固定できない API では、開いた流れと、作業が生まれたリクエストが同じ場所に着地しません。\n操作する側の UI は、人が閉じます。閉じた瞬間に常駐が死ぬと、常駐にした意味がありません。ラップトップを畳んだ、というだけで「マシンが無い」になってはいけません。\n同じ筐体を複数人が使うこともあります。SSH の宛先は筐体に対するもので、インストールの身元ではありません。到達できたことと、どのアカウントの常駐に繋がったかは、別の事実です。\n秘密の置き方も課題になります。導入が成功した状態が、プロセス一覧やサービス定義を読める人にとっての資格情報になってはいけません。\n身元を自己申告の文字列で済ませると、別の問題が残ります。ボディに「自分はこのマシンだ」と書くだけでは検証できません。あるマシン宛の作業を、同じ人の別のマシンが取れます。一台を失効させても、共有している鍵が残ります。\n制約 この構成が置いた制約は、次のとおりです。\n導入が終わったあと、エージェントは待受を持ちません。制御プレーンとの通信は、マシンからの外向きだけにします。制御プレーンは、マシンのネットワークアドレスを保存しません。ループバック以外への待受は拒否します。\n導入の経路は、人がすでに持っている SSH に限ります。そのセッションは短いです。人が端末の前にはいますが、その端末に人のセッションが無い場合は、短い承認コードを人がブラウザで認めます。いずれにしても、新しいマシンを認める権限は人のセッションにだけ置き、自動で動く主体には持たせません。\n同じマシンの上での制御は、UNIX ドメインソケットに置きます。ソケットはホストの外へ出ません。リクエストごとに、ローカルなトークンを要求します。\nインストールの身元は、そのインストールに発行した資格情報で証明します。手元に残す識別子は、再起動のあとで自分の行を探すためのもので、認可には使いません。資格情報は、プロセスの引数、サービス定義、サービスマネージャがプロセスへ渡す環境には置きません。導入時は SSH の標準入力で渡し、所有者だけが読めるファイルへ、中身を書く前に権限を絞ってから原子的に置き換えます。\nUI を閉じてもデーモンは残ります。止めるのは、明示の停止、OS からの停止シグナル、マシンそのものの停止だけです。明示の停止はラッチを残し、UI の再起動やログインのやり直しは、その停止を取り消しません。\n人があとから対話するデータ面は、その人が張った SSH で、先の UNIX ソケットへ転送します。制御プレーンは、ファイルの中身や実行の記録を通しません。\n採らなかった方式 インバウンドを開ける ポートを公開する、逆向きのトンネルを常時張って制御プレーンがダイヤルする、待受のアドレスを台帳に書く、という方式です。対象にしているマシンは、まさにそれができない側です。NAT の対応表は、内側から開けたものだけがしばらく生きます。外側から新しい流れを始めても、届く相手がいません。\n仮に開けたとしても、制御プレーンがアドレスと待受を運用し続けることになります。マッピングは切れ、アドレスは変わり、待受は露出します。この構成では、エージェントの待受を持たず、アドレスも持ちません。\n常時の VPN 全マシンを一つのネットワークに入れると、SSH も待受も「届く」ように見えます。届くようにしているのは、そのネットワークをこちらが管理しているからです。管理できないマシンに対して、ネットワークの管理を前提にしています。\n残る問題もあります。トンネルのアドレスは、インストールの身元ではありません。VPN の資格情報と、マシンの資格情報を二重に回すことになります。ラップトップが眠ると経路が落ち、エージェントが死んだように見えます。見たい事実は、そのインストールが生きていて、誰のもので、作業を受けられるかです。経路が生きていることは、その代わりになりません。\nデータ面まで SSH を使い続ける SSH は、導入と、人がいるあいだの対話には合います。生存確認と作業の受け取りまで同じセッションに載せると、セッションを握っている側が死んだ瞬間に、マシンが不在になります。UI を閉じた、ラップトップを閉じた、踏み台が切れた、というだけで常駐が消えます。\n対話的なバイト列は、人が見ているあいだだけで足ります。生存はそれより長いです。SSH をデータ面の経路として残すことと、SSH をマシンの存在条件にすることは、別の決定です。この構成が採らなかったのは、後者です。\n生存確認にデータ面を載せる 短い外向きの確認に、ファイルや実行の入出力を載せる方式です。一回の転送が遅れると、マシンが落ちたように見えます。確認の処理は、ディスクや大きな応答を待ってはいけません。\n制御プレーンが作業コピーの通り道にもなります。プレーンは、他人のファイルや実行の記録を持つ場所ではありません。接続を特定のインスタンスに固定できない API では、開いたソケットと、作業が生まれたリクエストが同じプロセスに着地しません。プッシュのために接続を固定する経路は、この構成の前提にしませんでした。\n生存と作業の取得を、一つの長い保留で兼ねる案もありました。保留が切れたときと、マシンが死んだときが、外からは同じ沈黙になります。静かなマシンが接続を握り続けるコストも残ります。生存は短い確認のままにし、作業の取得は別の短い外向きリクエストにしました。\n遠いディスクを手元にマウントする 手元のツールに、先のファイルシステムを見せる方式です。エージェントが動く場所は先のマシンです。マウントは先にプロセスを生みません。遅延の大きい経路では、状態を細かく見る道具が先に壊れます。端末も、実行の継続も、この方式では届きません。\n人の資格情報をマシン間で共有する 人の鍵を各マシンが持ち、ボディの識別子で「どのマシンか」を名乗る方式です。識別子は自己申告なので、検証になりません。一台を失効させても同じ鍵が残り、あるマシン宛の作業を別のマシンが取れます。資格情報はインストールに結び、人のセッションとは分けます。\n採用した通信の形 通信は三層に分けました。導入のときだけの SSH、その後ずっと続くマシン発の確認、人がいるときだけ戻ってくるデータ面、です。\n下の三つの図は、経路ごとに、何がどの向きに流れ、何が通らないかを描いています。\n導入だけ、人が持つ SSH 矢印は、人からマシンへ向かいます。向かうのは、導入のときだけです。SSH が運ぶのは、成果物と資格情報です。資格情報は標準入力に載ります。制御プレーンには、この経路の矢印がありません。向こうからダイヤルしません。資格情報は、引数には流れません。\n前提は、人がその OS アカウントへ SSH できることです。制御プレーンがその経路を代わりに持つわけではありません。\n初回は、ホスト鍵の指紋を人に見せます。確認の前に走らせるのは、実害のないコマンドだけです。資格情報も、実行の入力も、この確認の前には送りません。確認のあとは厳密に照合し、鍵が変わったら接続を止めます。\n成果物は、その検証済みの接続で置きます。署名を確認してから、サービスマネージャの対象にします。後からの更新も、人が接続したときの事前確認で行い、実行中の作業があるあいだは延期します。通常の利用者向けコマンドを更新しても、この常駐が指している実体は変わりません。\n資格情報は標準入力で渡します。引数にすると、プロセス一覧に残ります。サービス定義や、サービスマネージャが読む環境に書くと、定義を読める範囲が資格情報の範囲になります。デーモンは、所有者だけが読める権限を先に付けた一時ファイルへ書き、同期してから置き換えます。空のファイルが途中で見えることはあっても、中途半端な中身が資格情報の名前で残ることは避けます。\n置いた直後に、その資格情報で外向きの確認を一回行います。制御プレーンが返した行が、今選んでいるマシンと一致することを見てから、実行の制御を開きます。揃えるものは三つです。SSH が認証した利用者、固定したホスト鍵、資格情報に結び付いた制御プレーン上の行です。トンネルの先が返した行と、人が選んだ行が違うなら、その接続では実行を開きません。\n手元に UI が無い導入は、短いコードを人がブラウザで承認します。交換の記録に、資格情報の実体は置きません。承認前の自己申告、名前やアカウントや識別子は、承認する人がどのマシンかを見分ける表示です。証明ではありません。承認後の受け取りは一度だけで、期限切れも拒否も使用済みも、外からは同じ失敗に見えます。どれが起きたかを分けると、コードを見ているだけの相手に、内部の状態が漏れます。\nその後は、マシンからの接続 導入のあと、マシンは制御プレーンへ、外向きの矢印を二つ開きます。確認が運ぶのは、生存と身元です。作業の取得は、もう一本の矢印で、確認とは分かれています。ファイルの中身と、実行の入出力は、確認へ向かう矢印を持ちません。制御プレーンからマシンへ戻る矢印はありません。ダイヤルしません。\nサービスマネージャがデーモンを持ちます。UI の終了は、停止を呼びません。次に UI を開いたときは、動いているデーモンをそのまま使います。明示の停止だけがラッチを残し、UI も OS のログインも、それを取り消して起動し直しません。\nログアウトのあとにもユーザーサービスが残る設定が要る環境があります。それが無いと、デーモンはログオフで死にます。この欠落は、不在とは別に、「起きても、このままでは作業を受けられない」側の事実として返します。ログインに紐づく常駐と、ログイン無しの常駐は、壊れ方が違います。同じ「止まっている」にまとめると、どちらを直せばよいかが消えます。\n制御 API は、そのアカウントの UNIX ソケットだけです。トークンはリクエストごとに要ります。転送の近端を UNIX ソケットにできず、ループバックの TCP にせざるを得ない環境でも、境界はトークン側に残します。ポートに届いただけのプロセスは、認証の失敗しか得ません。トークンは操作側のメモリに置き、そちらにはディスクへ書きません。\n生存確認は、外向きの HTTPS です。間隔はサーバが毎回返します。クライアントに焼き込むと、間隔を変えたいマシンにだけ、新しいバイナリが届きません。下限は数十秒です。資格情報を受け取ったとき、準備状態が変わったとき、停止する直前には、間隔を待たずに打ちます。探査がばたついて熱ループにならないよう、連続の下限だけは置きます。\n停止の直前の一回が、「止めた」と「消えた」を分けます。この一回は、すでに取り消された処理の文脈を継ぎません。短い独自の期限で送ります。理由は、人の操作、更新、再起動、引き継ぎ、シグナル、失効のように、決まった語だけを載せます。\n確認のボディに書く名前、版、準備状態、ライフサイクルは、表示です。権限は生みません。認可は資格情報だけが持ちます。識別子を知っていても、既に資格情報があるインストールの二枚目は発行しません。再発行は、今の資格情報によるローテーションか、マシンの削除です。削除は識別子を再び使えるようにします。これを欠くと、秘密を失ったマシンを入れ直せません。未使用の識別子を名乗って資格情報を得るには、そのマシン上の識別子を読めている必要があります。識別子はどこにも公開しません。\nローテーションは、新しい世代を出したうえで、古い世代をすぐ捨てません。新しい値を受け取ってから保存するまでの間に落ちても、古い鍵で確認を続けられるようにするためです。古い世代を切るのは、新しい鍵が一度成功したときです。成功が、届いた証拠になります。古い鍵から次の世代を始めさせると、控えてあった古い鍵が別の世代列を始められます。ローテーションを始めてよいのは、今有効な世代だけです。\n失効と、ローテーションの遅れは、応答で区別します。マシンが削除されたときは、ループを止めて待ちます。識別子は解放されているので、人がもう一度認めれば戻れます。デーモンはそのあいだ、空へ確認を打ち続けません。鍵が世代遅れで切られただけなら、手持ちの鍵で間隔を空けて再試行します。この二つを同じ「知らない鍵」に潰すと、削除が、一覧から隠しただけに戻ります。\n準備状態は、デーモンが断言する真偽値にしません。阻害を表すコードを制御プレーンが解釈します。生存は、最後の確認からの時間だけで決めます。二分程度より新しければ在席、それより前なら不在、一度も無ければ未着、です。ライフサイクルは、最後に何と言ったかと、その後も打ち続けているかの組です。停止と言ってから沈黙したときだけ、停止とみなします。稼働中と言ったあとに沈黙したときは不明であり、停止ではありません。クラッシュも、スリープも、ネットワークの死も、ここには同じように見えます。未来についての自己申告は保存しません。\n表示名は、最初に名前が載った確認で一度だけ採用します。デーモンは定期的に OS のホスト名を送ります。毎回それで上書きすると、人が付けた名前をマシンが取り消します。起動直後で探査が終わっていない欠測は、阻害が無いという意味に読みません。保存してある阻害と準備状態は、そのまま残します。\n制御プレーンからマシンへの依頼は、生存確認の応答に載せます。ダイヤルする経路が無いからです。ログの末尾が欲しい、という依頼は、行に時刻として書きます。デーモンは次の応答でその時刻を見ます。アップロードは別の外向き送信で、確認の処理からは外します。確認が転送を待つと、遅いディスクがオフラインに見えます。同じ時刻への再送は一度だけにし、失敗したときだけ次の確認でやり直します。応答に同じ時刻が残っていても、成功済みなら送り直しません。依頼した時刻と、アップロードがエコーした時刻が一致したときだけ、依頼を消します。別の依頼が途中で重なっても、先のアップロードが後の依頼を消しません。マシンが止まっているあいだの依頼は失敗ではありません。次に確認が戻ったときに届きます。\n作業の取得も、マシン発の短い要求です。生存確認とは分けました。静かなマシンのコストは、見ているディレクトリの数ではなく、マシンの数に比例させます。一台が複数の場所を見ていても、生存の接続は一つで足ります。宛先がこのインストールに固定された作業は、沈黙しているあいだはここで待ちます。\nデータ面は、人がいるときだけ SSH 人がいるあいだ、読み書きは人からマシンへ SSH で流れ、変更の通知はマシンから人へ戻ります。人がいないあいだ、データ面の矢印は待ちます。外向きの確認は、それと一緒には待ちません。確認は続きます。\n人がそのマシンで対話するときは、既存の SSH の転送で、先の UNIX ソケットに着けます。要求のたびに新しい TCP や暗号の握手を始めるのではなく、張ってある接続の上にチャネルを足します。コストは、呼び出し一回ぶんの往復に近いです。人が操作するペースの読み書きなら、これで足ります。変更の通知は、先のマシンが見て、こちらへ流します。経路の向こうを定期的に全部読み直すことはしません。\n転送が届くのは、認証した OS アカウントのソケットまでです。同じ筐体の別アカウントは、別のソケット、別の資格情報、別の行を持ちます。筐体を共有しても、アカウントを横断しません。どの作業場を見せるかは、マシンの資格情報とは別に認可します。マシンは人に属し、ある作業場から見えるのは、そこへ結び付けたディレクトリまでです。他の作業場の一覧には使いません。\nSSH の宛先とホスト鍵は、操作する側の手元に置きます。制御プレーンの身元にはしません。人が SSH できないあいだも、生存確認と作業の取得は外向きのまま続きます。データ面だけが、その人の経路が戻るまで待ちます。\n何が解けるようになったか ポートを開けず、常時の VPN も張らずに、NAT の向こうのインストールを一台として数え、生存を見られます。制御プレーンはアドレスを持たないので、NAT の対応表が切れても、次の外向き確認で関係が戻ります。\nUI を閉じてもデーモンは残ります。閉じたことは停止ではありません。意図した停止、失効、ただの消失は、同じ「見えない」に潰れません。在席しているが作業を受けられない、という状態も、不在とは別の文で返せます。\n一台を削除すると、その資格情報では次から動けません。削除と失効を分けて残すと、次の確認が削除を取り消します。\nログのように、今は入れないが次に起きたら欲しいものは、ダイヤルせずに依頼できます。依頼は確認の応答で届き、答えはマシンからの別の送信で戻ります。\n人が接続しているあいだは、データ面をその SSH に載せられます。接続していないあいだも、マシンとしての生存と、固定した作業の待機は残ります。\n秘密はプロセス一覧にもサービス定義にも出ません。新しいマシンの承認は、人の操作に閉じます。同じ筐体の別アカウントは、別のマシンとして扱えます。\n通信の形を一つにまとめると、届かなさ、セッションの短さ、データ面の大きさ、身元の強さが、同じ経路の上で同時に満たせなくなります。分けたのは、それぞれが失敗する条件が違うからです。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/resident-agent-behind-nat/","summary":"NAT 配下の常駐エージェントを、外向き接続・独立した生存確認・必要時の SSH セッションで設計する方法を説明します。","title":"NAT の向こうでエージェントを常駐させる"},{"content":"私が携わっているコードベースは、複数の言語が同居するモノレポです。常駐する Go のデーモンとその CLI が、PostgreSQL をバックエンドとする Python の API と並んでいます。\n本稿では、このコードベースでの最近の作業から得た 5 つの手法をまとめます。互いに直接の関係はありませんが、根底にある考え方は共通しています。本番で起きる問題の多くは、必要以上に強い、長命な、あるいは非決定的な何かに起因する、ということです。それはロックであることもあれば、プロセスやリトライ、あるいはレビューコメントの中にしか存在しないルールであることもあります。\n1. 既存のコードベースに Go のスタイルガイドを適用する 課題 レビューコメントの中にしか存在しない規約はスケールしません。レビュアーは同じ指摘を繰り返し、ルールは少しずつぶれていき、新しく加わったメンバーはそもそもルールが何なのかを知る手段がありません。定番の解決策は linter を有効にすることですが、既存のコードベースでは別の理由でうまくいきません。初回の実行で大量の指摘が一度に出て、その指摘の山を前に結局 linter が無効化されてしまうのです。\n導入：まず新しいコードをゲートし、既存の違反はパッケージ単位で解消する issues.new-from-merge-base は、ターゲットブランチとのマージベース以降に持ち込まれた指摘だけを報告します。これにより、初日から新しいコードには準拠を求めつつ、既存のコードには手を付けずに済みます。new-from-rev ではなく、こちらを選んでください。origin/main のようなリビジョンは動き続ける先端であり、ベースブランチが自分の分岐点より先に進むと、それとの差分には他人の変更が含まれ、他人の指摘まで報告されてしまいます。マージベースは動きません。ただし、CI のチェックアウトには全履歴が必要になります（actions/checkout の fetch-depth: 0）。shallow clone ではマージベースを計算できないからです。\n既存の違反は、その後パッケージ単位で 1 つずつ取り除いていきます。まだ直せない関数には、//nolint:gocyclo // pre-existing; tracked for refactor のように個別の抑制を付けます。nolintlint があれば、すべての例外が grep で見つかり、理由も付いています。解消作業は、それらの行を消していくだけの話になります。レガシーの抑制が 1 つも残らなくなったら、new-from-merge-base を外してツリー全体を lint します。\nリファクタリングそのものについても触れておきます。長い関数の循環的複雑度が高くなる原因は、ほぼ決まった数パターンに収まります。switch がすべての case の本体をインラインで抱えている、エラー処理がネストしたブロックの中で正常系と交互に現れる、複数のフェーズ（準備、実行、後処理）が 1 つの関数本体を共有している、といったパターンです。フェーズをメソッドに、インライン化された case をディスパッチテーブルに切り出せば、ほとんどは解消します。落とし穴は、引数が 7 つもあるヘルパーを切り出してしまうことです。それは、ヘルパー間で共有している状態を小さな struct にまとめるべきか、フェーズの切り方が間違っているかのどちらかを意味します。振る舞いを変えない切り出しは、振る舞いを変えるコミットとは別のコミットにしておきましょう。\nすべての関数を無理に閾値以下に収める必要はありません。単一の switch が仕様をそのまま写しているパーサやプロトコルのステートマシンは、1 つの関数のままのほうが仕様と照らし合わせやすくなります。理由付きの //nolint は、まさにこういうときのためにあります。\ngo/parser によるアーキテクチャテスト 構造に関するルールの中には、どの linter もカバーしていないものがあります。たとえば、internal 配下のすべてのパッケージは自身の境界をドキュメント化しなければならない、あるいは特定のサービス型はインターフェース越しにしかパッケージ境界をまたいではならない、といったルールです。こうしたルールは、ソースツリーをパースする普通のテストとして安価に強制できます。\npackage boundaries import ( \u0026#34;go/parser\u0026#34; \u0026#34;go/token\u0026#34; \u0026#34;os\u0026#34; \u0026#34;path/filepath\u0026#34; \u0026#34;strings\u0026#34; \u0026#34;testing\u0026#34; ) // internal/ 配下のすべてのパッケージは、パッケージコメントで自身の役割を説明します。 func TestInternalPackagesAreDocumented(t *testing.T) { root := filepath.Join(\u0026#34;..\u0026#34;, \u0026#34;..\u0026#34;, \u0026#34;internal\u0026#34;) err := filepath.WalkDir(root, func(dir string, d os.DirEntry, err error) error { if err != nil || !d.IsDir() { return err } files, _ := filepath.Glob(filepath.Join(dir, \u0026#34;*.go\u0026#34;)) documented, hasSource := false, false for _, file := range files { if strings.HasSuffix(file, \u0026#34;_test.go\u0026#34;) { continue } hasSource = true f, err := parser.ParseFile(token.NewFileSet(), file, nil, parser.PackageClauseOnly|parser.ParseComments) if err != nil { return err } documented = documented || f.Doc != nil } if hasSource \u0026amp;\u0026amp; !documented { t.Errorf(\u0026#34;%s has no package comment\u0026#34;, dir) } return nil }) if err != nil { t.Fatal(err) } } 同じ手法で、ast.StructType と ast.FuncType のノードを走査し、セレクタ式をファイルの import を通じて解決すれば、パッケージをまたいで「具象型ではなくインターフェースを保持する」ことを強制できます。既知の例外は明示的な許可リストで管理し、許可リストのエントリが不要になったらテストが失敗するようにしておきます。そうすれば、リストは縮む一方になります。\n2. すべての CI 実行でレースディテクタを走らせる 判断 レースディテクタは、実行中に実際に発生したデータ競合しか報告しません。適切なインターリーブで実行されなかった経路上の競合は見逃されます。そのため、ローカルでたまに -race を付けて実行する程度では、ほとんど意味がありません。レースディテクタが元を取れるのは、変更のたびにテストスイート全体をその下で実行した場合だけです。\n多くのチームを思いとどまらせるのは、ドキュメントに記載されたコスト、つまり実行時間で 2〜20 倍、メモリで 5〜10 倍というオーバーヘッドです。ただし、このオーバーヘッドはメモリアクセスと同期処理の計装から生じます。サブプロセス、ソケット、タイマーの待ち時間が大半を占めるテストスイートなら、CPU バウンドなものよりはるかに負担は小さくなります。判断する前に、最も重いパッケージで計測してみるとよいでしょう。もう 1 つの必須要件は cgo で、Darwin 以外のプラットフォームでは C ツールチェーンも必要になります。そのため、CGO_ENABLED=0 のビルドでは使えません。\n- run: go test -race -parallel 4 ./... -parallel のデフォルトは GOMAXPROCS です。並列実行される各テストが実プロセスやファイルウォッチャーを起動する場合、このデフォルトのままでは、macOS ランナーのプロセスあたりのファイルディスクリプタ上限を使い切ってしまうことがあります。明示的に上限を指定しておきましょう。\n見つかったもの 見つかった競合は、本番のロジックにはありませんでした。その周りを支える足場のコードに潜んでいたのです。一度もレースディテクタの下で実行されたことのないコードベースでは、典型的なパターンです。\nログの出力先として使われた bytes.Buffer。 テストがバッファをロガーに渡し、サーバーの goroutine がまだ書き込んでいる最中にそれを読み出します。bytes.Buffer は並行利用に対して安全ではありません。ラップしましょう。\ntype syncBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *syncBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *syncBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } テスト用フェイクのエクスポートされたフィールドの、テスト途中での書き換え。 Hold bool を持つフェイクを生成時に設定し、フェイクを実行している goroutine がそれを読んでいる間に、テストが fake.Hold = false を代入します。フィールドをフェイク自身の mutex の内側に移してメソッドとして公開すれば、ロックを迂回できなくなります。\ntype fakeRunner struct { mu sync.Mutex hold bool } // SetHold は Run が解放を待つかどうかを切り替えます。メソッドチェーンでセットアップを簡潔に書けます。 func (f *fakeRunner) SetHold(v bool) *fakeRunner { f.mu.Lock() defer f.mu.Unlock() f.hold = v return f } func (f *fakeRunner) holding() bool { f.mu.Lock() defer f.mu.Unlock() return f.hold } 起動時に代入されるパッケージレベル変数。 グローバル変数に代入する SetLogger は、一度しか起動しないプロセスなら問題ありません。しかし、デーモンを何度も起動するテストバイナリでは、前のインスタンスのバックグラウンド goroutine がまだ読んでいる間に代入が起こります。atomic.Pointer を使えば、ホットパスに mutex を置かずに安全に差し替えられます。\nvar pkgLogger atomic.Pointer[slog.Logger] func SetLogger(l *slog.Logger) { if l != nil { pkgLogger.Store(l) } } func logger() *slog.Logger { if l := pkgLogger.Load(); l != nil { return l } return slog.Default() } 3 つとも根本原因は同じで、同期の責任が呼び出し側に委ねられていたことです。恒久的な対策としては、状態を所有する型の内側にロックを置きます。そのロックの内側から、内部の map や slice への参照を返してはいけません。maps.Clone などでコピーを返しましょう。\n//go:build !race でテストを race ビルドから除外してよいのは、レースディテクタによる速度低下がタイミングのアサーションを壊す場合に限られます。実際の報告を隠すために使ってはいけません。\n3. サブプロセスの寿命を制御する 判断：読み取りは呼び出し元に従い、書き込みは完了させる git を呼び出すデーモンには、呼び出し元がいなくなったときに子プロセスをどう扱うかの方針が必要です。最終的に私は、コマンドが何をするかで扱いを分ける方針に落ち着きました。\n読み取り専用のコマンド（status、log、diff、rev-parse、grep）は、呼び出し元の context.Context に紐付けます。HTTP クライアントが切断したり、デーモンがシャットダウンしたりすれば、子プロセスは kill されます。もう誰もその結果を必要としていないからです。 状態を変更するコマンド（commit、checkout、worktree add、config の書き込み）は、独自のタイムアウトの下で最後まで実行します。呼び出し元がいなくなったからといって、作りかけの worktree や残された index.lock を放置してよい理由にはなりません。 すべてのコマンドには、それとは別に上限のタイムアウトを設けます。credential helper やネットワークファイルシステム、残ったロックで固まった git プロセスは、止まったままの goroutine ではなく、報告可能なエラーになるべきです。 仕組みと、その限界 exec.CommandContext は、context が終了すると cmd.Cancel を呼びます。デフォルトではこれは Process.Kill で、Unix では SIGKILL になります。これが届くのは直接の子プロセスだけです。git fetch は ssh や credential helper を起動し、それらの孫プロセスは生き残ります。孫プロセスが子の stdout を引き継いでいると、Wait がブロックします。パイプをコピーしている goroutine は EOF を待っており、EOF は書き込み側を保持しているすべてのプロセスが終了するまで来ないからです。\nこれに対処する仕組みは 2 つあり、組み合わせて使えます（Cancel と WaitDelay は Go 1.20 以降が必要です）。\ncmd.WaitDelay は、キャンセル後（あるいは子プロセスの終了後）に Wait がどれだけ待つかの上限を決めます。上限を超えるとパイプを強制的に閉じて戻ります。保証されるのは Wait が戻ることであって、プロセスが消えることではありません。 プロセスグループとカスタムの Cancel を組み合わせると、シグナルをツリー全体に届けられます。 //go:build unix func commandInGroup(ctx context.Context, name string, args ...string) *exec.Cmd { cmd := exec.CommandContext(ctx, name, args...) cmd.SysProcAttr = \u0026amp;syscall.SysProcAttr{Setpgid: true} cmd.Cancel = func() error { // 負の pid を指定すると、プロセスグループ全体にシグナルが送られます。 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } cmd.WaitDelay = 2 * time.Second return cmd } これにはトレードオフがあります。Setpgid は子プロセスを端末のフォアグラウンドプロセスグループから外すので、対話的な Ctrl-C が届かなくなります。デーモンならそれで正しいのですが、TTY を中継する CLI では正しくありません。グレースフルな終了を許すために Cancel で SIGTERM を送る場合は、WaitDelay 経過後のエスカレーションがグループリーダーに対する Process.Kill だけであることに注意が必要です。Windows で同じ保証を得るには Job Object が必要になります。読み取り専用のコマンドなら後始末するものが何もないので、グループへの SIGKILL が妥当なデフォルトです。\ngit を非対話的に、パースしやすくする cmd.Env = append(cmd.Environ(), \u0026#34;GIT_TERMINAL_PROMPT=0\u0026#34;, // 認証情報の入力を求めずに失敗させます \u0026#34;GIT_OPTIONAL_LOCKS=0\u0026#34;, // 読み取りで index.lock を取りません ) // -c core.quotepath=off で、非 ASCII のパスを 8 進エスケープせずそのまま出力します。 微妙なのは GIT_OPTIONAL_LOCKS=0 のほうです。デフォルトでは、git status は機会があればインデックスをリフレッシュし、そのために index.lock を取得します。別のプロセスが同じワーキングツリーでコミットしていると、どちらかが \u0026ldquo;index.lock exists\u0026rdquo; で失敗します。読み取りの経路が、書き込みの経路とこのロックを奪い合うことがあってはなりません。\nstdout は戻り値として、stderr はエラーの中に保持します。git が成功に至る途中で出力した警告を、パースされた SHA に紛れ込ませてはいけません。終了ステータスを解釈する前に ctx.Err() を確認し、キャンセルが signal: killed ではなくキャンセルとして報告されるようにします。\u0026ldquo;not a git repository\u0026rdquo; や unborn な HEAD といった既知の stderr のパターンは 1 か所でセンチネルエラーに対応付け、呼び出し側が文字列比較ではなく errors.Is を使えるようにしておきます。\n事例：ツールのフォールバックによる全文検索 ワーキングコピーに対する内容検索は、再実装するのではなく、それを得意とする既存のツールに任せるべきです。フォールバックの順序は rg、git grep、grep とします。それぞれのツールには、無視できない挙動の違いがあります。\nrg は .gitignore を尊重し、隠しファイルとバイナリファイルをスキップします。--hidden を付けて .github/ のような dotfile も検索対象にし、そのうえで .git/ を glob で明示的に除外します。--json を使えば、曖昧さのない構造化出力が得られます。 git grep はワークツリーの中でしか動きません。git rev-parse --is-inside-work-tree で確認し、結果が true でなければ次のツールに回します。--untracked を付けると、未追跡かつ無視されていないファイルも対象になります。-z を使えば、コロンを含むパスも曖昧になりません。 grep -r は ignore ファイルについて何も知りません。.git や node_modules などには --exclude-dir を渡します。--null は GNU grep と BSD grep の両方で使えます。 どのツールでもリテラルマッチ（-F / --fixed-strings）を使い、パターンはオプションの後に -e で渡し、パスの前には -- を置きます。そうしないと、- で始まるユーザーのクエリがフラグとして解釈されてしまいます。各バイナリのパスは sync.OnceValue(func() string { p, _ := exec.LookPath(\u0026quot;rg\u0026quot;); return p }) で一度だけ解決します。\nランナーが正しく扱うべきことは 3 つあります。並行数の制限、結果件数の上限、そして意図的な停止とキャンセルの区別です。\nvar searchSlots = make(chan struct{}, 3) // rg 自体がすでに複数コアに処理を分散します func runSearch(ctx context.Context, dir, bin string, args []string, limit int, parse func([]byte) (Match, bool)) ([]Match, bool, error) { select { // スロットを待ちますが、呼び出し元が諦めたらこちらも諦めます case searchSlots \u0026lt;- struct{}{}: defer func() { \u0026lt;-searchSlots }() case \u0026lt;-ctx.Done(): return nil, false, ctx.Err() } caller := ctx ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, bin, args...) cmd.Dir = dir stdout, err := cmd.StdoutPipe() if err != nil { return nil, false, err } if err := cmd.Start(); err != nil { return nil, false, err } var matches []Match partial := false sc := bufio.NewScanner(stdout) sc.Buffer(nil, 1\u0026lt;\u0026lt;20) // バンドルされた JavaScript などの長い行を許容します for sc.Scan() { if len(matches) == limit { partial = true break } if m, ok := parse(sc.Bytes()); ok { matches = append(matches, m) } } cancel() // ツールをその場で止めます _, _ = io.Copy(io.Discard, stdout) // Wait の前に読み切ります waitErr := cmd.Wait() if err := caller.Err(); err != nil { return nil, false, err // 呼び出し元のキャンセルを 0 件ヒットに見せてはいけません } var exitErr *exec.ExitError if waitErr != nil \u0026amp;\u0026amp; !partial \u0026amp;\u0026amp; !(errors.As(waitErr, \u0026amp;exitErr) \u0026amp;\u0026amp; exitErr.ExitCode() == 1) { return nil, false, waitErr // 3 つのツールいずれも、終了コード 1 は「一致なし」を意味します } return matches, partial, nil } 末尾の順序が重要です。os/exec のドキュメントには、StdoutPipe からの読み取りがすべて完了する前に Wait を呼ぶのは誤りだと明記されています。読み捨てているのはそのためです。上限に達したときは意図的に context をキャンセルしているので、その結果の kill は失敗ではありません。部分的な結果であり、レスポンスでもそう伝えるべきです。呼び出し元に起因するキャンセルでは、context のエラーを返さなければなりません。空の slice を返すと「何も一致しなかった」と読めてしまいます。\nrg と grep は、読めないファイルがあると、一致を出力していても終了ステータス 2 で終わります。権限エラーによる部分的な結果を許容できるかどうかは、ユースケースに応じて判断する必要があります。上のランナーでは、上限に達した場合を除き、これを失敗として扱っています。\n4. bootout の競合を避けて launchd サービスを再登録する 障害のパターン macOS の LaunchAgent をその場でアップグレードするには、plist を書き換えてサービスをリロードします。\nlaunchctl bootout gui/501/com.example.agent launchctl bootstrap gui/501 ~/Library/LaunchAgents/com.example.agent.plist この 2 つを続けて実行すると、2 つ目のコマンドがときどき失敗します。\nBootstrap failed: 5: Input/output error bootout は、後片付けが完了する前に応答を返します。launchd は SIGTERM を送り、ジョブの ExitTimeOut まで待ってから SIGKILL にエスカレーションし、ドメインからのサービスの削除は非同期に行います。その間に同じラベルを bootstrap すると EIO が返ります。厄介なのはその後です。bootout 自体は成功しているので、launchd はジョブが正常にアンロードされたとみなし、KeepAlive はそれを復活させません。再起動で済むはずのアップグレードで、サービスが停止したままになってしまいます。\n消えるのを待ち、上限付きでリトライする func bootout(target string) { _ = exec.Command(\u0026#34;launchctl\u0026#34;, \u0026#34;bootout\u0026#34;, target).Run() // ロードされていなくても問題ありません deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { // ラベルがドメインから消えると、print は非ゼロで終了します。 if exec.Command(\u0026#34;launchctl\u0026#34;, \u0026#34;print\u0026#34;, target).Run() != nil { return } time.Sleep(200 * time.Millisecond) } } func bootstrap(domain, plist string) error { var out []byte var err error for attempt := 1; attempt \u0026lt;= 6; attempt++ { if out, err = exec.Command(\u0026#34;launchctl\u0026#34;, \u0026#34;bootstrap\u0026#34;, domain, plist).CombinedOutput(); err == nil { return nil } time.Sleep(time.Duration(attempt) * 300 * time.Millisecond) // 線形バックオフ } return fmt.Errorf(\u0026#34;launchctl bootstrap %s: %w: %s\u0026#34;, plist, err, bytes.TrimSpace(out)) } 一般的なケースはポーリングで対応できます。リトライは、ラベルが print から消えてから launchd が再び受け付けられる状態になるまでの、残りの隙間をカバーします。リトライには上限が必要です。EIO はこの競合に固有のエラーではなく、plist の不備やパスの誤りでも同じエラーが出るからです。リトライの対象を Input/output error という文字列に絞れば、それ以外の失敗は早く表面化します。いずれにせよ、plist は事前に検証し（plutil -lint）、最後の出力をエラーに含めておきましょう。\n再起動のセマンティクスも定義の一部 KeepAlive に Crashed = true を指定すると、SIGSEGV のようなクラッシュシグナルで死んだ場合にのみジョブが再起動されます。スーパーバイザーに再起動させる目的などで、意図的に非ゼロのステータスで終了したプロセスは再起動されません。その場合は SuccessfulExit = false を使います。こちらは非ゼロで終了すれば必ず再起動します。launchd は再起動の頻度も抑制する（ThrottleInterval、デフォルトは 10 秒）ので、クラッシュループは空回りせず、ペースが落ちるだけで済みます。systemd で相当するのは Restart=on-failure です。\nこれはアップグレードの際に効いてきます。古いインストールには、誤った再起動ポリシーや古いバイナリパスを含む定義が残っているかもしれません。アップグレードのたびに、現行バージョンが書き出すはずの定義を生成してインストール済みのものと比較し、異なれば再登録します。この操作は冪等で、別途マイグレーションの手順を用意しなくても、古いインストールが修正されます。\nsystemd には同じ競合はありません。systemctl stop や restart は、--no-block を指定しない限りジョブの完了を待ちます。systemd でよくある失敗は、unit を変更した後の systemctl --user daemon-reload を忘れることと、ログインセッションなしで user unit が動くと期待してしまうことです。後者には loginctl enable-linger が必要になります。\n5. PostgreSQL：FOR UPDATE と FOR NO KEY UPDATE 課題 親行の状態遷移を直列化する一般的な方法は、SELECT ... FOR UPDATE で読み、判断し、書き込むことです。しかしこうすると、状態遷移は外部キーで親を参照する行を挿入または更新した、実行中のすべてのトランザクションを待つことになります。逆も同じで、親がロックされている間は、そうした子の書き込みが待たされます。どの操作も親のキーを変更しないにもかかわらず、更新の集中する親行ではロックの渋滞（lock convoy）が起きます。\n仕組み 子の側の外部キーチェックはシステムトリガーとして実装されており、実質的に SELECT 1 FROM ONLY parent WHERE pk = $1 FOR KEY SHARE を実行します。これは挿入した文の終わり（遅延制約ならコミット時）に実行され、ロックは子のトランザクションが終わるまで保持されます。FOR KEY SHARE は、参照先のキーが子の知らないうちに削除・変更されないことを保証します。行レベルロックの競合表によれば、これが競合するモードは FOR UPDATE だけです。\n要求 \\ 保持 KEY SHARE SHARE NO KEY UPDATE UPDATE FOR KEY SHARE X FOR SHARE X X FOR NO KEY UPDATE X X X FOR UPDATE X X X X 行ロックは、共有のロックテーブルではなくタプルヘッダ（xmax）に記録されます。並行する FK チェックのように、複数のトランザクションが 1 つの行に共有ロックを保持している場合、PostgreSQL はそれを MultiXact として記録します。通常の SELECT がこれらを待つことは一切ありません。MVCC の読み取りは行ロックを取らないからです。\nこの修正を安全なものにしているのは、次の点です。キー列を変更しない UPDATE は、それ自体がすでに FOR NO KEY UPDATE を取得しています。ここで PostgreSQL の言う「キー列」とは、外部キーから参照できる一意インデックスに含まれる列のことで、部分インデックスや式インデックスは含みません。したがって、そうした更新の前に明示的に取る FOR UPDATE は後に続く書き込みよりも強いことになり、その余分な強さは FK チェックとの競合以外に何ももたらしません。\n修正 例では次のスキーマを使います。\nCREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, status text NOT NULL DEFAULT \u0026#39;open\u0026#39;, closed_at timestamptz ); CREATE TABLE order_items ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id bigint NOT NULL REFERENCES orders (id), sku text NOT NULL ); FOR NO KEY UPDATE は自分自身とは競合するので、同じ行に対する 2 つの状態遷移は引き続き直列化されます。一方で FOR KEY SHARE とは競合しないので、子の挿入は待たずに進められます。psycopg 3 では次のようになります。\nfrom psycopg import AsyncConnection async def close_order(conn: AsyncConnection, order_id: int) -\u0026gt; str: async with conn.transaction(): async with conn.cursor() as cur: await cur.execute( \u0026#34;SELECT status FROM orders WHERE id = %s FOR NO KEY UPDATE\u0026#34;, (order_id,), ) row = await cur.fetchone() if row is None: return \u0026#34;missing\u0026#34; if row[0] == \u0026#34;closed\u0026#34;: return \u0026#34;already_closed\u0026#34; # 冪等な再実行 await cur.execute( \u0026#34;UPDATE orders SET status = \u0026#39;closed\u0026#39;, closed_at = now() WHERE id = %s\u0026#34;, (order_id,), ) return \u0026#34;closed\u0026#34; タイミングではなくロックをテストする ロックの挙動をタイミングでテストすると、どちらの方向にも不安定になります。lock_timeout を使えば、「待たされたはず」を決定的な LockNotAvailable エラー（SQLSTATE 55P03）に変えられます。子の行を挿入した未完了のトランザクションで FOR KEY SHARE を保持したまま、短い lock_timeout の下で対象の操作を実行します。\nimport asyncio import pytest from psycopg import AsyncConnection from psycopg.errors import LockNotAvailable DSN = \u0026#34;postgresql://localhost/test\u0026#34; # `order_id` は、open 状態の注文を挿入してその id を yield する pytest の fixture です。 async def hold_fk_share(order_id: int, release: asyncio.Event) -\u0026gt; None: async with await AsyncConnection.connect(DSN) as conn: await conn.execute( \u0026#34;INSERT INTO order_items (order_id, sku) VALUES (%s, \u0026#39;X\u0026#39;)\u0026#34;, (order_id,) ) await release.wait() # トランザクションと、その KEY SHARE を開いたままにします await conn.rollback() async def with_writer_in_flight(order_id: int, sql: str) -\u0026gt; None: release = asyncio.Event() holder = asyncio.create_task(hold_fk_share(order_id, release)) try: await asyncio.sleep(0.2) async with await AsyncConnection.connect(DSN) as conn: await conn.execute(\u0026#34;SET lock_timeout = \u0026#39;2s\u0026#39;\u0026#34;) await conn.execute(sql, (order_id,)) await conn.rollback() finally: release.set() await holder @pytest.mark.asyncio async def test_no_key_update_does_not_wait_for_fk_writers(order_id: int) -\u0026gt; None: await with_writer_in_flight( order_id, \u0026#34;SELECT 1 FROM orders WHERE id = %s FOR NO KEY UPDATE\u0026#34; ) @pytest.mark.asyncio async def test_for_update_would_have_waited(order_id: int) -\u0026gt; None: with pytest.raises(LockNotAvailable): await with_writer_in_flight(order_id, \u0026#34;SELECT 1 FROM orders WHERE id = %s FOR UPDATE\u0026#34;) ネガティブコントロールは省略できません。これがなければ、ポジティブ側のテストは、コードがどのロックモードを使っていても通ってしまいます。空いているロックがタイムアウトに達することはないからです。3 つ目のテストとして、並行する 2 つの状態遷移が引き続き直列化されることも検証すべきです。そもそもロックは、その性質のためにあったのですから。\n弱めるべきでないとき トランザクションがその行を削除する、またはキーを変更する場合。 これらの文はいずれにせよ FOR UPDATE を取ります。先に弱いロックを取ると後でアップグレードが必要になり、ロックのアップグレードはデッドロックの典型的な原因です。PostgreSQL のドキュメントも、必要になる最も制限の強いモードを最初に取得するよう勧めています。 子の挿入をブロックすることが意図である場合。 たとえば「注文の確定処理中は新しい明細行を追加させない」といったケースです。弱いロックにすると、その保証は黙って失われます。ロックモードの副作用に頼るのではなく、子の書き込み経路でのステータスチェックや制約によって、意図を明示的に表現しましょう。 そもそも「読んで、判断して、書く」ロジックがない場合。 UPDATE orders SET status = 'closed' WHERE id = %s AND status \u0026lt;\u0026gt; 'closed' RETURNING id のような条件付きの単一の更新文には、明示的なロックは不要です。暗黙に FOR NO KEY UPDATE を取るうえ、ロックを保持し続けるべき独立した読み取りの段階もありません。 まとめ 5 つの問題は、いずれも同じ過ちが異なる層で現れたものです。\nレビューコメントの中にしか存在しない lint ルール 呼び出し側に委ねられた同期の責任 自分を起動したリクエストよりも長生きするサブプロセス 直前のコマンドが戻ったことを理由に、不要とみなされたリトライ 保護対象の書き込みよりも強い行ロック 対策には共通の原則があります。強さと寿命を操作に見合ったものにし、正しい振る舞いを決定的かつ機械的に検査できるものにすること。 新しいコードだけをゲートし、残りは段階的に解消していきます。レースディテクタは変更のたびに走らせます。キャンセル時にはプロセスツリー全体を kill し、書き込みは完了させてください。bootstrap の前には、後片付けが完了したことを実際に観測できるまで待ちましょう。そして、直列化すべきものを直列化できる範囲で最も弱い行ロックを取り、より強いロックの下では失敗するテストでそれを証明します。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/go-daemon-and-python-api-hardening/","summary":"複数言語が同居するコードベースで得た 5 つの手法をまとめます。既存の Go コードへの golangci-lint の段階導入、CI でのレースディテクタの常時実行、サブプロセスの寿命の制御、bootout の競合を避けた launchd サービスの再登録、そして外部キーを踏まえた PostgreSQL の行ロック強度の選び方です。","title":"Go デーモンと Python API の堅牢化"},{"content":"現在、AI エージェントのスタックの多くは Python で作られています。\nそれには理由があります。Python には LangChain、LangGraph、LlamaIndex といった成熟したフレームワークがあり、周辺のエコシステムも試行錯誤に最適化されています。しかし私の場合は、最終的に Go で AI エージェント基盤を構築し、そのほぼすべてをゼロから実装することになりました。\n本稿では、なぜその選択をしたのか、システムの成長とともに初期実装がどう破綻したのか、そしてクリーンアーキテクチャを軸にどう再設計したのかを説明します。あわせて、スレッドベースのチャットモデル、データベース設計、永続化の戦略、そして「とりあえず動く」状態から実際にプロダクトの中で使い続けられるものへ移行する過程で直面した、現実的なトレードオフについても取り上げます。\nなぜ Go で AI エージェント基盤を作るのか 最初の理由は、思想的というよりは実務的なものでした。プロダクトのバックエンドが、すでに Go で書かれていたのです。\nMVP の段階で別の Python サービスを導入すれば、デプロイ、レビュー、運用の複雑さが増します。既存の Go バックエンドの中にすべてを収め、まずはそこで素早く進めるほうが安上がりでした。\n実際に作り始めてみると、Go には AI エージェントの基盤に意外なほど向いている性質がいくつもあることにも気づきました。\ngoroutine で並行実行をシンプルに書ける SSE、WebSocket、gRPC を軽量に実装できる 単一のバイナリで済むことが多いため、デプロイが容易 インターフェースによって抽象化の境界が明示的になる 依存関係をきれいに分離すれば、テスト容易性が高い Go には Python ほど多くのエージェント向けツールがないため、実装コストはいくらか高くなります。その代わり、アーキテクチャ、永続化、オブザーバビリティ、長期的な保守性を、はるかに細かくコントロールできます。\nなぜ LangGraph や LlamaIndex を使わなかったのか 理由の一部はタイミングです。その時点で、すでに Go で直接 MVP を作り始めていました。\nしかし、構造的な理由もありました。以前のプロダクトで LangGraph Platform の上に AI エージェントシステムを構築したとき、プロダクトへの統合に関して、いくつかの点で想定以上に制約が多いと感じました。たとえば次のような点です。\n内部状態がブラックボックスになりやすい 永続化やスレッド管理が、フレームワークの規約に従わざるを得ないことが多い モデルやツールが、フレームワーク固有の抽象に結合する 部分的な導入は見た目ほど簡単ではない エクスポート、ロギング、監査可能性の扱いが厄介になりうる フレームワークは有用で、目的が高速なプロトタイピングであればなおさらです。しかし、AI が既存のバックエンドの中で長く使われる機能になるなら、メッセージをどう保存するか、ツールをどう実行するか、ストリーミングをどう扱うか、そして後からすべてをどうエクスポート・監査できるかを自分でコントロールできることを、私は非常に重視します。\nそこで、エージェントフレームワークをシステムの中心に据えるのではなく、プロダクトのアーキテクチャを中心に保ち、エージェント層は薄く、差し替え可能なままにしておきたかったのです。\nなぜクリーンアーキテクチャは AI エージェントに合うのか AI エージェントのシステムは絶えず変化します。\nモデルが変わり、プロンプトの形式が変わり、ツールが追加されては削除され、メモリの戦略も進化します。ストリーミングの要件が SSE から WebSocket や gRPC に移ることもあれば、検索パイプラインが置き換えられることもあります。Planner-Executor パターンも現れては消えていきます。\nつまり、本当の課題は「LLM API をどう呼ぶか」ではありません。変更をどう隔離するかです。\nまさにここで、クリーンアーキテクチャが役に立ちます。\n出典: The Clean Architecture \u0026ndash; Robert C. Martin (Uncle Bob)\n私の設計では、エージェントの中核となる概念を、次のような抽象に分離しています。\nModel: OpenAI、Anthropic、ローカル LLM Memory: インメモリ、PostgreSQL、Redis Tool: 外部 API 呼び出し、DB 参照、計算、検索 Agent: ReAct 型の実行、ワークフローベースのエージェント、Planner-Executor Streaming: SSE、WebSocket、gRPC これらの概念をインターフェースの背後に置くことで、アプリケーション層を壊さずに実装を差し替えられます。具体的には、次のようなことです。\nOpenAI から別のプロバイダに切り替えても、大規模なリファクタリングを強いられない ツールの追加や削除の影響が局所にとどまる メモリをインメモリから永続ストレージに変えても、エージェントのロジックを書き直さずに済む トランスポートを SSE から WebSocket に変えても、エージェント自体を再設計する必要がない AI システムでは、この柔軟性がほぼ何よりも重要になります。\nBefore：モノリシックな /api/ai/chat 最初のバージョンは、意図的にシンプルにしました。\nエンドポイントは /api/ai/chat の 1 つだけで、ほぼすべての処理をそこで行っていました。\nリクエストのバインド プロンプトの構築 ツールの登録 OpenAI の呼び出し SSE によるレスポンスのストリーミング MVP の段階では、これが正しい選択でした。コード量を最小限に抑えられ、全体の挙動も追いやすいからです。1 つのファイルを見れば、処理の流れがすべてわかりました。\n概念的には、構造は次のようになっていました。\nPOST /api/ai/chat ↓ [Presentation] ChatHandler ├─ request binding ├─ prompt building ├─ tool registration └─ agent execution ↓ [Application] ReactAgent ├─ OpenAI call ├─ tool execution ├─ state handling └─ streaming ↓ [Domain] prompt/context helpers ↓ [Infrastructure] OpenAI client PostgreSQL repository このバージョンは、機能を検証するには十分でした。\nしかし、すぐに構造上の問題に突き当たりました。\n最初のバージョンの問題点 1. アプリケーション層が事実上 OpenAI に縛られていた OpenAI の SDK が、アプリケーションロジックに直接埋め込まれていました。そのため、モデルプロバイダの変更は 1 つの実装を差し替えれば済む話ではなく、複数の層に影響しました。\nアプリケーションロジック エージェントの振る舞い レスポンスの処理 エラー処理 「システムが OpenAI をサポートしている」のではなく、実態は「OpenAI がシステムに焼き付いている」状態でした。\n2. ツールが具体的な実装と密結合していた ツールレジストリと個々のツールが、アプリケーションロジックに近すぎました。リポジトリに直接触れるツールもあれば、モデル固有のデータ形式に依存するツールもありました。そのため、次のことが難しくなっていました。\nツールを単独でユニットテストする 複数のエージェント間でツールを再利用する MCP のような別のツールプロトコルに対応していく 3. ストリーミングが SSE 前提でハードコードされていた 最初から SSE を前提に設計していたため、後から出力のトランスポートを変えるには、ハンドラからエージェントの実行フローに至るまで、大量のコードに手を入れる必要がありました。\nWebSocket や gRPC が具体的に必要になる前から、アーキテクチャ上の問題はすでに見えていました。ストリーミングの詳細が、内側にまで深く漏れ出していたのです。\n4. テストがつらかった アプリケーション層が OpenAI の SDK、ツールの実装、メモリの実装に直接依存していたため、どれか 1 つをモックしようとすると、ほかも芋づる式に巻き込まれがちでした。\nその結果、コードは結合テスト中心の方向に押しやられていきました。本当にやりたかったのはもっと単純なことで、Model、Memory、Tool の依存をモックし、エージェントの振る舞いだけを検証したかったにもかかわらず、です。\nこの時点で、エージェントのシステムにはきちんとしたアーキテクチャ上の境界が必要だと判断しました。\n再設計：エージェントの中核を pkg/ai に移す 再設計の核となるアイデアはシンプルでした。\nAI の中核となる抽象を専用のパッケージにまとめ、プロバイダ、永続化、トランスポートの詳細はその外に置く、というものです。\n結果として、構造はおおよそ次のようになりました。\npkg/ai/ agents/ // Agent の抽象と ReactAgent memory/ // Memory の抽象 models/ // Model の抽象 openai/ // OpenAI の実装 prompts/ // プロンプトの抽象 streaming/ // StreamEvent / StreamWriter の抽象 tools/ // Tool / ToolRegistry の抽象 types.go // Message / ToolCall / TokenUsage application/ ai/tools/ // アプリ固有のツール usecases/ // AgentRunUsecase / ThreadUsecase / ThreadChatUsecase infra/ ai/memory/ // PostgreSQL によるメモリ ai/tools/ // デフォルトのツールレジストリ ai/streaming/ // SSE writer など presentation/ handlers/ai/ // HTTP ハンドラ これは要するに、Go バックエンドの中に置いた、薄い自前のエージェント層です。\n役割としては LangChain の軽量な社内版に近いのですが、プロダクトをフレームワークに合わせるのではなく、プロダクト自身のアーキテクチャに合うように作られています。\nModel の抽象化 最初のステップは、プロバイダ固有の振る舞いを Model インターフェースの背後に隠すことでした。\ntype Model interface { Generate( ctx context.Context, messages []ai.Message, opts ...ai.ModelOption, ) (*Response, error) } type StreamingModel interface { Model GenerateStream( ctx context.Context, messages []ai.Message, opts ...ai.ModelOption, ) (\u0026lt;-chan streaming.StreamEvent, error) } これにより、2 つの関心事がきれいに分離されます。\nアプリケーションとエージェントは、モデルを呼び出していることだけを知っている プロバイダ固有のコードは別の場所にある OpenAI 固有の実装は pkg/ai/openai の中に隔離されており、次の役割を担います。\n内部のメッセージを OpenAI のリクエストメッセージに変換する ツール定義を、プロバイダの function calling の形式に変換する ストリーミング出力を、内部のストリームイベントに変換する そのため、システムのほかの部分は OpenAI の SDK について何も知る必要がありません。\nMemory の抽象化 会話履歴は Memory インターフェースを通じて扱います。\ntype Memory interface { LoadHistory(ctx context.Context, opts ...ai.MemoryLoadOption) ([]ai.Message, error) Save(ctx context.Context, msg ai.Message) (*ai.StoredMessageInfo, error) Clear(ctx context.Context) error } 最初はインメモリの実装を使っていました。その後、PostgreSQL による永続化に置き換えました。\n重要なのは、メッセージがメモリ上に保存されているのか、Postgres に保存されているのか、それ以外の場所なのかを、エージェントは知らないということです。エージェントは、インターフェースを通じてメッセージを読み込み、保存するにすぎません。\nこれにより、メモリの戦略は構造的な依存ではなく、差し替え可能な関心事になります。\nTool の抽象化 ツールは、エージェントが外の世界とやり取りするためのインターフェースです。\ntype Tool interface { Name() string Description() string JSONSchema() map[string]any Call(ctx context.Context, args json.RawMessage) (any, error) } type ToolRegistry interface { Register(tool Tool) error Get(name string) (Tool, error) List() []Tool Execute(ctx context.Context, name string, args json.RawMessage) (any, error) } これにより、責務をきれいに分けられます。\npkg/ai/tools が抽象を定義する インフラ層がレジストリの実装を提供する アプリケーションのコードがプロダクト固有のツールを提供する この分離は重要でした。プロダクトのツールはドメインのリポジトリやビジネスルールを必要とすることが多いのですが、エージェントの中核はそれらを直接知るべきではありません。\nエージェント本体：ReAct 型の実行ループ エージェントは抽象にのみ依存します。\nModel Memory ToolRegistry StreamWriter そのため、エージェントのロジックは実行パターンだけに集中できます。\n私の場合は、ReAct 型のループを実装しました。\nメモリから履歴を読み込む 動的なシステムプロンプトを差し込む ユーザーのメッセージを追加する モデルを呼び出す モデルがツール呼び出しを要求したら、それを実行し、ツールの結果を履歴に追加する 再びモデルを呼び出す 最終的な回答が返ってきたら、それを永続化して実行を終了する エージェントは HTTP を知りません。SSE のことも特に知りませんし、PostgreSQL がどう動くかも知りません。知っているのは、メッセージの流れ、ツール呼び出し、停止条件をどうオーケストレーションするかだけです。\nこの境界こそが、再設計で最も価値のある部分でした。\n単発のチャットからスレッドベースのチャットへ 第 2 フェーズでの大きな変更は、チャットを単発のリクエストとして扱うのをやめ、本来のスレッドとしてモデル化し始めたことです。\n中核となるドメインエンティティを 3 つ導入しました。\nAgent: エージェントの定義と設定 Thread: 会話のセッション Message: スレッド内の個々の要素 これは、ステートレスな /chat エンドポイントよりも、プロダクトの実情にはるかによく合います。\nAgent Agent エンティティは、次のような設定を保持します。\n名前 説明 モード 有効なツール モデル temperature 最大トークン数 タイムアウト メタデータ Thread Thread エンティティは、次を保持します。\n所有者とテナント 紐づくエージェント タイトル ステータス メタデータ 最終メッセージのタイムスタンプ Message Message エンティティが保持するのは、次の項目です。\nスレッド ID ロール（user、assistant、system、tool） 構造化されたコンテンツ メッセージの順序 ツール呼び出し ツール呼び出し ID トークン使用量 タイムスタンプ このモデルによって、デモ用のエンドポイントではなく、プロダクトレベルのチャットシステムを作れるようになります。\nデータベース設計 データベースのスキーマは、意図的にシンプルにしました。\nagents threads messages 大きな意味を持った設計上の選択の 1 つが、メッセージに message_index を持たせたことです。\nこれには、いくつかの利点がありました。\n順序がタイムスタンプに依存せず、明示的になる 最新 N 件のメッセージを簡単に読み込める 挿入や編集といった将来の機能にも対応しやすくなる 些細なことに聞こえますが、チャットシステムでは、永続化、デバッグ、リプレイを気にし始めた途端に、安定した順序付けの重要性が増します。\nインメモリの履歴を PostgreSQL のメモリに置き換える スレッドのモデルができたので、次のステップはメモリの裏側を PostgreSQL にすることでした。\nPostgres の実装が行うことは、主に 2 つあります。\n履歴の読み込み スレッドに属するメッセージを message_index 順に取得し、内部の ai.Message オブジェクトに変換します。\n意図的に加えた細かな工夫の 1 つが、永続化されたデータから再読み込みする際に、システムメッセージを除外することです。システムプロンプトは実行のたびに動的に生成されるため、通常の保存された履歴と同じように扱いたくなかったのです。\nメッセージの保存 保存時、実装は次のことを行います。\n次の message_index を割り当てる ツール呼び出し ID を扱う メッセージの可視性を決める この可視性という概念は役に立ちました。たとえば、\nユーザーに見せるメッセージは、エンドユーザー向けの UI に表示できる 内部的なツールの出力や途中のアシスタントメッセージは、ユーザーからは隠したまま、デバッグや監査には使える これにより、エージェントが実際に行っていることと、プロダクトの UI が見せるべきものとを、はるかにきれいに分離できます。\nユースケースとハンドラ 抽象ができあがると、アプリケーション層はずっとシンプルになりました。\nAgentRunUsecase は、オーケストレーションを担います。\nアプリ固有のコンテキストを解決する ツールレジストリを構築する そのユースケースに関係するツールを登録する モデルファクトリを使ってモデルを構築する エージェントを構築する それを実行する 言い換えれば、ユースケースはどの部品を組み立てるかを選びますが、エージェントのロジックそのものは持ちません。\nプレゼンテーション層は、さらに薄くなっています。ハンドラがやるべきことは次だけです。\nリクエストを検証する 認証を行う ストリームライターを作る 適切なユースケースに処理を委譲する つまり、HTTP の関心事は HTTP の関心事のまま、AI の関心事は AI の関心事のままに保たれます。\nこの分離は、まさに最初から望んでいたものでした。しかし、それが手に入ったのは明示的な抽象を導入してからです。\n実装時の現実的な落とし穴 再設計でアーキテクチャは改善しましたが、実装上の罠がなくなったわけではありません。\n特に重要だった 2 つを紹介します。\n1. ユーザーのメッセージが誤って二重に保存されていた ある時点で、ユースケースとエージェントの両方が、同じユーザーのメッセージを保存していました。\nユースケースが実行前に保存していました エージェントが、履歴を読み込んでユーザーの入力を追加した後に、もう一度保存していました 責務の境界を理解してしまえば、修正は単純でした。\nエージェントが管理するすべてのメッセージ（ユーザー、アシスタント、ツール、そしてシステム関連の内部フロー）は、エージェント側がメモリを通じて保存すべきです。ユースケースがその責務を重複して持つべきではありません。\n2. ストリーミングのレスポンスが永続化されていなかった トークンの差分をクライアントに直接ストリーミングしていると、保存すべき最終的なアシスタントメッセージは自動的には手に入りません。\nそのため、エージェントはストリーミングしたテキストを明示的に蓄積する必要がありました。\nテキストの差分をすべてバッファに集める その差分は、引き続きクライアントにストリーミングする ストリームが終わったら、結果がツール呼び出しではなく最終回答であれば、集めたテキストを 1 つのアシスタントメッセージとして保存する この手順がないと、UI には回答がリアルタイムに表示されるのに、データベースには最終的なレスポンスが実際には残りません。\nこの種の問題は、トランスポートと永続化のロジックが明確に分離されていないと見落としやすいものです。\n再設計で何が楽になったか 最大の改善はエレガントさではありません。変更が局所的になったことです。\n再設計後は、次のようになりました。\nモデルプロバイダを変更する際の影響範囲が、ずっと限定されるようになりました ツールの追加や削除が、コードベースの無関係な部分を汚染しなくなりました メモリの実装を変えても、エージェントのロジックを書き直す必要がなくなりました ストリーミングのトランスポートが、差し替え可能な出力の関心事になりました HTTP なしでエージェントの振る舞いをテストできるようになりました ユースケースレベルのオーケストレーションが明確になりました 実際、これで開発のループが変わりました。\nハンドラやエンドポイントのロジックの中で直接実験するのではなく、pkg/ai の中で自由にイテレーションを回し、その成果を後からアプリケーション層やプレゼンテーション層につなぎ込めるようになったのです。\nこれにより、実験も安定化もやりやすくなりました。\nそれでも残るトレードオフ このアプローチを過大に売り込むつもりはありません。\nGo で薄い自前のエージェント層を作るのは、AI 機能を画面に出すための最速の方法ではありません。デモが欲しいだけの場合や、そのシステムがプロダクトの中核機能になることがない場合は、既存のフレームワークを使うほうが十分に良い選択になりえます。\nトレードオフは単純です。\nフレームワーク優先は、短期的な開発を速くします アーキテクチャ優先は、長期的なコントロールを高めます AI の機能が既存の Go バックエンドの中で動き、きれいに永続化され、監査でき、観測可能で、時間とともに進化していく必要があるなら、アーキテクチャ優先の道のほうがずっと魅力的になります。\n今後の拡張 この設計には、さらなる拡張の余地も残されています。\nRAG RAG は、少なくとも 2 つの方法で追加できます。\nSearchDocumentsTool のようなツールとして メモリの読み込みの中の検索ステップとして ToolRegistry と Memory はどちらも抽象化されているので、どちらのアプローチも、エージェント層のほかの部分を不安定にすることなく後から導入できます。\nマルチプロバイダのモデル ModelFactory インターフェースがあれば、複数のプロバイダに素直に対応できます。\nOpenAI Anthropic ローカル LLM これは後に、エージェントごとのモデル選択や、動的なモデルルーティングにまで発展させられます。\nMCP と外部ツールプロトコル ツールはすでに抽象化されているので、MCP をバックエンドとするツール層も、次の方法で統合できます。\nMCP に対応したツールを実装する あるいは、リモートのツール定義をレジストリに同期する エージェントから見れば、それらもただのツールにすぎません。\nまとめ このプロジェクトから得た最も重要な教訓は、プロダクト向けの AI エージェント構築は、LLM API の呼び出しよりもはるかにシステム設計の問題だということです。\n難しいのは次の点です。\nプロバイダへの依存を隔離する ツールの実行を構造化する メモリを正しく設計する 何を永続化するかを決める トランスポートの詳細をあちこちに漏らさずにストリーミングを扱う AI スタックが変わり続ける中でも、システムをテスト可能に保つ 私のユースケースでは、Go とクリーンアーキテクチャは、これらの問題を解くうえで非常に良い組み合わせでした。\nAI ツールのエコシステムでは今も Python が圧倒的で、それには十分な理由があります。しかし、プロダクトのバックエンドがすでに Go で書かれていて、フレームワークの形をした後付けではなく、本物のプロダクトのサブシステムとして振る舞う AI エージェントシステムが欲しいなら、Go で薄い社内エージェント層を作るのは非常に現実的な選択肢です。\n最初は遅くなります。\nしかし、重要なところでコントロールが手に入ります。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/ai-agent-clean-architecture/","summary":"LangChain を使わずに Go で AI エージェント基盤を構築した理由と、クリーンアーキテクチャによってモデル、メモリ、ツール、ストリーミングの各関心事を独立して差し替えられるようにした方法を紹介します。","title":"AIエージェントのクリーンアーキテクチャ"},{"content":"こんにちは。東京大学で CS を学ぶ学部生（現在休学中）で、フリーランスのソフトウェア / AI エンジニアとして活動している Yusuke Ikoma です。\nこのブログは、自分が学んでいることや取り組んでいることについての考えを整理し、共有する場として始めました。主に次のようなテーマについて書いていきます。\n機械学習・AI \u0026ndash; 論文、実験、いま探求している概念 システム・インフラ \u0026ndash; クラウド、分散システム、DevOps ソフトウェアエンジニアリング \u0026ndash; アーキテクチャ、ツール、実際のプロジェクトから得た教訓 書くことで、考えがよりはっきりします。これらのテーマに興味があれば、ここで何か役に立つものが見つかるとうれしいです。\n立ち寄ってくださり、ありがとうございます。\n","permalink":"https://blog.yusukeikoma.com/ja/posts/hello-world/","summary":"ブログへようこそ。CS、機械学習、エンジニアリングについてのメモを共有していく場所です。","title":"Hello World"},{"content":"Yusuke Ikoma 東京大学 工学部 電気電子工学科の 3 年生で、現在は休学中です（2026 年 4 月〜2027 年 3 月予定）。休学期間中はフリーランスの ソフトウェアエンジニア / AI エンジニアとして受託案件に取り組みながら、エンジニアリングのさまざまな分野を探求しています。\n興味のある分野 機械学習 / AI \u0026ndash; 深層学習、モデルの学習と最適化、機械学習の応用 システム / インフラ \u0026ndash; 分散システム、クラウドアーキテクチャ、MLOps ソフトウェアエンジニアリング \u0026ndash; バックエンド、フルスタック開発、システム設計 スキル 言語: Python, TypeScript, Go, Lisp\nML / AI: TensorFlow, PyTorch\nインフラ: Docker, AWS, GCP\nWeb / アプリ: React, Next.js, NestJS, Supabase\n連絡先 GitHub: yusukeikoma X: @IYusuke1205 LinkedIn: Yusuke Ikoma ","permalink":"https://blog.yusukeikoma.com/ja/about/","summary":"自己紹介","title":"About"}]