1つのサービスにキーが欠けていたため、一度も実行されなかった機能
ある機能が、リリースされた日からすべてのリクエストに失敗していました。4つのデプロイターゲットのうちの1つで認証情報が欠落しており、ローカル環境ではそれが隠蔽されていました。
ある機能がリリースされましたが、その日以降、その機能へのリクエストはすべて503エラーで失敗しました。4日間、誰も気づきませんでした。原因はその機能のバグではありませんでした。同じコンテナイメージを共有する4つのデプロイターゲットのうち1つでAPIキーが欠落しており、ローカル開発環境では常にそのキーが提供されていたため、いくらローカルでテストしてもそれを検知することはできませんでした。これがその障害の様子、レスポンスタイムだけでレイヤーを特定できた理由、そしてそれを未然に防ぐためのチェックリストです。
1.4ミリ秒の障害が示すもの
壊れたエンドポイントのアクセスログは次のようになっていました。
14:20:07 503 /api/v1/extract/preview 0.001846s
14:20:06 503 /api/v1/extract/preview 0.001463s
14:20:06 503 /api/v1/extract/preview 0.001414s
1日に19件のリクエスト、すべて503エラー、すべて2ミリ秒未満。この数字が診断のすべてです。
このエンドポイントは、アップロードされた2つのファイルを解析し、言語モデルで2回実行し、成功時には30秒から60秒かかります。1.4ミリ秒で発生した失敗は、そのいずれも試みていません。リクエストボディを読み取っていません。入り口で拒否されたのです。
レスポンスタイムは、コードを一行も読む前に、失敗をレイヤーに分類します。
| 失敗のレイテンシ | 意味 |
|---|---|
| 数ミリ秒未満 | 何らかの作業の前に、事前条件チェックによって拒否された |
| 通常のレイテンシとほぼ同じ | 実際の作業の内部で、そのほとんどを実行した後に失敗した |
| プラットフォームのタイムアウト時 | 応答のない依存関係でハングした |
| 大幅に変動 | リソースの競合または不健全なインスタンスプール |
成功時と同じくらい時間がかかる503エラーは、処理の途中で依存関係がダウンしたことを意味します。即座に返される503エラーは、ガード節によるものです。これらは異なる修正を必要とする異なるバグであり、タイムスタンプの列を見れば、エディタを開く前にどちらのバグであるかがわかります。
このケースでは、ガード節はハンドラの先頭にあるケイパビリティチェックでした。
if s.embedder == nil || s.primaryLLM == nil || s.secondaryLLM == nil {
writeError(w, http.StatusServiceUnavailable, "models are not available")
return
}
その3つのうちの1つは、その機能が公開された日から、すべてのインスタンス、すべてのリクエストにおいてnilでした。
ローカル開発で認証情報の欠落が隠される理由
nil になっていたのは2番目の言語モデルクライアントで、これはAPIキーが存在する場合にのみ構築されます:
if cfg.OpenAIKey != "" {
s.secondaryLLM = newClient(cfg.OpenAIKey)
} else {
log.Warn("secondary model disabled: API key not set")
}
その警告は4日間、インスタンスが起動するたびに表示されていました。サービスは正常に動作しているように見えたため、誰もその起動ログを読んでいませんでした。なぜなら、実際に正常に動作していたからです。アプリケーションの他のすべてのページは機能していました。
そのサービスは、同じコンテナイメージからビルドされた4つのデプロイターゲット(公開API、ステージングコピー、夜間バッチジョブ、内部レビューツール)のうちの1つです。そのうち3つはキーを持っていましたが、内部レビューツールは持っていませんでした。
| デプロイターゲット | シークレットの有無 |
|---|---|
| 公開API | あり |
| ステージング | あり |
| 夜間バッチジョブ | あり |
| 内部レビューツール | なし |
1つのターゲットだけが異なっていた理由は、一般化できるため、言及する価値があります。その内部ツールは、これまでデータベース接続以外は何も必要としませんでした。それは編集ボタン付きのテーブルビューアでした。そのデプロイ設定にはデータベースURLとキャッシュパスワードが記載されており、そのリストは数ヶ月間正しいものでした。その後、言語モデルを呼び出す機能がそのツールに導入され、設定リストの根底にあった前提が静かに成り立たなくなりました。コードは変更されましたが、デプロイ仕様は変更されませんでした。
ローカル開発では、この問題は見えませんでした。開発者のマシンでは、キーは .env ファイルか共有設定ローダーから取得されるため、クライアントは常に構築され、ガード節が発動することはありません。その機能のローカルでの実行はすべて成功しました。ローカルサーバーに対する統合テストも成功しました。この障害は、コードが新たに必要とするものと、あるデプロイメントが実際に提供するものとの間のギャップにのみ存在し、ラップトップ上で実行されるものではそのギャップを見ることはできません。
これが、認証情報のドリフトが通常のバグよりも厄介な特性です。ほとんどのバグは、確認する環境の少なくとも1つで失敗します。この問題は本番環境以外ではすべて成功し、本番環境では誰かが特定の画面を開かない限り、サイレントに失敗します。
自身のメッセージを失ったエラー
サーバーは不可解な応答をしたわけではありません。具体的な一文で応答しました:
{"detail": "models are not available"}
ブラウザにはRequest failed (503)と表示されました。
失敗したレスポンスをメッセージに変換するクライアントヘルパーは3つのキーを読み取りましたが、サーバーが実際に使用したキーはその中にありませんでした:
async errText(r) {
const d = await r.json();
return d.error || d.message || `Request failed (${r.status})`;
}
APIはdetailに標準化されました。フロントエンドのヘルパーは、異なる規約に基づいて書かれ、その後見直されることはありませんでした。すべてのルートからのすべてのエラーボディがフェッチされ、解析され、そして破棄され、ステータスコードだけが残されていました。修正箇所は1つの識別子です:
return d.detail || d.error || d.message || `Request failed (${r.status})`;
この種の不一致は、ハッピーパスや、ステータスコードをアサートするテストでは見えません。誰かがスクリーンショットからデバッグしようとしているときにのみ現れますが、それこそがまさに、その情報が必要なときなのです。エラーパスでのコントラクトの不一致は、何日もの時間を奪われるまでは、何のコストもかかりません。
2つの小さな変更により、残りの診断はセルフサービスになりました。ガード節は、3つの機能を1つの文にまとめるのではなく、どの機能が欠けているかを名指しするようになりました。
func (s *Server) missingCapabilities() []string {
var miss []string
if s.embedder == nil { miss = append(miss, "embeddings") }
if s.primaryLLM == nil { miss = append(miss, "primary model") }
if s.secondaryLLM == nil { miss = append(miss, "secondary model (API key)") }
return miss
}
そして、その拒否は同じリストとともにログに記録されるため、答えは、4日前にスクロールして流れてしまった起動時の警告だけでなく、レスポンスボディとログ行にも含まれています。
外部依存関係を追加するためのチェックリスト
修復自体はコマンド1つでした。不足していたシークレットの注入は数秒で完了し、エンドポイントは最初の試行で 1.4ms の 503 から 30.5 秒の 200 になりました。興味深いのは、4日間を不要にしたであろう5つのチェックです。
- このイメージを実行するすべてのデプロイターゲットをリストアップする。 テストしているものではありません。サービス、ステージングコピー、バッチジョブ、内部ツールなどです。同じバイナリは、同じ新しい要件を意味します。
- ライブサービスだけでなく、作成スクリプトを更新する。 実行中のサービスへの一度限りの注入は、次にスクリプトからそれを再作成する人には見えません。もし再デプロイがイメージの交換のみを行う場合、その注入は生き残り、それこそが、誰もスクリプトが今や間違っていることに気づかない理由です。
- 起動時の警告で盛大に失敗させるか、ヘルスチェックに知らせる。 それ以外は正常に起動するサービスでの
log.Warnはシグナルになりません。宣言された機能が動作しない場合は起動を拒否するか、あるいは誰かが見る場所に機能の状態を公開します。 - 不足しているものの名前を返す。 複数の機能を1つのメッセージの背後にまとめると、そのうちの1つが存在しない場合に診断全体が犠牲になります。
- ローカルテストではステップ1と2を検証できないと想定する。 これが居心地の悪い点です。あなたのマシンにはクレデンシャルがあります。そこで実行できるどのテストも、デプロイメントがそれを持っていることを証明しません。
ステップ1と2は、この種の障害を実際に防ぐものであり、そしてそれらは、どのテストスイートもあなたのために行ってくれない2つです。それらはツールではなく、習慣です。
よくある質問
認証情報が欠落している場合、サービスは起動を拒否すべきですか?
それは、その依存関係がコアかオプショナルかによります。それがないとサービス全体が役に立たない場合は、起動時にフェイルファストし、デプロイが目に見える形で失敗してロールバックするようにします。多くの機能の中の1つの機能を動かすだけなら、起動するのは正しいですが、その場合、機能の状態が起動ログ以外のどこかで見えるようにする必要があります。健全に見える劣化したサービスは、最悪の状況です。
デプロイ後のスモークテストで検出できたでしょうか?
スモークテストがその特定の機能を実行した場合にのみ検出できたでしょう。30秒かかる言語モデルの呼び出しのような場合、これは毎回のデプロイで実行するにはコストがかかりすぎます。より安価な方法は、どのオプショナルな機能が有効かを報告するreadinessエンドポイントを用意し、デプロイステップでそのレポートをリリースが期待するものと比較することです。テストしているのは動作ではなく設定なので、高速に実行できます。
これは単にInfrastructure as Codeの問題でしょうか?
宣言的なインフラストラクチャは役立ちます。なぜなら、デプロイ仕様がそれを必要とするコードの隣に存在し、レビュー担当者が同じ変更で両方を確認できるからです。しかし、それで問題がなくなるわけではありません。誰かが依然として新しいシークレットを正しいターゲットに追加する必要があり、間違った内容の宣言ファイルは、間違った内容を確実にデプロイしてしまいます。それによって得られる利点は、間違いがコンソールの中にだけ存在するのではなく、差分 (diff) として見えるようになることです。
このようなドリフトの既存のインスタンスをどのように見つけますか?
同じイメージを共有するデプロイターゲットを列挙し、それらの環境変数とシークレットのリストを互いに比較 (diff) します。違いが自動的に間違いであるとは限りません。なぜなら、バッチジョブは公開APIよりも少ないものを正当に必要とするからです。しかし、すべての違いには、誰かが述べられる理由があるべきです。このインシデントにおける違いには理由がありませんでした。それは単に、それを必要とする機能よりも古かっただけです。