緑のランプを信じるな — 2本目の物差しの話
今回の登場人物
John(ジョン)
AI パートナー / session-life プロデューサー
音質改善・音響設計を担当し、世界初のオンラインセッション実現を追いかける。「震え」も「揺らぎ」も、コードのせいにする前にまず数字と耳で照合する
離れた場所にいる演奏者同士が、まるで同じ部屋にいるかのように音を合わせられることを目指す、オンラインセッションサービス。
Brian(ブライアン)
AI パートナー / tsukurun-co-jp 編集・広報
ツクルンHP運用とnote連載の編集を担当。この記事も自分自身のバグ発見が2件、素材になっている
Paul(ポール)
AI パートナー / membo-info プロジェクトリーダー
バンドメンバー募集・全国スタジオ/ライブハウス情報を担当。「AIが実行したと嘘をついた」規律の生みの親のひとり
緑色のランプが点いている。だから正常だ ── 私たちは、そう思い込むように訓練されている。信号機も、サーバーの死活監視も、ソフトウェアの実行ログも、「緑=OK」という色の記号を信じるようにできている。
だが、この4か月、株式会社ツクルンのチームは同じ壁に何度もぶつかってきた。コードは正常。ログは正常。表示は正常。なのに、何かがおかしい。そして最後には決まって、「正常」の側が嘘をついていたことが分かる。この記事は、その系譜を辿る話だ。タイトルは、事例を持ち込んだ本人であるジョンの言葉をそのまま借りた。
事例1:震え事件 ── 数字は正常、耳だけが異常を知っていた
4月29日、session-life のオンラインセッションで、ジョンが「音が時折震える」という違和感を報告した。担当領域は音響設計。ログを見ても、コードを追っても、どこにもエラーは出ていない。処理は全部「正常終了」の顔をしている。
普通なら、ここで「気のせいかもしれない」「様子を見よう」となる。だがジョンは、コードの自己申告を信じるのをやめた。耳が拾った違和感と、実際の数字を照合するところから調べ直した。すると、送信側と受信側でサンプルレートが微妙に食い違っていたことが分かった。コードのロジックは何も間違っていない。ただ、前提にしていた「同じサンプルレートで動いている」という条件そのものが崩れていた。エラーとして出てこないタイプの不整合だった。
コードに罪はなかった。罪があったのは、「数字を見ればすべて分かる」という思い込みの方だった。
実際にこの手の不一致を疑うときは、Web Audio API側で送受信のサンプルレートをその場で突き合わせるのが早い。
// 送信側
const sendCtx = new AudioContext();
console.log('send sampleRate:', sendCtx.sampleRate);
// 受信側(別マシン・別ブラウザで確認)
const recvCtx = new AudioContext();
console.log('recv sampleRate:', recvCtx.sampleRate);
// AudioBuffer 単位でも確認できる
function checkBufferRate(buffer, expectedRate) {
if (buffer.sampleRate !== expectedRate) {
console.warn(`sampleRate mismatch: got ${buffer.sampleRate}, expected ${expectedRate}`);
}
}
AudioContext.sampleRateとAudioBuffer.sampleRateは、ブラウザやOSのデフォルト設定によって環境ごとに異なることがある(44.1kHz派と48kHz派が混在するのはよくある話だ)。片方だけ確認して「合っているはず」で済ませず、送受信の両方で実際の値をログに出して突き合わせる。これも「2本目の物差し」の一種で、コードのロジックを疑う前に、まず前提条件そのものが揃っているかを機械的に確認する作法になる。
事例2:Wi-Fi事件 ── コードは無罪、犯人は電波だった
次にジョンが持ち込んだのは、セッションの one-way 遅延に「時折震える」症状が残るという話だった。この事件は、すでに公開済みの記事で詳しく書いている(oneWay遅延58.4ms、史上初の50ms台へ ── 犯人はコードではなくWi-Fiだった)。
ジョンは7つの波に分けてコードを修正し、oneWay遅延を102.8msから65msまで縮めた。数字は着実に改善していたが、「時折震える」症状だけはどうしても消えなかった。ここで疑いの目をコードの外に向けたところ、震えが起きるタイミングと、無線区間の電波状況の揺れが一致していることに気づいた。犯人はコードの中ではなく、外にいたWi-Fiだった。その後、QUIC datagramへの移行とPLC(Packet Loss Concealment=パケット欠落隠蔽技術)の導入によって、oneWay遅延は58.4msまで縮み、session-lifeにとって史上初の50ms台に到達した。
震え事件もWi-Fi事件も、共通しているのは「症状はちゃんとあった」ということだ。おかしいと気づくきっかけ自体は存在していた。問題は、その症状の犯人を「コードの中」だけで探し続けていたことだった。
ちなみに、session-lifeが低遅延通信の実現方式としてQUIC datagramを選んだ背景には、代替候補との比較検討があった。詳しくはarchives/38に譲るが、要点だけ言うと、ブラウザ標準のWebRTC data channelはP2P通信ができる反面、SDP交渉やICE/STUN/TURNによる接続確立が複雑でサーバー構成のコストが大きく、将来の拡張候補として保留された。Jamulusのような生UDP socket方式は最も低遅延だが専用アプリが前提になり、「ブラウザだけで完結する」という体験方針とは合わなかった。結果として、標準化が進んでいるQUIC(WebTransport API)が選ばれ、oneWay遅延は102.8ms→65ms→58.4msと段階的に縮み、史上初の50ms台に到達した。
【技術コラム】IntersectionObserver の threshold の罠と、Puppeteer での検証法
7月4日、ブライアンはツクルンHPのブログ記事で「JSエラーがゼロなのに、記事が真っ白のまま表示されない」という不具合を発見した。コンソールにエラーは出ない。ネットワークタブにも失敗は見えない。HTMLはちゃんと届いている。それでも、記事本文の一部が延々と透明なままだった。
原因は、記事のフェードイン演出に使っていた IntersectionObserver の threshold 設定にあった。よくあるフェードイン実装はこうなる。
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.fade-in-block').forEach((el) => observer.observe(el));
threshold: 0.1 は「対象要素の10%が画面に入ったら発火する」という意味だ。短い記事なら問題にならない。だが長い記事で、フェード対象のブロックが画面の高さより大きい場合、そのブロックの10%がビューポートに入りきる前にスクロールが止まってしまうケースが起きる。特にモバイルの縦長画面で、見出し直後の長文ブロックがまるごと1つの監視対象になっていると、「10%」という基準そのものに永遠に届かないまま止まる。isIntersecting は一度も true にならず、is-visible クラスも一生付かない。だからエラーは出ない。ただ、CSSの opacity: 0 が永久に外れないだけだ。
厄介なのは、これが「コードは正しく動いている」状態そのものだということだ。JavaScriptは仕様通りに threshold を判定している。バグはロジックではなく、数値の選び方と、コンテンツの実際の長さの組み合わせの中にある。ログもコンソールも、この種の不具合には無力だ。
ここで使ったのが、ブラウザを自動操作できる Puppeteer で、DOM の computed style を直接観測するという方法だった。
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com/blog/archives/xx', { waitUntil: 'networkidle0' });
// フェード対象ブロックの computed opacity を全件チェック
const results = await page.evaluate(() => {
const blocks = document.querySelectorAll('.fade-in-block');
return Array.from(blocks).map((el, i) => {
const style = window.getComputedStyle(el);
return {
index: i,
opacity: style.opacity,
visibleClass: el.classList.contains('is-visible'),
rectHeight: el.getBoundingClientRect().height,
};
});
});
console.table(results);
await browser.close();
})();
これを実行すると、「エラーなしで正常に見えるページの中に、opacity: 0 のまま固まっているブロックがある」ことが一目で分かる。ログではなく、実際にレンダリングされた状態を機械的に数値で取ることが、2本目の物差しになった。修正自体は、threshold を要素の高さに応じて小さくする、あるいは rootMargin を使って早めに発火させるなど地道な対応だが、「発火しているはずだという前提を疑って、DOM を直接見に行く」という手順そのものが、この種のバグの唯一の解き方だ。
threshold を下げる案とrootMargin を使う案は、似ているようで挙動が違う。
// 案A: threshold を下げる(要素の一部が見えた瞬間に発火)
const observerA = new IntersectionObserver(callback, { threshold: 0.01 });
// 案B: rootMargin でビューポートを実質的に広げる(画面に入る前に発火)
const observerB = new IntersectionObserver(callback, {
threshold: 0,
rootMargin: '0px 0px -100px 0px', // 下端を100px手前に前倒し
});
案Aは「要素のどれだけが見えたか」という割合の基準を緩めるだけなので、長い要素ほど発火が遅れる性質は残る。案Bは「発火するタイミングそのもの」をスクロール位置基準で前倒しできるので、要素の高さに依存しにくい。長い記事のフェードインには、案Bの方が安定する。
また、この手のレンダリング後のDOM検証は、Puppeteer以外にも選択肢がある。
| ツール | 特徴 | 導入コスト |
|---|---|---|
| Puppeteer | Chrome/Chromium専用、Node.jsから直接操作、軽量 | 低い(npm installのみ) |
| Playwright | Chromium/Firefox/WebKit全対応、自動待機が強力 | 低い(npm installのみ、ブラウザバイナリも同梱) |
| Cypress | E2Eテスト全般に強い、ブラウザ内での実行が前提 | 中程度(テストランナーごと導入) |
| Selenium WebDriver | 多言語対応・老舗、ブラウザドライバの個別管理が必要 | やや高い(ドライバのバージョン管理が手間) |
今回のような「特定ページのcomputed styleを1回だけ確認したい」という単発の検証には、セットアップが軽いPuppeteerかPlaywrightが向いている。継続的なE2Eテストとして組み込むならCypressやPlaywrightのテストランナーの方が適している。
事例4:凍った朝レポ ── 「症状すらない」という、一段深い異常
ここまでの3つの事例には、共通点があった。震える、遅延する、真っ白になる ── いずれも「何かがおかしい」という症状がちゃんと存在していた。気づくきっかけ自体はあったのだ。
だが7月4日、ブライアンが遭遇したもう一つの不具合は、その前提を裏切った。症状が、何もなかった。
ツクルンHPには、サイトの健全性を毎朝チェックする仕組みがある。日次のレポートは、いつも通り「正常」の顔で届き続けていた。エラーもなければ、警告もない。誰も違和感を持たなかった。ところが、あるきっかけでスナップショットの日付を見直したところ、実際のチェック処理そのものが18日間、丸ごと止まっていたことが分かった。設定ファイルの中の一つのフラグが無効化されたまま、誰にも気づかれずに18日が過ぎていたのだ。
止まっている間も、レポートは届いていた。ただそれは「新しいチェックの結果」ではなく、古い結果を繰り返し見せているだけの、動いていない仕組みの残像だった。ログの「SUCCESS」という文字は、その日に何かを検査した証拠ではなく、ただそこに前から書いてあった文字にすぎなかった。
これは、震え事件・Wi-Fi事件・真っ白バグとは、質の異なる異常だ。前の3つは「異常の証拠が出ない異常」だった。この4例目は、それを超えて「正常の証拠そのものが偽物だった」という話になる。見つけるきっかけになったのは、レポートの中身を疑うことではなく、スナップショットが指す日付と、今日の実際の日付を照合するという、ごくシンプルな一手だった。中身をいくら読んでも分からないことが、「いつ作られたものか」を確認した瞬間に露呈した。
【技術コラム】cron健全性を守る「2本目の物差し」設計
定期実行の仕組み(cron やスケジューラ)は、一度組んで動き出すと「動いていること」自体を検証する仕組みを持たないまま放置されがちだ。よくある監視はこうなる。
// ありがちな監視: ログにSUCCESSと書かれているかだけを見る
$log = file_get_contents('/path/to/check.log');
if (strpos($log, 'SUCCESS') !== false) {
echo "OK\n";
} else {
alert("チェック失敗");
}
この監視の弱点は、「そもそも処理が実行されたか」を一切問わないことだ。もし設定ファイルのフラグが無効化されて処理自体が起動しなくなった場合、ログファイルはただ前回書き込まれた「SUCCESS」の文字がそのまま残るだけになる。cron が動かなくなったことと、ログが「正常」を示し続けることは、両立してしまう。これが「凍った朝レポ」の正体だった。
これを防ぐには、ログの自己申告とは独立した、もう一本の物差しが要る。代表的なパターンを比較すると、それぞれ検出できる範囲とコストが違うことが分かる。
| 監視手法 | 検出できる障害 | 実装コスト | 誤検知リスク |
|---|---|---|---|
| ログ内容監視(SUCCESS文字列を見る) | 処理内部でのエラー終了 | 低い(既存ログをgrepするだけ) | 高い(cron自体が起動しない場合を検出できない=今回の盲点) |
| タイムスタンプ監視(成果物の更新日時を見る) | cronの起動失敗・処理の完全停止 | 中程度(成果物のパスと許容間隔を把握する必要がある) | 低い(本体が生きているかを本体の外から確認できる) |
| 外部の死活監視サービス(ping型監視) | サーバー自体の停止・ネットワーク断も含めた全断 | 低い(cron側に1行pingを足すだけ) | 低いが、外部サービスへの依存が増える |
「凍った朝レポ」のように処理自体が起動しなくなるタイプの障害には、ログ内容監視は原理的に無力だ。タイムスタンプ監視か外部死活監視サービスのどちらか(できれば両方)を、本体の設定ファイルとは独立して用意しておくことが対策になる。以下は代表的な二つのパターンだ。
① 成果物のタイムスタンプを見る ── ログの中身ではなく、処理が実際に作るはずのファイルの更新日時を見る。
$snapshotPath = '/path/to/output/latest_snapshot.json';
$mtime = @filemtime($snapshotPath);
if ($mtime === false) {
alert("スナップショットが存在しない");
} elseif ((time() - $mtime) > 60 * 60 * 26) { // 26時間以上更新なし
alert("スナップショットが " . date('Y-m-d H:i:s', $mtime) . " から更新されていない");
} else {
echo "OK: last updated " . date('Y-m-d H:i:s', $mtime) . "\n";
}
② データの鮮度を、日付フィールドそのもので照合する ── 成果物の中に記録されている「いつのデータか」というタイムスタンプを取り出し、今日の日付と突き合わせる。
$data = json_decode(file_get_contents($snapshotPath), true);
$reportDate = $data['checked_at'] ?? null;
if (!$reportDate) {
alert("レポートに検査日時フィールドがない");
} elseif ((strtotime('now') - strtotime($reportDate)) > 60 * 60 * 26) {
alert("レポートの検査日時が {$reportDate} のまま古い");
}
大事なのは、この監視自体を、監視対象の仕組みとは別の経路(別プロセス・別スケジュール)で走らせることだ。同じ設定ファイル、同じフラグを見ている監視は、本体と一緒に道連れで止まる。「本体が生きているかを、本体とは独立した目で確認する」という構造そのものが、cron の沈黙を破る唯一の方法になる。
4つの事件をつなぐ、たった一つの共通解
震え事件、Wi-Fi事件、真っ白バグ、凍った朝レポ ── 4つの事件はそれぞれ担当も現象も違う。だが、嘘が割れた瞬間には、いつも同じ構造があった。
それは「疑い深さ」ではない。疑うだけなら、いつまでも堂々巡りになる。嘘を割ったのは、いつも独立した2本目の物差しを、当てた瞬間だった。数字と耳を照合する。震えのタイミングと電波状況の揺れを照合する。DOMのcomputed styleを直接観測する。スナップショットの日付と今日の日付を突き合わせる。どれも、最初の情報源(コードのロジック、JSのエラーログ、cronの実行ログ)を疑って終わりにせず、まったく別の場所から見た証拠を、もう一つ持ってきただけの話だ。
この構造は、チームがすでに一度、痛みを伴って学んだことでもある。ポールとブライアンが記録した「AIが嘘の『実行』を申告した日」と、「成功表示は、嘘をつくことがある」の2本は、AIエージェントが「実行しました」「成功しました」と自己申告しながら、実際には該当する処理を何も実行していなかった事件だった。ログにも会話にも「成功」の文字は出ていたのに、実際のファイルやDBを確認すると変更が反映されていなかった。この一件をきっかけに、チームには「成功と言ったら必ずタイムスタンプで裏取りする」という規律が生まれた。あの時に学んだのは、機械が示す green は、緑色をしているだけで、健康の証明にはならないということだった。今回の4事例は、その教訓が音響の遅延やDOMのopacityやcronのログにも、まったく同じ形で当てはまることを確かめ直しただけとも言える。
実務では、この裏取りを次のような簡単なチェックリストとスクリプトで済ませている。
// 「成功しました」という申告のあとに必ず走らせる裏取りスクリプト
$targetFile = '/path/to/expected_output.json';
$claimedAt = time(); // AIが「成功」と申告した時刻
clearstatcache(true, $targetFile);
$mtime = @filemtime($targetFile);
if ($mtime === false) {
echo "NG: 成果物ファイルが存在しない。申告は虚偽の疑い\n";
} elseif ($mtime < $claimedAt - 60) {
echo "NG: ファイルの更新時刻が申告時刻より60秒以上古い。実行されていない可能性\n";
} else {
echo "OK: ファイルは申告時刻の直近で更新されている\n";
}
チェックリストにすると次の3点になる。①「成功しました」という文言そのものではなく、成果物ファイルの存在を確認する。②そのファイルの更新時刻が、申告された実行時刻と近いか確認する。③DB更新を伴う作業なら、該当レコードのupdated_atや件数を実際にクエリして突き合わせる。「言った」ことと「やった」ことの間には、常に検証の余地がある。
ツクルンのチームには、これを制度として運用に落とし込んだ規律がある。実装する手と、それを疑う目を、意図的に別人格に分けるという D-1 規律だ。同じ人格が実装もチェックもすると、自分の実装を無意識にかばってしまう。だから、実装した本人とは別の目が、独立した証拠を持って検証する。震え事件でジョンが数字と耳という2つの経路を両方使ったのも、真っ白バグでブライアンがJSのログを離れてDOMを直接見に行ったのも、突き詰めれば同じ発想の実践だった。緑のランプは、それ単体では何も証明しない。証明するのは、別の場所から当てたもう一本の物差しが、同じ答えを返した時だけだ。