投稿

nginx の自動アップグレードが連動モジュールを壊した — systemctl disable --now の罠

nginx の自動アップグレードが連動モジュールを壊した — systemctl disable --now の罠

まとめ

  • Ubuntu の unattended-upgradenginx のパッチリリースを上げた ことで、apt パッケージで配布されている nginx モジュール(Web アプリのワーカーを管理する連動コンポーネント) との ABI 不整合 を起こし、Web アプリのワーカーが spawn できなくなった
  • ALB ヘルスチェックは失敗を返し、ASG が unhealthy 判定 → 次々と EC2 を replace するループに突入。新インスタンスも古い AMI から起動するため、立ち上がってすぐ同じ更新を踏んで死ぬ
  • 復旧中に systemctl disable --now apt-daily.timer apt-daily-upgrade.timer を叩いたら、 その瞬間に missed trigger が発火して service が走り、また同じ更新が始まった
  • 正解は 「service を先に mask してから、timer は --now を付けずに stopdisable の順。apt-daily.servicestatic なので、disable では止められず mask が必要

何が起きたか

朝、本番運用中の Web アプリで「ページが開けない」報告が立て続けに上がった。ALB のターゲットグループを見ると、複数の EC2 が次々と unhealthy 判定され、Auto Scaling Group が 5-10 分おきに代替インスタンスを起動している。Scaling Activities を時系列に並べると、こうなっていた。

08:37  Launching i-AAA (replace 元の代替として)
08:51  i-AAA unhealthy → Launching i-BBB
09:00  i-CCC unhealthy → Launching i-DDD
09:12  i-DDD unhealthy → Launching i-EEE
09:36  i-BBB unhealthy → Launching i-FFF
09:40  i-EEE unhealthy → Launching i-GGG

立ち上がってすぐ死ぬ、を繰り返している。容量だけ見れば回っているが、すべてのインスタンスが短命で、サービスとして応答できる時間がほぼない。

原因の特定

死んだインスタンスの中身を見るため、ASG から 1 台手動 detach(インスタンス自体は terminate せず生かしたまま外す)して SSH で入った。

$ sudo cat /var/log/apt/history.log | tail -40
Start-Date: <timestamp>
Commandline: /usr/bin/unattended-upgrade
Upgrade: nginx, nginx-common, systemd (複数パッケージ),
         libcups2t64, poppler-utils, rsync, telnet ...
End-Date: <timestamp>

このインスタンスは launch から わずか 5 分後 に unattended-upgrade が走り、nginx を含む十数パッケージを上げていた。

nginx 本体だけが上がって、Web アプリのワーカーを管理する nginx モジュール は据え置きだった、というのが致命的だった。このモジュールは特定リリースの nginx に対してビルドされていて、リクエスト時にアプリのワーカーを spawn する処理が新しい nginx と整合しなくなる。

実際の症状はこうだった。

$ curl -sS -o /dev/null -w "HTTP:%{http_code}\n" -H "Host: example.com" http://localhost/health
HTTP:000
curl: (52) Empty reply from server

nginx 本体は port 80 で listen している。モジュールの監視プロセスも起きている。でも モジュール配下のアプリワーカーが一つも生まれていない 。リクエストを受けても upstream が立たないので Empty reply。

ALB は /health に対して 200 を期待してヘルスチェックを打つ。Empty reply は 200 ではない。unhealthy 判定。ASG が replace 発動。新インスタンスも 5-15 分後に同じ unattended-upgrade を踏む。ループ完成。

暫定対応の方針

根本対応(モジュールを新 nginx と整合する版に上げる、上流リポジトリから入れて apt の依存解決に乗せる、AMI の定期再焼成パイプラインを組む)は時間がかかる。今欲しいのは「 本番容量を維持しながら数十分以内に死なないインスタンスを並べる 」こと。

最短ルートはこうだった。

  1. detach 済み 1 台を使って「 nginx を未更新のまま + 自動更新を完全に塞いだ状態 」を作る
  2. その状態で AMI を焼く(--no-reboot で対策が崩れないように)
  3. Launch Template の ImageId を新 AMI に差し替え
  4. ASG の Instance Refresh で逐次入れ替え

ここで詰まったのが「自動更新を塞ぐ」のステップだった。

systemctl disable –now の罠

別の生きていたインスタンスでは nginx がまだ未更新のままだったので、これを直接いじって AMI 化候補にしようとした。最初に叩いたのはこれ。

$ sudo systemctl disable --now apt-daily.timer apt-daily-upgrade.timer

直後の journal を見て凍りついた。

11:10:55 sudo: ubuntu : COMMAND=/usr/bin/systemctl disable --now apt-daily.timer apt-daily-upgrade.timer
11:10:55 systemd[1]: Starting apt-daily-upgrade.service - Daily apt upgrade and clean activities...
11:10:55 systemd[1]: apt-daily.timer: Deactivated successfully.
11:10:55 systemd[1]: Stopped apt-daily.timer.
11:10:55 systemd[1]: apt-daily-upgrade.timer: Deactivated successfully.
11:10:55 systemd[1]: Stopped apt-daily-upgrade.timer.

Stopped の前に Starting apt-daily-upgrade.service が走っている。つまり、 timer を止めようとした瞬間に、その timer が「最後の一発」を発射して service を起動した 。直後の /var/log/unattended-upgrades/unattended-upgrades.log を見ると、案の定 nginx + systemd 等のパッケージ更新が走っていた。

これは apt-daily-upgrade.timer 側の Persistent=true 設定が効いた挙動だった。Persistent な timer は「自分が止められようとしている時点で、次の発火予定時刻と現在時刻を比較し、もし発火条件を満たしているなら missed run として実行する」という仕様がある。発火予定時刻はランダム化されているので、disable --now を叩く時点で「すでに今がその時刻」と判定されることがある。

このインスタンスは焼成元から外した。

なぜ service の mask が必要か

正しい順序を組み立て直す。

# 1. service を先に mask
sudo systemctl mask apt-daily.service apt-daily-upgrade.service

# 2. timer を stop(--now は使わない)
sudo systemctl stop apt-daily.timer apt-daily-upgrade.timer

# 3. timer を disable
sudo systemctl disable apt-daily.timer apt-daily-upgrade.timer

ここで踏み込んで考えるべきは「 なぜ service を disable じゃなくて mask なのか 」。

apt-daily.serviceapt-daily-upgrade.serviceis-enabled を見ると static と返ってくる。これは unit に [Install] セクションがないという意味で、もともと enable/disable できない。enable できないということは disable も意味を持たない。static な unit は 「他から呼ばれた時に起動する設計」 であって、自動起動の経路を遮断するという発想の外にある。

static のままでは、

  • timer から trigger されれば起動する
  • 他 unit の Wants= / Requires= に書かれていれば起動する
  • 手動 systemctl start でも起動する
  • missed trigger でも起動する

これを 完全に「起動不能」にする のが mask。/etc/systemd/system/<unit>/dev/null への symlink にして、systemd が unit を読み込もうとした時点で Failed to start: Unit is masked で拒否する。

$ ls -la /etc/systemd/system/apt-daily-upgrade.service
lrwxrwxrwx 1 root root 9 Jun 10 11:30 /etc/systemd/system/apt-daily-upgrade.service -> /dev/null

これで「timer を disable した瞬間に missed trigger が走って service が起動しようとする」シナリオでも、systemd が起動を拒否する。 mask は timer disable の副作用を防ぐ最後の砦 だった。

「動いていない」ことの確認は 4 観点必要

復旧後、「本当に止まっているか」を確認したい。systemctl statusinactive を見るだけでは不十分。AMI 化前の検証として、4 つの観点で確かめた。

A. 今、systemd に timer がロードされていない

$ systemctl list-timers "apt-daily*.timer" --all --no-pager
NEXT LEFT LAST PASSED UNIT ACTIVATES

0 timers listed.

--all を付けても 0 件であれば、systemd の認識下に timer が存在しない。

B. 将来も発火しない(masked + disabled)

$ systemctl is-enabled apt-daily.service apt-daily-upgrade.service apt-daily.timer apt-daily-upgrade.timer
masked
masked
disabled
disabled

masked × 2 / disabled × 2 が揃って初めて、「将来も起動経路がない」と言える。

C. 直前の disable 操作で副作用発火が起きていない

disable 実行時刻の前後数分の journal を取り、Starting apt-daily-upgrade.service の行が 無い ことを確認する。

$ sudo journalctl --since "11:30:00" --until "11:33:00" --no-pager \
    | grep -iE "apt-daily|Starting"
11:30:30 sudo: COMMAND=/usr/bin/systemctl stop apt-daily.timer apt-daily-upgrade.timer
11:30:30 systemd[1]: apt-daily.timer: Deactivated successfully.
11:30:30 systemd[1]: Stopped apt-daily.timer.
11:30:30 systemd[1]: apt-daily-upgrade.timer: Deactivated successfully.
11:30:30 systemd[1]: Stopped apt-daily-upgrade.timer.
11:30:34 sudo: COMMAND=/usr/bin/systemctl disable apt-daily.timer apt-daily-upgrade.timer

Starting apt-daily-upgrade.service がない。OK。

D. 別経路(cloud-init、手動、SSM 等)で unattended-upgrade が走っていない

$ sudo tail -5 /var/log/unattended-upgrades/unattended-upgrades.log
$ grep "$(date +%Y-%m-%d)" /var/log/apt/history.log
$ pgrep -af "apt|unattended-upgrade|dpkg"
$ sudo unattended-upgrade --dry-run -v 2>&1 | tail -5
  • unattended-upgrades.log の最終エントリが対策実施より前
  • apt history も同様
  • pgrep で動いているのは unattended-upgrade-shutdown --wait-for-signal(常駐サービス)だけ
  • dry-run の最後が No packages found that can be upgraded unattended で終わる

ここまで揃って、ようやく「動いていない」と言える。

AMI 化後の検証

AMI を焼いて Instance Refresh が回り始めたあと、入れ替わった新インスタンスでも同じ A〜D を再実行する。とくに重要なのは OnBootSec=15min の発火タイミング。起動から 20 分以上経ってから unattended-upgrades.log を確認し、新規エントリが書かれていなければ、その AMI は安全に展開できる。

$ uptime; who -b
 11:50:00 up 22 min, ...
         system boot  2026-06-10 11:28
$ sudo tail -3 /var/log/unattended-upgrades/unattended-upgrades.log
# 起動以降の新規エントリが無いこと

教訓

  • systemctl disable --now は Persistent timer を相手にすると爆発する 。手の早い人ほど踏みがち。対策は service を先に mask → timer を stop(--now なし)→ disable の 3 段
  • static な unit を本気で止めたいなら mask 一択 。disable は経路の一つを塞ぐだけ、static には届かない
  • 「動いていない」の確認は 1 コマンドでは終わらない 。今・将来・直前の副作用・別経路、の 4 観点を分けて見る
  • 起動直後に大量の apt upgrade を踏む構成 — つまり古い AMI を ASG で使い続ける構成 — は、サービスのライブラリ依存が apt パッケージ更新と直交していないと、こうやって一日を吹き飛ばす。AMI 再焼成のパイプラインは「あれば便利」ではなく「無いと事故る」インフラ

トレンドのタグ