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つの問いに順に答える必要があります。

  1. 古いプロセスが待ち受けをやめ、新しいプロセスがまだ起動していない間に、到着する接続はどこへ行くのか。
  2. 古いプロセスが処理中のリクエストはどうなるのか。
  3. 新しいバージョンが動いていて、健全だと、どうやって知るのか。
  4. そうでなければ、古いものをどうやって戻すのか。

状態の引き継ぎ(新バージョンが古いバージョンから知るべきこと)は、サービスによっては5つ目の問いです。

使ったサーバーは小さなものです。接続を受け、1行読み、設定した時間だけ「仕事」をして、バージョンとpidを返します。SIGTERM を受けると受け付けをやめ、ドレインして、終了します。自分でソケットをbind(必要なら SO_REUSEPORT つき)することも、systemdからもらうこともでき、準備完了を伝えたり停止時間の延長を頼んだりする程度の sd_notify も話せます。

#!/usr/bin/env python3
"""A small line-based server that can be replaced while it serves.
Env: VERSION (label in replies), WORK (seconds of "work" 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."""
import os, signal, socket, sys, threading, time

VERSION = os.environ.get("VERSION", "v1")
WORK = float(os.environ.get("WORK", "0"))
PORT = int(os.environ.get("PORT", "9100"))

def notify(msg):                                   # minimal sd_notify(3)
    path = os.environ.get("NOTIFY_SOCKET")
    if not path: return
    s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
    s.connect("\0" + path[1:] if path[0] == "@" else path); s.send(msg.encode()); s.close()

def listener():
    if os.environ.get("LISTEN_PID") == str(os.getpid()) and int(os.environ.get("LISTEN_FDS", "0")) >= 1:
        return socket.socket(fileno=3), "inherited from the service manager"
    s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    if os.environ.get("REUSEPORT"): s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
    s.bind(("127.0.0.1", PORT)); s.listen(128)
    return s, "bound by myself"

lock = threading.Lock(); inflight = 0; stopping = False

def handle(c):
    global inflight
    try:
        c.recv(100); time.sleep(WORK); c.sendall(f"{VERSION} {os.getpid()}\n".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"{VERSION} pid={os.getpid()} listening ({how})", flush=True)
notify("READY=1")
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"{VERSION} pid={os.getpid()} SIGTERM: draining {inflight} request(s)", flush=True)
notify("STOPPING=1")
while inflight:
    if os.environ.get("EXTEND"): notify("EXTEND_TIMEOUT_USEC=2000000")
    time.sleep(0.2)
print(f"{VERSION} pid={os.getpid()} drained, exiting", flush=True)

負荷生成器は、4スレッドで1秒に約100回、実行時間の間ずっと新しい接続を開き、各接続が何を受け取ったかを数えます。

#!/usr/bin/env python3
"""Open a new connection ~100x/s from 4 threads for DURATION seconds; count what happened."""
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(("127.0.0.1", PORT)); c.sendall(b"GET\n")
        data = c.recv(100)
        out = data.decode().split()[0] if data else "EOF-without-reply"
    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 ("v1", "v2", "v3"): first_err.append(round(time.monotonic() - t0, 2))
def loop():
    while time.monotonic() - t0 < 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("  results:", dict(res), "| errors between", (min(first_err), max(first_err)) if first_err else "-", "s | slowest request", round(slowest[0]*1000), "ms")

ステップ1: 停止の意味と、ドレイン

systemdがサービスを止めるときは、メインプロセスに SIGTERM を送って終了を待ち、TimeoutStopSec までに終了しなければ、「SIGKILL で強制終了される」とあります(systemd.service(5))。処理中の仕事を終わらせたいサーバーは、(a)受け付けをやめ、(b)持っている仕事を終わらせ、(c)その時間内に収めるか、延長を頼む必要があります。

上のサーバーは(a)と(b)をやります。待ち受けを閉じ、処理中のハンドラーを待ちます。(c)については、マニュアルによれば、Type=notify のサービスが EXTEND_TIMEOUT_USEC=... を送ると、停止時間を TimeoutStopSec より延ばせます。最初のメッセージはタイムアウトを超える前に届く必要があり、指定された間隔のうちに繰り返すか、サービス自身が終了する必要があります(systemd.service(5)、sd_notify(3))。このサーバーは EXTEND=1 のとき、ドレイン中に0.2秒ごとに送ります。

3秒のリクエストを1件処理中にして、1秒後にサービスを再起動します。

#!/bin/bash
# usage: drain.sh <label> <TimeoutStopSec> <EXTEND 0|1>   : one 3-second request is in flight when the service is restarted
echo "=== $1  (TimeoutStopSec=$2, EXTEND_TIMEOUT_USEC sent: $( [ "$3" = 1 ] && echo yes || echo no ))"
systemctl stop web.socket web.service web-b.service 2>/dev/null
cat > /run/systemd/system/web.service <<EOT
[Unit]
Description=swapdemo drain
[Service]
Type=notify
Environment=VERSION=v1 WORK=3 $( [ "$3" = 1 ] && 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 - <<'PY' &
import socket, time
t = time.monotonic(); c = socket.socket(); c.connect(("127.0.0.1", 9100)); c.sendall(b"GET\n")
try: d = c.recv(100); print(f"  client: {'reply ' + d.decode().strip() if d else 'connection closed with no reply'} after {time.monotonic()-t:.1f}s")
except Exception as e: print(f"  client: {type(e).__name__} after {time.monotonic()-t:.1f}s")
PY
P=$!
sleep 1; t=$(date +%s.%N); systemctl restart web.service; echo "  restart returned after $(python3 -c "import time;print(f'{time.time()-$t:.1f}')")s"
wait $P
journalctl -u web --no-pager -o cat --since "-8s" | grep -E 'SIGTERM|drained|killed|timed out|Failed|SIGKILL|signal' | sed 's/^/  journal: /'
=== 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 は、通常の停止やハングしたプロセスの停止まで遅くしますが、延長なら本当にドレイン中の間だけ待つ代償で済みます。

ステップ2: 旧と新の間のすき間

ドレインが守るのは、すでに到着したリクエストです。古いプロセスが待ち受けを閉じてから、新しいプロセスが自分の待ち受けを開くまでの間に到着する接続には、何もしません。これを埋める3つの方法を、ハンマー5回ずつ、2秒の時点で入れ替えて測りました。

#!/bin/bash
# plain service, bind itself
cat > /run/systemd/system/web.service <<EOT
[Unit]
Description=swapdemo app
[Service]
Type=notify
Environment=VERSION=v1 REUSEPORT=1
ExecStart=/usr/bin/python3 /run/swapdemo/srv.py
EOT
cat > /run/systemd/system/web-b.service <<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 > /run/systemd/system/web.socket <<EOT
[Socket]
ListenStream=127.0.0.1:9100
EOT
systemctl daemon-reload
#!/bin/bash
# usage: run.sh <label> '<start command>' '<what to do at t=2s>'
echo "=== $1"
systemctl stop web.socket web.service web-b.service 2>/dev/null; sleep 0.5
eval "$2"; sleep 1
python3 /run/swapdemo/hammer.py 5 &
H=$!
sleep 2
eval "$3"
wait $H
echo "  journal:"; journalctl -u web -u web-b --no-pager -o cat --since "-12s" 2>/dev/null | grep -v -E 'Started|Stopped|Starting|Stopping|Deactivated|Consumed|Succeeded|Finished' | sed 's/^/    /'
#!/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 "$0")
for i in $(seq 1 "${2:-5}"); do
  case $1 in
    plain)   $D/run.sh "plain restart, run $i"          'systemctl start web.service'  'systemctl restart web.service' ;;
    overlap) $D/run.sh "start v2 beside v1, run $i"     'systemctl start web.service'  'systemctl start web-b.service; sleep 0.3; systemctl stop web.service' ;;
    socket)  $D/run.sh "socket activation, run $i"      'systemctl start web.socket'   'systemctl restart web.service' ;;
  esac
done

ハンマーは実行ごとに結果別の件数を表示します(毎回約650件)。各方式の5回分を、出力順にまとめます。

方式接続拒否(各回)リセット(各回)その他最も遅いリクエスト
自分でbindするサーバーの systemctl restart5, 10, 34, 30, 41, 1, 1, 2, 3返信なしで閉じられた接続が1件(1回目)1〜16ms
v1の隣にv2を起動(SO_REUSEPORT)し、0.3秒後にv1を停止0, 0, 0, 0, 01, 0, 1, 0, 0なし1〜10ms
ソケットアクティベーション、サービスを systemctl restart0, 0, 0, 0, 00, 0, 0, 0, 0なし248〜262ms

単純な再起動の実行では、エラーはすべて再起動の開始から約4分の1秒以内に出ました(ハンマーは2.0〜2.24秒の間と報告し、再起動は2秒の時点です)。ソケットアクティベーションは、5回のどれでもエラーがありませんでした。

後日、同じスクリプトで方式ごとに5回ずつをもう1セット動かした結果です。単純な再起動は、接続拒否が33、4、37、9、9件で、リセットは2、3、2、2、2件でした。並走は拒否ゼロで、リセットは5回中3回(0、2、1、0、1件)。ソケットアクティベーションはエラーなしで、最も遅いリクエストは267、248、255、285、58msでした。順序は同じでした。件数と、並走のどの回でリセットが出るかは、同じではありませんでした。

それぞれについてです。

  • 単純な再起動。 古いプロセスがソケットを閉じてから新しいプロセスが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のグループを作る前に有効にしておく必要があります。

もっともらしい話のままにしたくなかったので、確かめました。systemdなしで、同じサーバーとハンマーを、新しいネットワーク名前空間の中で動かしています(この設定は名前空間ごとなので、ホストには触れません)。スクリプトは、設定ごとにv1をv2へ8回入れ替えます。タイミングは上と同じです。

#!/bin/bash
# usage: migrate.sh <0|1>      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 "$(dirname "$0")" && pwd)
ip link set lo up
sysctl -qw net.ipv4.tcp_migrate_req=$1
echo "tcp_migrate_req=$(cat /proc/sys/net/ipv4/tcp_migrate_req)"
for i in 1 2 3 4 5 6 7 8; do
  VERSION=v1 REUSEPORT=1 python3 $HERE/srv.py >/dev/null & P1=$!
  sleep 0.7
  python3 $HERE/hammer.py 4 & H=$!
  sleep 2
  VERSION=v2 REUSEPORT=1 python3 $HERE/srv.py >/dev/null & P2=$!
  sleep 0.3
  kill -TERM $P1                       # v1 closes its listener; v2 keeps listening
  wait $H
  kill -TERM $P2; wait $P1 $P2 2>/dev/null
done

ハンマーがリセットを1回以上見た回を数えた結果です。

tcp_migrate_reqリセットが出た回リセットの合計
0(既定)8回中5回7
18回中0回0

同じ種類の名前空間で、より長く動かした最初のパスでも、0 では16回中11回、1 では16回中0回でした。つまりこのカーネル(Linux 6.12)では、並走の実行で出たリセットは受け付けキューで中断された接続で、このsysctlでそれは消えます。

注意が2つあります。同じカーネルのページに、設定の違う待ち受けの間で移すとアプリケーションが壊れうる、という警告があります。読まずに多数のマシンで有効にしないでください。またこれはカーネル全体(名前空間ごと)の設定で、デプロイのスクリプトが持つものではないのが普通です。もう1つの逃げ道は、この記事の最後の節にあるソケットアクティベーションです。待ち受けが一度も閉じないからです。

ステップ3: リリースを切り替え、確認し、巻き戻す

新しいバージョンへの再起動は、危険な半分です。僕が使う型は、わざと退屈にしてあります。

  1. リリースは別々のディレクトリに置き、current というシンボリックリンクがその1つを指す。
  2. 切り替えはアトミックにする。一時的な名前で新しいシンボリックリンクを作り、古いものの上に名前を変更して重ねる(mv -T)。
  3. 再起動し、サービスにどのリリースかを尋ねる。期限つきで。「答える」だけでは足りません。
  4. 確認に失敗したら、シンボリックリンクを戻して、もう一度再起動する。
#!/bin/bash
# usage: swap.sh <release-name>      Releases live in /run/swapdemo/rel/<name>/srv.py ; /run/swapdemo/current is a symlink.
set -u
new=$1; base=/run/swapdemo; unit=web.service
old=$(basename "$(readlink $base/current)")
echo "swap: $old -> $new"
healthy() {   # healthy = the service answers one request AND says it is the release we expect
  for i in $(seq 1 20); do
    out=$(python3 - <<PY 2>/dev/null
import socket
c = socket.socket(); c.settimeout(1); c.connect(("127.0.0.1", 9100)); c.sendall(b"GET\n"); print(c.recv(100).decode().split()[0])
PY
)
    [ "$out" = "$1" ] && return 0
    sleep 0.25
  done
  return 1
}
ln -sfn "$base/rel/$new" "$base/current.tmp" && mv -T "$base/current.tmp" "$base/current"   # atomic switch
systemctl restart $unit 2>/dev/null
if healthy "$new"; then echo "swap: $new is healthy, keeping it"; exit 0; fi
echo "swap: $new did not become healthy within 5 s, rolling back to $old"
ln -sfn "$base/rel/$old" "$base/current.tmp" && mv -T "$base/current.tmp" "$base/current"
systemctl reset-failed $unit 2>/dev/null; systemctl restart $unit
healthy "$old" && echo "swap: $old is serving again" || echo "swap: ROLLBACK FAILED"
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 "s/^VERSION = .*/VERSION = \"$v\"/" $b/rel/$v/srv.py; done
# v3 crashes at start; v4 starts, but answers with the wrong label
sed -i 's/^srv, how = listener().*/import sys; sys.exit("v3 cannot start: bad config")/' $b/rel/v3/srv.py
sed -i 's/^VERSION = .*/VERSION = "v1-but-actually-broken"/' $b/rel/v4/srv.py
ln -sfn $b/rel/v1 $b/current
cat > /run/systemd/system/web.service <<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>/dev/null; systemctl restart web.service

同じ出発点(v1が稼働中)からの3回の切り替えです。v3は起動時にクラッシュし、v4は起動しますが誤ったラベルで答えます。

swap: v1 -> v2
swap: v2 is healthy, keeping it
swap: v2 -> v3
swap: v3 did not become healthy within 5 s, rolling back to v2
swap: v2 is serving again
swap: v2 -> v4
swap: v4 did not become healthy within 5 s, rolling back to v2
swap: v2 is serving again

確認がバージョンのラベルを比べ、「返信が来た」で止まらないのは、v4のためです。ポートが応答するかだけを見る健全性確認なら、v4を残してしまいます。この実験での「ラベル」は、サービスが自分について正直に報告できるもの(ビルドid、スキーマのバージョンなど)の代わりです。

ステップ4: 状態の引き継ぎ(ルールだけ)

これは実行していません。僕が従うルールは次のとおりです。古いバージョンは、状態をファイルかデータベースにアトミックな置換で書く(一時的な名前に書いてflushし、対象の上に名前を変更して重ねる)。新しいバージョンは起動時にそれを読み、ファイルがない、または読めない場合は、失敗にせず「新規に始める」とみなす。メモリにしかないものは、プロセスが終了すれば消え、上のどの再起動方式もそれを変えません。

どれを使うか

状況選択
短いリクエストで、短時間の接続拒否は許される(再試行するクライアント、社内ツール)単純な再起動に、ドレインと適切な 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が最適です。

  1. sudo mkdir -p /run/swapdemo && sudo cp srv.py hammer.py units.sh run.sh bench.sh drain.sh swap.sh setup_rel.sh /run/swapdemo/ && sudo chmod +x /run/swapdemo/*
  2. sudo /run/swapdemo/units.sh && sudo /run/swapdemo/bench.sh plain 5 を実行します。成功なら、すべての回で ConnectionRefusedError の件数が出ます。
  3. sudo /run/swapdemo/bench.sh overlap 5 と sudo /run/swapdemo/bench.sh socket 5 を実行します。成功なら、拒否はなく、socketでは最遅リクエストが数百msでエラーなしです。
  4. sudo /run/swapdemo/drain.sh "a" 90 0、次に ... "b" 1 0、... "c" 1 1 を実行します。成功なら、応答あり、応答なし、応答ありです。
  5. sudo /run/swapdemo/setup_rel.sh のあと、sudo /run/swapdemo/swap.sh v2、v3、v4 を実行します。
  6. 任意: srv.py と hammer.py を隣に置き、ip netns add mig; sudo ip netns exec mig bash migrate.sh 0、続けて ... migrate.sh 1 を実行すると、リセット数が変わるのを確かめられます。終わったら ip netns del mig です。
  7. 片付けは、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、ソケットアクティベーションのページからの引用は、書く際に元のページで読みました。

確認できていないこと: 負荷がもっと高いときにハンドシェイク途中の接続にも tcp_migrate_req が効くか、カーネルのページにあるBPFによる移送ポリシー、持続的な接続、TLS、実際のクライアント、Accept=yes のソケットアクティベーション、延長メッセージが止まる場合の挙動、状態の引き継ぎ、他のバージョンのsystemd、本番の負荷。件数はハンマーの速度と、このマシンのプロセス起動の速さに依存するので、期待すべき数字ではなく、順序(エラーは単純な再起動 > 並走 > ソケットアクティベーション)の例として読んでください。

接続が待つ場所

「再起動はどれだけ速いか」ではなく、「再起動の間、接続はどこで待つのか」を問います。プロセスより長生きする待ち場所を用意し、処理中のものはドレインし、新しいバージョンには、安心する前に自分の正体を証明させます。