14.3.2. ウォッチドッグ

ハードウェアウォッチドッグは、他のあらゆるハードニングの選択が乗る土台です。これは、長時間にわたって所定の指示を受けなかった場合にプロセッサをリセットする、小さな独立したタイマーです。不安定なセンサーで動かなくなったスクリプト、タイムアウトを超えてブロックするネットワーク呼び出し、ヒープの片隅で詰まったメモリアロケータ、ループから抜け出した例外。これらのいずれもウォッチドッグを止めません。タイマーはおかまいなくカウントダウンを続け、cam は再起動します。

出荷する製品にとって、ウォッチドッグは任意ではありません。それがなければ、上記の障害モードのいずれかが発生した時点で、誰かが気づいて電源を入れ直すまで cam は死んだままになります。ウォッチドッグがあれば、cam は自力で復帰し、障害の唯一の痕跡はログの 1 行だけになります。

参考

ハードウェアの章の ウォッチドッグタイマー のページでは、ハードウェアレベルでウォッチドッグとは何かと、machine.WDT API の基本を扱っています。このページでは、量産デプロイで何が変わるかを扱います。

14.3.2.1. ウォッチドッグの起動

machine.WDT がその API です。これはハードウェアに支えられており、いったん構築されると、次のリセットまでタイマーが動き続けます。stop()deinit() も Ctrl-C による脱出もありません。それこそが狙いです。

main.py の先頭、保護対象のループの直前に置く典型的なセットアップは次のとおりです:

from machine import WDT

wdt = WDT(timeout=10_000)              # milliseconds

main.py がウォッチドッグの正しい置き場所であるのは、そこにループがあるからです。ウォッチドッグリセットはハードウェアリセットなので、コールドブートの経路が再実行され、main.py は自力でループに再突入します。boot.py に何の仕掛けがなくても復旧が機能します。代わりに boot.py でウォッチドッグを起動すると、ソフトリセットのたびに(たとえば開発者の Ctrl-D)、止める術のないハードウェアタイマーをアプリケーションに渡すことになります。これは作業台では煩わしく、ループの準備が整う前に実行される量産セットアップコードでは罠になります。

タイムアウトは、メインループの観測された最悪のイテレーション時間の 2 倍から 3 倍長く設定します。フレームレートのジッタ、コールドなセンサーでの遅いセンサー読み取り、短い Wi-Fi の途切れ。これらのいずれもウォッチドッグを作動させてはいけません。本物のハング(無限ループ、ブロックされた I/O 呼び出し)は作動させるべきです。短すぎるタイムアウトはウォッチドッグを誤リセットの原因に変え、長すぎるタイムアウトは復旧が始まるまで cam を数分間応答不能のまま放置します。

14.3.2.2. ウォッチドッグへの給餌

wdt.feed() はカウントダウンをリセットします。メインループのイテレーションごとに 1 回、ループ本体の 先頭 で呼び出して、ハングする可能性のある作業の前に無条件に給餌が行われるようにします:

while True:
    wdt.feed()
    frame = csi0.snapshot()
    process(frame)

14.3.2.3. 例外への耐性

ウォッチドッグはハングを扱います。例外はそれとは別の障害モードです。処理されなかった例外はスクリプトのトップレベルまで上がり、main.py は終了し、cam は REPL に落ちます。REPL からは誰も給餌しないため、ウォッチドッグはタイムアウト後に作動し、cam はリセットされ、main.py が再び実行されます。つまり復旧は確かに機能しますが、フィールドではクラッシュのたびにタイムアウトと再起動の代償を丸ごと支払うことになり、トレースバックは誰も読まない USB の標準出力に送られ、アプリケーションが保持していたメモリ上の状態はすべて失われます。

メインループをトップレベルの try / except で囲むと、クラッシュは、リセットの代償を払うことなくアプリケーションが処理を続けられる、ログに記録されるイベントへと変わります:

import logging

log = logging.getLogger(__name__)

while True:
    wdt.feed()
    try:
        frame = csi0.snapshot()
        process(frame)
    except Exception:
        log.exception("frame loop iteration failed")

BaseException ではなく Exception を捕捉することで、KeyboardInterruptSystemExit が機能し続けます。これは USB 経由で接続した開発者が望むものです。

このパターンは生存性のソフトウェア側の半分です。ウォッチドッグがハングを捕らえ、ラッパーがクラッシュを捕らえ、ログがそのどちらが捕らえたかを記録します。

14.3.2.4. なぜ起動が起きたかを知る

あらゆるソフトリセットとあらゆるウォッチドッグリセットは、最終的に新しい起動として現れます。起動時診断ヘルパーは、コールドスタートのたびに machine.reset_cause() をログに記録します。reset cause の行は、復旧が実際に作動したのか、それとも cam が単に通常どおり電源を入れ直しただけなのかをフィールドに伝えるものです。

reset-cause の行は、ウォッチドッグの働きをログ上で可視化するものです。watchdog timeout リセットで埋め尽くされたログは、アプリケーションがハングし続けており、ウォッチドッグがそれを復旧し続けていることを示します。それらがないログは、ウォッチドッグが作動する必要がなかったことを示します。これは通常、アプリケーションが健全であることを意味しますが、実際に起きているハングを捕らえるにはタイムアウトが長すぎる設定になっていることを意味する場合もあります。

14.3.2.5. 完成したスターター

ウォッチドッグ、ロギングのセットアップ、起動時診断、そしてラッパーを一つにまとめた main.py は次のようになります:

import logging
from machine import WDT

from app.logging_setup import setup_logging, log_boot_diagnostics

setup_logging('/sdcard/logs/app.log')
log_boot_diagnostics()

log = logging.getLogger(__name__)

wdt = WDT(timeout=10_000)

while True:
    wdt.feed()
    try:
        step()
    except Exception:
        log.exception("loop iteration failed")

step() はアプリケーションのイテレーションごとの作業です。この足場の残りの部分は製品間で変わりません。ハードニングとは、一つのウォッチドッグ、一つのラッパー、そしてコールドスタートのたびにログに記録される起動です。たいしたコード量ではありませんが、自力で復旧する cam とサービスコールを必要とする cam との違いを生みます。