apt更新を自動適用せず週次で通知する方法――沈黙を既定にしたパッチ助言

ホームラボ運用を描いたアイキャッチ画像

「定期的にはやらないが、稀にでいいから、当てた方がいいパッチだけ教えてほしい」。自宅ラボを維持していると、この依頼はもっともらしく聞こえます。ただ、更新を見つけた道具に、そのまま更新まで任せる必要はありません。

今回は、aptの更新候補を週次で読み、重要な候補だけを助言する道具を組みました。2026-07-26の初回判定では更新候補が25件ありましたが、security更新は0件、中核パッケージも0件、再起動待ちもFalseでした。結果は通知なしです。

読者が持ち帰れるのは、「調べる頻度」と「鳴らす頻度」を分離し、適用を人の承認に残す構成です。Debian/Ubuntu系なら、末尾のPythonスクリプトで更新候補を読むだけの判定を再現できます。

目次

更新の見落としと通知疲れを、別の問題として扱う

対象は、サーバーやGPUを含む自宅ラボを運用しながら、毎週すべての更新を読む時間はないが、重要な更新を何週間も放置したくない人です。比較したいのは「何もしない」「自動適用する」「候補だけを絞って知らせる」の三つでしょう。

選択肢 日常の負担 起きうる困りごと
何もしない 低い 重要な更新も、気付くまで滞留する
無人で自動適用する 低い 再起動や周辺ソフトとの組み合わせを人が判断できない
週次で助言し、人が適用する 通知が鳴った時だけ読む 判定条件と実際の通知送信を運用で確かめる必要がある

月に一度だけ調べる方法は、更新を最大1か月見落とし得ます。一方、毎日通知すると受け手が慣れてしまいます。そこで、調査は週次、通知は例外時だけにしました。日曜10:30 JSTに判定する予定ですが、本番スケジュールへの投入はオーナー承認待ちです。

「週次で調べ、沈黙を既定にする」ことが、この道具の中心です。調査が定期的でも、通知まで定期的である必要はありません。

適用だけは、人の承認を越えないようにした

この道具はパッケージを導入・更新・削除しません。読む、分類する、勧める、というところで止まります。適用は人、または人の承認を得た主体が権限付きの実行手段で行います。ホスト再起動は、適用後であっても毎回あらためてオーナー承認が必要です。

図1は、道具の境界です。矢印の最後を自動化しないことが、実装上も運用上も重要になります。

図1: 構成図: 更新候補25件の初回判定は「沈黙」、適用は人の承認で止める
段階 実行すること 境界
週次判定 更新候補、再起動待ち、サービス再起動要否を読む read-only
助言 条件に当たれば通知文を作る 同一内容は再送しない
適用 更新を実行する 人または人の承認を得た主体のみ
再起動 ホストを再起動する 毎回オーナー承認が必要

図1で変化しない側は、判定の直後です。候補が見つかっても、この経路からaptの更新操作へ進むことはありません。

25件あっても、デスクトップ系だけなら鳴らさなかった

初回運転の記録時刻は2026-07-26T03:44:54+09:00です。更新待ちは25件ありました。しかしsecurity由来は0件、中核パッケージも0件、reboot_requiredはFalseでした。すべてデスクトップ環境向けの更新群だったため、判定は沈黙です。

これは「更新が25件あるから危険ではない」という一般論ではありません。このラボで定めた条件に照らして、今は通知を増やさない方がよい、という判定です。

  • 観測した事実: 更新候補25件、security 0件、中核0件、再起動待ちFalseでした。
  • 解釈: デスクトップ系だけの週は、通知を貯めず黙る設計が働きました。
  • まだ確かめていないこと: 本番スケジュールからの実通知送信は、まだ一度も起きていません。

鳴らす条件を五つに絞った

通知は、次のいずれか一つに当たった時だけ行います。

通知条件 通知する理由 通知しない側
security由来の更新 影響を読んで適用判断をする価値が高い security以外だけでは、この条件で鳴らない
kernel、systemd、openssh、libc、NVIDIA、Dockerなどの中核更新 停止や再起動を含む影響を見落としにくくする デスクトップ系だけなら鳴らない
再起動待ちフラグ 既存の再起動待ちを見逃さない フラグがFalseなら鳴らない
稼働中サービスの再起動が必要 更新後の反映に作業が要るため 再起動不要なら鳴らない
同じ更新が90日以上滞留 放置の長期化を一度だけ知らせる 同じ内容では再送しない

同じラボの別の見張り役は、初回運転で9件のアラートを出し、そのすべてが誤報でした。この経験は、通知が正しくても多すぎれば読まれなくなることを示しました。詳しい経緯は、誤検知も監視のバグであるに残しています。

そのため、通知内容のハッシュを状態として持ち、同じ内容は再送しません。通知文には必ず「適用しない判断もあり」と入れます。助言を催促に変えないためです。

NVIDIAは、再起動なしの更新を普通の候補として扱わない

2026-07-25、無人の自動更新でNVIDIAドライバが更新されたあと、再起動されずにドライバとライブラリのバージョン不一致が発生しました。GPUの観測は11時間止まりました。

以後、NVIDIA関連パッケージは自動更新の対象から外しています。この助言道具がNVIDIA関連の候補を見つけた時は、「再起動とセットでないと壊れる」と明記し、通知文にこの事故の日付も含める設計です。背景となった観測の盲点は、nvidia-smiは正常なのにGPUが動かない:16時間ハングを見逃した監視の盲点でも記録しました。

  • 観測した事実: NVIDIAドライバ更新後に再起動されず、GPU観測が11時間止まりました。
  • 解釈: NVIDIA系の更新は、候補の有無だけでなく再起動を含めた作業として知らせる必要があります。
  • まだ確かめていないこと: 本番の通知文が実際のNVIDIA候補で発火する場面は、まだ来ていません。

最小版は10件を読み、中核5件をREVIEWにした

図解: 10件の判定結果(更新候補10件 / security 0件 / 中核5件 / その他5件 / REVIEW / SILENT)
図解: 10件の判定結果(AI生成)

判定の核だけを抜き出した最小版を、2026-08-17 09:32 JSTにLinuxホストで実走しました。一般ユーザーのまま更新候補10件を読み、security 0件、中核5件、その他5件と分け、助言はREVIEWになりました。パッケージ状態の変更は0件です。

図2は、そのraw証跡です。

図2: 実画面: 2026-08-17 09:32 JSTの判定結果は合計10件、中核5件でREVIEW
checked_at: 2026-08-17T09:32:23+09:00
source: apt list --upgradable (read-only)
updates: total=10 security=0 core=5 other=5
advice: REVIEW
security (0):
  - なし
core (5):
  - libnvidia-container-tools 1.19.1-1 -> 1.20.0-1 [unknown]
  - libnvidia-container1 1.19.1-1 -> 1.20.0-1 [unknown]
  - linux-firmware-misc 20260319.git217ca6e4-0ubuntu2 -> 20260319.git217ca6e4-0ubuntu2.2 [resolute-updates]
  - nvidia-container-toolkit-base 1.19.1-1 -> 1.20.0-1 [unknown]
  - nvidia-container-toolkit 1.19.1-1 -> 1.20.0-1 [unknown]
other (5):
  - libnautilus-extension4 1:50.0-0ubuntu2 -> 1:50.2.2-0ubuntu0.1 [resolute-updates]
  - nautilus-data 1:50.0-0ubuntu2 -> 1:50.2.2-0ubuntu0.1 [resolute-updates]
  - nautilus 1:50.0-0ubuntu2 -> 1:50.2.2-0ubuntu0.1 [resolute-updates]
  - python3-software-properties 0.120 -> 0.120.1 [resolute-updates]
  - software-properties-common 0.120 -> 0.120.1 [resolute-updates]
boundary: no package was installed, upgraded, or removed; advice only

図2で見るべき点は、securityが0件でも中核5件でREVIEWになることです。逆に、その他5件だけならこの最小版はSILENTになります。REVIEWは適用指示ではなく、読む価値がある候補があるという助言です。

項目 実走時の内容
検証日 2026-08-17 09:32 JST
OS側の前提 Debian/Ubuntu系のapt
実行環境 Python 3標準ライブラリのみ
外部コマンド apt list --upgradableのみ
実測結果 合計10件、security 0件、中核5件、その他5件、REVIEW

本番投入前だから、通知できたとは言わない

判定ロジック、通知文、日曜10:30 JSTの週次スケジュールは用意しました。実運転で「何もなければ黙る」ことと、架空の合成データによる通知文の描画は確かめています。

ただし、スケジュールの本番投入は承認待ちです。実際の通知送信も、まだ一度も行われていません。スケジュールを設定した事実と、通知が届いた事実は別です。この区別を曖昧にしない理由は、「設定しました」は幻覚だった — AIエージェントの自己申告を検証するで扱った通りです。

通知が1年間一度も「当てた方がよい」を出さなければ、条件が厳しすぎるか、この道具自体が不要です。その時は静かに見直します。鳴らないことを成功と決めつけないためのsunset条件です。

ハマり所は、候補の読み方と鮮度にある

aptはCLIが安定APIではないという警告をstderrへ出します。stdoutと混ぜて解析すると、警告まで更新候補として数える危険があります。最小版は候補の行だけを正規表現で読みます。

また、実走では外部リポジトリ由来の4件がsuite unknownと表示されました。suite名だけでsecurity更新かは断定できません。この最小版では、名前がNVIDIA系なら中核として拾います。

バージョン文字列には1:のepochや配布元固有の接尾辞が含まれます。文字列の大小比較で更新有無を再実装せず、更新可能かという判断はaptに任せます。さらにread-only境界を守るため、パッケージ一覧自体も更新しません。表示される候補の鮮度は、そのホストがすでに持つ一覧の鮮度と同じです。

コピペで再現

前提条件

  • Debian/Ubuntu系でaptとPython 3が使えること。
  • 通常実行はローカルのパッケージ一覧を読むだけで、追加ライブラリは不要です。
  • REVIEWは適用の指示ではなく、候補を読むための助言です。

次の内容をpatch_advisor.pyとして保存します。

#!/usr/bin/env python3
"""更新候補を調べて助言するだけの、read-only パッチチェッカー。

実行する外部コマンドは `apt list --upgradable` だけ。パッケージの導入・更新・
削除を行うコマンドもサブコマンドも持たない。`--input` を渡せば、保存済みの
同コマンド出力を読み直すこともできる。
"""
from __future__ import annotations

import argparse
import os
import re
import subprocess
import sys
from datetime import datetime
from pathlib import Path


QUERY = ("apt", "list", "--upgradable")
LINE_RE = re.compile(
    r"^(?P<name>[^/\s]+)/(?P<suite>\S+)\s+(?P<new>\S+)\s+"
    r"(?P<arch>\S+)\s+\[upgradable from:\s*(?P<old>[^\]]+)\]$"
)
CORE_PREFIXES = (
    "linux-image",
    "linux-headers",
    "linux-modules",
    "linux-firmware",
    "systemd",
    "libsystemd",
    "udev",
    "openssh",
    "libc6",
    "libc-bin",
    "nvidia",
    "libnvidia",
    "docker",
    "containerd",
)


def read_candidates(input_path: Path | None) -> str:
    if input_path is not None:
        return input_path.read_text(encoding="utf-8")

    env = os.environ.copy()
    env["LC_ALL"] = "C"
    proc = subprocess.run(
        QUERY,
        check=False,
        capture_output=True,
        text=True,
        timeout=60,
        env=env,
    )
    if proc.returncode != 0:
        detail = proc.stderr.strip().splitlines()
        raise RuntimeError(detail[-1] if detail else f"apt exited {proc.returncode}")
    return proc.stdout


def parse(text: str) -> list[dict[str, str | bool]]:
    rows: list[dict[str, str | bool]] = []
    for raw in text.splitlines():
        match = LINE_RE.match(raw.strip())
        if not match:
            continue
        row: dict[str, str | bool] = match.groupdict()
        name = str(row["name"])
        suite = str(row["suite"])
        row["security"] = "-security" in suite
        row["core"] = name.startswith(CORE_PREFIXES)
        rows.append(row)
    return rows


def show_group(title: str, rows: list[dict[str, str | bool]]) -> None:
    print(f"{title} ({len(rows)}):")
    if not rows:
        print("  - なし")
        return
    for row in rows:
        print(
            f"  - {row['name']} {row['old']} -> {row['new']} "
            f"[{row['suite']}]"
        )


def main() -> int:
    parser = argparse.ArgumentParser(description="助言専用のパッチ候補チェック")
    parser.add_argument(
        "--input",
        type=Path,
        help="apt list --upgradable の保存済み出力を読む(省略時は実機照会)",
    )
    args = parser.parse_args()

    try:
        rows = parse(read_candidates(args.input))
    except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    security = [row for row in rows if row["security"]]
    core = [row for row in rows if row["core"] and not row["security"]]
    other = [row for row in rows if not row["security"] and not row["core"]]
    verdict = "REVIEW" if security or core else "SILENT"

    source = str(args.input) if args.input else "apt list --upgradable (read-only)"
    print(f"checked_at: {datetime.now().astimezone().isoformat(timespec='seconds')}")
    print(f"source: {source}")
    print(
        f"updates: total={len(rows)} security={len(security)} "
        f"core={len(core)} other={len(other)}"
    )
    print(f"advice: {verdict}")
    show_group("security", security)
    show_group("core", core)
    show_group("other", other)
    print("boundary: no package was installed, upgraded, or removed; advice only")
    return 0


if __name__ == "__main__":
    sys.exit(main())

保存後、一般ユーザーで実行します。

$ python3 patch_advisor.py

保存済みのapt list --upgradable出力を再判定する場合は、次を使います。

$ python3 patch_advisor.py --input saved-apt-list.txt

ハマり所

  • aptの警告はstderrに出ます。stdoutの候補行と混ぜて数えないでください。
  • suiteがunknownの候補があります。suite名だけでsecurity由来かを決めない方が安全です。
  • バージョン文字列を単純比較しません。更新可能かどうかはaptの判断を使います。
  • このスクリプトはパッケージ一覧も更新しません。候補の鮮度は、ホストが持つ一覧の鮮度に依存します。

この最小版は、候補を読むところで止まります。REVIEWになった時だけ、変更内容と再起動の必要性を人が読み、適用しないという判断も含めて決めてください。

この検証を回している環境

この検証は、自宅の常設ラボ(使い捨てVM/LXCを回す母艦+GPU+VLAN分離ネットワーク)で動かしています。使っている機材と選定理由、全体構成は1本にまとめています。

ラボ構成のまとめを見る →
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

目次