代表的な面接トピック

Linux面接:シグナルとグレースフルシャットダウンはどのように機能するか?

一般難しい
Offer.cc 編集チーム公開日 更新日

質問

Linux HTTPサービスがKubernetesコンテナ内で実行されています。ローリングアップデート中に、200件のリクエストが処理中(in-flight)、2つの子ワーカーが実行中、バックグラウンドジョブのコンシューマがアクティブな状態で、30秒の猶予期間を持つSIGTERMを受信しました。そのシャットダウンプロトコルを設計し、どのように検証するかを説明してください。

プロンプトと適用されるコンテキスト

Linux HTTPサービスがKubernetesコンテナ内で実行されています。ローリングアップデート中に、200件のリクエストが処理中(in-flight)、2つの子ワーカーが実行中、バックグラウンドジョブのコンシューマがアクティブな状態で、30秒の猶予期間を持つSIGTERMを受信しました。シグナルの配信からプロセスの終了までのシャットダウンプロトコルを設計してください。SIGTERMSIGKILLの動作、シグナルハンドラの安全性、トラフィックのドレイン、ジョブおよび子プロセスの処理、デッドライン、そして検証方法について説明してください。

30秒と200件のリクエストは面接シナリオの入力値です。Kubernetesは一般的にデフォルトで30秒のPod終了猶予期間を使用しますが、本番環境の値は測定されたリクエスト処理時間、クリーンアップ時間、ワークロードのセマンティクス、および可用性要件に基づいて決定されるべきです。このシナリオでは、内部のドレインデッドラインを25秒とし、最終的なクリーンアップとスケジューリングのばらつきのために5秒を確保します。この時間配分はエンジニアリング上の判断であり、プラットフォームによる保証ではありません。

中核となる問題は、Linuxのプロセスライフサイクルプロトコルです。KubernetesとHTTPは動作コンテキストを提供します。優れた回答は、シグナルがカーネルから対象プロセスに届く経路をたどり、それを安全な状態遷移へと変換し、受付と処理中の作業を制御し、終了がデッドライン内に収まることを証明します。

面接官が評価するポイント

第一に、面接官はシグナルのセマンティクスを確認します。プロセスはSIGTERMを捕捉(catch)、ブロック、または無視できます。そのデフォルト動作は終了です。アプリケーションがこれを捕捉した場合、アプリケーション自体が最終的に終了処理を行わなければなりません。SIGKILLは捕捉、ブロック、無視のいずれもできず、クリーンアップの機会は提供されません。標準シグナルの重複は永続的なキューにはなりません。同一の保留中(pending)の標準シグナルが複数ある場合、それらは1つに合流(coalesce)する可能性があります。

第二に、面接官はシグナルがサービスに到達しているかを確認します。コンテナ内では、シェル形式のエントリポイントを使用すると/bin/sh -cがPID 1のままになり、実行ファイルが期待されるSIGTERMを受信できなくなる可能性があります。サービスは通常exec形式のエントリポイントにするか、ラッパースクリプトをexecで終了させる必要があります。子プロセスを生成するサービスは、終了シグナルを子プロセスに転送してそれらを回収(reap)するか、自身でPID 1の責務を果たせない場合は適切な軽量init(tiny init)を使用して実行する必要があります。

第三に、面接官は非同期シグナル安全(async-signal-safe)の境界を確認します。生のC言語シグナルハンドラは、任意の命令の実行中に通常の処理を中断します。printfの呼び出し、メモリ割り当て、ミューテックスの取得、アプリケーションのオブジェクトグラフのクローズ、またはクライアントライブラリのフラッシュは、デッドロックや状態の破損を引き起こす可能性があります。ハンドラはシグナル安全な操作のみを使用して最小限の通知を発行するにとどめ、通常の制御パスがクリーンアップを担うべきです。

第四に、面接官はシャットダウンの順序を評価します。サービスは準備完了状態を解除(unready)して新しい作業の受け入れを停止し、デッドラインのもとで受付済み作業をドレインまたはキャンセルする必要があります。リースの所有権を失うことなくジョブの取り込みを停止し、共有依存関係はそれを使用する処理が完了するまで開いたままにし、子プロセスを終了して回収し、時間制限付きでテレメトリをフラッシュして、オーケストレータがSIGKILLへとエスカレーションする前に終了しなければなりません。

最後に、面接官は運用上の証拠を求めます。ハンドラの単体テストだけでは、コンテナのPID構成、エンドポイントの削除、接続の挙動、ジョブの再配信、子プロセスの回収、または猶予期間の遵守を証明できません。回答には、観察可能な合格基準を備えたコンテナレベルおよびロールアウトレベルのテストを含める必要があります。

回答前に明確にすべき質問

  • 誰が、どのPIDに対してシグナルを送信するのか? コンテナランタイム、設定された停止シグナル、エントリポイント、ラッパー、およびアプリケーションがPID 1であるかを確認します。
  • 30秒は何に消費されるのか? KubernetesのpreStopフックは同じ終了猶予期間内で実行されます。その実行時間は、アプリケーションのドレインに残された時間を減少させます。
  • 「処理中(in flight)」とは何を意味するのか? 受付済みのリクエスト、リクエストのないキープアライブ接続、ストリーミングレスポンス、アップグレードされた接続、キューイングされたアプリケーション作業を区別します。これらには異なる完了ポリシーが必要です。
  • サービスは新しい作業を即座に拒否できるか? レディネスプローブ、ロードバランサ、リスナー、サービスメッシュ、および直接の呼び出し元を特定します。レディネスの伝播は瞬時には行われません。
  • リクエストの所要時間とリトライの規約はどうなっているか? 短時間の冪等な読み取りは完了できる可能性がありますが、長時間のアップロードや副作用を伴う書き込みはキャンセル、引き継ぎ、または冪等性キーが必要になる場合があります。
  • バックグラウンドジョブの所有権はどのように管理されているか? 応答(acknowledgement)のタイミング、可視性タイムアウトまたはリース、ハートビートの動作、再配信、冪等性を明確にします。この規約がなければ「ワーカーを停止する」ことは安全ではありません。
  • 誰が子ワーカーを所有しているか? 親プロセスがプロセスグループにシグナルを送信できるか、子プロセスが独自のシャットダウンプロトコルを持っているか、そして誰がwaitpidを呼び出すかを判断します。
  • どのランタイムがシグナルを処理するのか? 専用のsigwaitスレッド、イベントループのコールバック、生のCハンドラでは、安全性の境界が異なります。実際のランタイムの動作を明記してください。
  • どのような結果をもって成功と定義するか? 新規受付、完了およびキャンセルされた作業、重複した副作用、ゾンビプロセス、終了時間、強制終了率の上限を設定します。

30秒の回答フレームワーク

「exec形式のエントリポイントによってSIGTERMがアプリケーションに届くことを検証します。生のハンドラは通常の制御パスを起こすだけにします。そのパスはレディネスを失敗させ、リクエストとジョブの受付を停止し、25秒の内部デッドラインまでに受け付けた作業をドレインします。その後、残存する作業を安全にキャンセルし、2つの子プロセスを終了して回収し、制限時間内で最終フラッシュを実行し、30秒の猶予期間が終わる前に終了します。SIGKILLはクリーンアップを実行できません。ビルドされたコンテナを同時リクエストとジョブの負荷下でテストし、受付の遮断、リトライ動作、子プロセスの回収、および終了時間を確認します。」

このフレームワークは制御フローを確立します。詳細な回答では、マルチスレッドでのシグナル配信、中断されたシステムコール、繰り返されるシャットダウン要求、およびエンドポイントの削除とリスナーでの受付の違いについてもカバーする必要があります。

ステップバイステップの詳細解説

配信経路から始めます。アプリケーションがランタイムの停止シグナルを直接受信できるように、ENTRYPOINTまたはCMDの実行可能形式を使用します。セットアップのためにラッパーが必要な場合は、末尾をexec "$@"にします。Dockerfileを盲信するのではなく、実行中のコンテナを検査します。PID 1、そのコマンドライン、親子関係、および設定された停止シグナルを検証します。コンテナにSIGTERMを送信するロールアウトテストが決定的です。

ハンドラをインストールする前にウェイクアップ機構を初期化し、レディネスを公開する前にハンドラをインストールします。C言語スタイルのスケッチでは、非ブロッキングのセルフパイプを使用できます。

c
static volatile sig_atomic_t stop_requested = 0;
static int wake_fd; /* initialized as nonblocking before sigaction */

static void on_term(int signo) {
  int saved_errno = errno;
  stop_requested = 1;
  const unsigned char byte = 1;
  (void)write(wake_fd, &byte, sizeof byte);
  errno = saved_errno;
}

writeは非同期シグナル安全です。非ブロッキングディスクリプタにより、パイプが通知ですでに満杯の場合でもハンドラが待機状態になるのを防ぎます。フラグは、ウェイクアップの書き込みが別のバイトを追加できない場合でも状態を保持します。ハンドラはログ記録、メモリ割り当て、ロック取得、子プロセスの待機、またはアプリケーションクライアントの呼び出しを行いません。通常のイベントループがパイプをドレインし、冪等なシャットダウン状態マシンを進めます。

マルチスレッドサービスの代替手段としては、ワーカースレッドを作成する前に終了シグナルをブロックし、1つの専用スレッドがsigwaitを呼び出すか、Linux上でsignalfdを使用する方法があります。シグナルの配置(disposition)はプロセス全体で共有されますが、各スレッドは独自のシグナルマスクを持ちます。プロセス宛てのシグナルは、それをブロックしていない任意のスレッドに配信される可能性があります。スレッドが開始する前にシグナルマスクが一貫して確立されていれば、集中型の同期シグナル処理によってアプリケーションコードから非同期ハンドラを排除できます。

サービスを起こすために、中断されたシステムコールだけに頼らないでください。インターフェースやSA_RESTARTの設定によって、ブロッキングコールは自動的に再開されるか、EINTRを返します。セルフパイプ、イベントディスクリプタ、ランタイムシグナルチャネル、または専用シグナルスレッドによって、明示的なウェイクアップ経路が作成されます。シャットダウン中のすべてのブロッキング待機にもデッドラインを設ける必要があります。

明示的な状態を通じてサービスを駆動します。

text
RUNNING
  --SIGTERM--> QUIESCING
  --admission closed--> DRAINING
  --work finished or 25 s reached--> FINALIZING
  --children reaped and bounded flush complete--> EXITED

RUNNINGからの遷移はアトミックかつ冪等でなければなりません。最初のSIGTERMで開始時刻とデッドラインを記録します。2回目のSIGTERMを受信しても、別のクリーンアップグラフを開始したり、同じリソースを2回閉じたりしてはなりません。チームは、単に重複を記録するかドレイン時間を短縮するかを選択できますが、その動作は文書化され、テストされる必要があります。

QUIESCINGでは、レディネスを失敗させ、新しいアプリケーションの受付を直ちに停止します。Kubernetesは終了中のエンドポイントを非準備完了(unready)としてマークしますが、コントロールプレーンとプロキシへの伝播には時間がかかります。リスニングソケットを閉じるか、acceptを無効にするか、あるいは受け入れ済み作業の境界を越えていないリクエストに対して受付レイヤーがリトライ可能なレスポンスを返すようにします。既存の受け入れ済み接続はドレインのために開いたままにしておくことができます。シャットダウン開始後にアイドル状態の古い接続から無制限に新しいリクエストが送信されないよう、HTTPキープアライブを明示的に処理します。

同じ境界で、バックグラウンドコンシューマが新しいジョブを取得するのを停止します。すでにリースされているジョブについては、デッドライン前に安全に完了できる場合のみ処理を継続します。それ以外の場合は、他のワーカーがリトライできるように、キューの規約に従ってハートビートを停止するか、リースを解放/nackします。応答(acknowledgement)は永続的な完了の後に行う必要があります。外部への書き込み後かつ応答前に強制終了が発生する可能性があるため、副作用には冪等性キーまたはトランザクション状態遷移が必要です。

DRAININGでは、カウンタまたはレジストリで受け入れ済み作業を追跡します。依存関係が利用可能な状態を維持しながら、リクエストを完了させます。リクエストハンドラが使用している間は、データベースプール、キャッシュクライアント、またはテレメトリエクスポータを閉じてはなりません。25秒の内部デッドラインに達したら、プロトコルに従って残りの作業をキャンセルします。ストリーミングを停止し、キャンセルを伝播させ、可能な場合は定義されたレスポンスを返し、リトライ可能な状態の一貫性を保ちます。残りの5秒は、キャンセルコールバック、子プロセスの回収、最終状態の書き込み、およびランタイムスケジューリングのばらつきのために確保します。

2つの子ワーカーについては、まず入力を停止し、合意された終了シグナルを送信し、デッドラインを設けて待機します。親プロセスが専用のプロセスグループを所有している場合、無関係なプロセスを避けながらそのグループにシグナルを送信できます。ゾンビプロセスが残らないように、終了したすべての子プロセスをwaitpidで回収します。アプリケーション自体がそれを行えないコンテナに対しては、軽量initがシグナルの転送と回収を提供できますが、アプリケーションのジョブやリクエストのセマンティクスまでは定義しません。

FINALIZINGでは、最終的なシャットダウンメトリクスを出力し、厳格な時間予算内でログやトレースをフラッシュします。可観測性は強制終了の原因究明に役立ちますが、テレメトリバックエンドが利用できないからといって猶予期間全体を消費してはなりません。残りのリソースを依存関係の順序で閉じ、グレースフルシャットダウンが完了した場合は終了ステータス0を返します。プロセスがプラットフォームのデッドラインを超過した場合、Kubernetesは最終的にランタイムにSIGKILLの送信を要求します。その時点以降は、いかなるハンドラ、遅延ブロック(deferred block)、シャットダウンフックも実行されません。

シグナルは作業メッセージではなく、制御通知として使用してください。標準シグナルは合流する可能性があり、ペイロードがほとんど含まれず、予期しないコード位置に届くことがあります。作業、リトライ、永続的な所有権はキューやステートストアに配置します。シグナルはローカルなライフサイクル遷移を開始またはエスカレーションするだけです。

検証では、本番環境で使用されるのと同じ境界をテストする必要があります。

  1. 実際のリバースイメージをビルドし、PID 1を検査し、サービスを開始して、内部のシャットダウンエンドポイントを呼び出すのではなく、コンテナにSIGTERMを送信します。
  2. 25秒以内に完了する作業とキャンセルする必要がある作業を含む、所要時間が混在した200件のリクエストを保持します。カットオフ後に新しいリクエストが受け入れられないこと、および受け入れられたすべてのリクエストに最終結果が記録されていることを検証します。
  3. レディネスがfalseになり、エンドポイントの伝播後に古いPodが新しいロールアウトトラフィックを受信しないことを確認します。また、リスナーの受付が個別に検証されるよう、直接接続もテストします。
  4. 終了処理をまたいでリースされたバックグラウンドジョブを実行します。完了した作業が1回だけ応答されること、未完了の作業がリトライ対象になること、および重複配信によってビジネス上の影響が重複しないことを検証します。
  5. 両方の子ワーカーが終了シグナルを受信し、デッドラインまでに終了し、回収されることを確認します。プロセステーブルを調べてゾンビプロセスがないか確認します。
  6. 2回目のSIGTERMを送信し、クリーンアップが冪等のままであることを証明します。別途SIGKILLを送信して、クリーンアップが前提とされておらず、リカバリ規約によって永続的な作業が依然として保護されていることを証明します。
  7. shutdown_started、受付状態、処理中の件数、ドレインデッドラインの超過、子プロセスの状態、終了時間、および強制終了を記録します。グレースフルな終了が30秒に達した場合はテストを失敗とします。

質の高い回答の例

「私はコンテナの境界から着手します。exec形式のエントリポイントを使用し、実行時にイメージを検査して、HTTPサービスがPID 1であるか、シグナルを転送するinitの背後にあることを確認します。シェルラッパーはexecで終了させ、SIGTERMがシェルで止まらないようにします。

レディネスがtrueになる前に、サービスはそのシグナル経路をインストールします。生のCハンドラでは、sig_atomic_tフラグを設定し、非ブロッキングのセルフパイプに書き込むだけにします。ロギング、ミューテックス、メモリ割り当て、データベース呼び出し、子プロセスの待機はそのハンドラから除外します。マルチスレッドの実装では、ワーカーを作成する前に終了シグナルをブロックし、1つのsigwaitスレッドからそれらを消費する方法を選択します。どちらの設計も、EINTRに依存するのではなく、通常の制御ループを明示的に起こします。

最初のSIGTERMは、サービスを実行中から停止中(quiescing)へとアトミックに移行させ、25秒後の内部デッドラインを確定します。サービスは直ちにレディネスを失敗させ、新規受付を閉じるか無効化し、キープアライブ接続がそれ以上リクエストを開始しないようにし、バックグラウンドジョブの取得を停止します。エンドポイントの伝播は非同期であるため、レディネスの解除と受付の遮断の両方が必要です。

受け入れられた200件のリクエストは、データベースおよびキャッシュクライアントが開いたままである間、処理を継続できます。これらは直接追跡します。25秒前に完了したリクエストは通常通り応答を返します。内部デッドラインで残りをアプリケーションプロトコル経由でキャンセルし、リトライ可能な状態を維持します。ジョブコンシューマについては、永続的な完了のみを応答(ack)します。未完了のリースジョブはキューの規約に従って解放または期限切れにし、その副作用には冪等性キーを使用します。

その後、定義されたシグナル経路を通じて2つの子ワーカーを終了させ、制限時間付きの待機で回収します。リクエストと子プロセスの利用者がすべていなくなった後にのみ、共有クライアントを閉じます。ログとトレースには、小さな制限付きのフラッシュ時間を割り当てます。成功したパスは30秒前にステータス0で終了します。そのデッドラインを超過した場合、SIGKILLによってプロセスが終了され、クリーンアップコードは一切実行されないため、永続的な正確性を最終フックに依存させることはできません。

検証のために、ビルドされたコンテナを所要時間が混在する200件の同時リクエストとアクティブなリースジョブで実行し、SIGTERMを送信します。実際のプロセスがシグナルを受信すること、レディネスが反転すること、受付境界を越える新しい作業がないこと、受け入れられた作業が25秒までに完了または明示的にキャンセルされること、未完了のジョブが重複した影響なしにリトライできること、両方の子プロセスが回収されること、そしてプロセスが30秒前に終了することを検証します。また、冪等性を確認するために繰り返しのSIGTERMを、リカバリ動作を確認するためにSIGKILLをテストします。ロールアウトダッシュボードには、シャットダウン所要時間、処理中の作業、デッドライン超過、および強制終了が表示されるようにすべきです。」

よくある間違い

  • 生のハンドラ内でクリーンアップを行う → ライブラリのロックやアロケータの状態がアクティブな間にシグナルがコードを中断する可能性があります → シグナル安全な最小限の通知のみを発行し、通常の制御パスでクリーンアップを行います。
  • アプリケーションがSIGTERMを受信すると決めつける → シェル形式のエントリポイントではシェルがPID 1のままになる可能性があります → exec形式またはexecで終わるラッパーを使用し、ビルドされたコンテナをテストします。
  • レディネスの失敗を受付の終了と同一視する → エンドポイントの更新には時間がかかり、直接接続や既存の接続から作業が送信される可能性があります → レディネスを失敗させると同時に、アプリケーションのリスナー/受付のカットオフを実施します。
  • 共有クライアントを最初に閉じる → 処理中のハンドラが完了する時間があったにもかかわらず、受付後に失敗する可能性があります → 必要なリソースを閉じる前に、それらを使用する処理をドレインします。
  • リースを確認せずにジョブコンシューマを停止する → 作業が不可視のまま残ったり、早すぎる応答が行われたり、副作用が繰り返されたりする可能性があります → 応答、リース、リトライ、および冪等性の規約に明示的に従います。
  • 完全なドレインを無限に待つ → オーケストレータはいずれSIGKILLを送信し、すべてのクリーンアップの機会を奪います → 最終処理のための時間を確保した内部デッドラインを使用します。
  • 子プロセスの所有権を忘れる → 子プロセスが親プロセスよりわずかに長く生存したり、回収されない場合にゾンビになったりする可能性があります → 意図的に終了シグナルを転送し、制限時間付きのwaitpidループを使用します。
  • 重複したシグナルでクリーンアップを2回開始する → 重複したクローズ操作やフラッシュ操作が競合したりクラッシュしたりする可能性があります → 状態遷移をアトミックにし、クリーンアップを冪等にします。
  • 標準シグナルをコマンドキューとして使用する → 保留中の同一の標準シグナルは合流する可能性があり、永続的な所有権情報を持ちません → 作業とリトライはキューに格納し、シグナルはライフサイクル制御のみに使用します。
  • 内部のシャットダウンメソッドのみをテストする → PID構成、ランタイムのシグナル配信、オーケストレーションの挙動がバイパスされます → 現実的な同時作業の負荷下で、本番イメージに実際のシグナルを送信します。

フォローアップの質問と回答

フォローアップ1:なぜシグナルハンドラから通常のシャットダウン関数を呼び出せないのですか?

ハンドラは、別スレッドまたは中断されたスレッド自体がアロケータ、stdio、ロギング、またはアプリケーションのロックを保持している最中にプログラムを中断する可能性があります。ほとんどのアプリケーションクリーンアップ関数は非同期シグナル安全ではありません。それらを呼び出すと、デッドロックや内部状態の破損を引き起こす可能性があります。ハンドラはフラグを設定してシグナル安全なウェイクアップ操作を使用するにとどめ、その後イベントループまたは専用のシグナルスレッドが通常のシャットダウンコードを呼び出すべきです。

フォローアップ2:マルチスレッドプロセスにおいてシグナルはどのように動作しますか?

シグナルの配置はプロセス全体で共有されますが、各スレッドは独自のシグナルマスクを持ちます。プロセス宛てのシグナルは、それをブロックしていない適格な任意のスレッドに配信される可能性があります。堅牢なパターンの1つは、ワーカーが作成される前に終了シグナルをブロックし、1つのスレッドがsigwaitまたはsignalfdでそれらを同期的に受信することです。もう1つは、安全な通知のみを投稿する最小限のプロセス共通ハンドラを使用することです。マスクが混在し一貫性がない場合、動作の推論が困難になります。

フォローアップ3:ここでのSIGTERMとSIGKILLの実質的な違いは何ですか?

SIGTERMは終了を要求し、捕捉が可能であるため、アプリケーションにプロトコルを実行する機会を与えます。そのデフォルト動作は依然としてプロセスの終了です。SIGKILLはカーネルによって強制される終了であり、捕捉、ブロック、無視のいずれもできず、クリーンアップ処理は一切実行されません。猶予期間が価値を持つのは、SIGTERMの処理パスが到達可能で、安全であり、時間制限が設けられている場合のみです。

フォローアップ4:なぜレディネスを失敗させるだけでなく、受付も閉じる必要があるのですか?

レディネスの変更はKubernetesとそのプロキシに対してルーティングの停止を通知しますが、エンドポイントの更新と接続のドレインは非同期です。既存のキープアライブ接続や直接接続は依然としてプロセスに到達する可能性があります。アプリケーションのカットオフは新しい作業が入ってこれなくなる正確なポイントを定義し、レディネスは通常のルーティングからPodを削除します。検証では両方のレイヤーを観察する必要があります。

フォローアップ5:処理が途中のジョブはどうすべきですか?

ジョブの所有権規約に従います。内部デッドライン前に安全に完了できる場合のみ処理を継続します。そうでない場合は、リトライできるようにリースを停止または解放し、永続的な完了の前に応答(ack)しないようにします。副作用と応答の間に強制終了が発生する可能性があるため、外部への副作用には冪等性キーまたはトランザクション状態遷移が必要です。

フォローアップ6:preStopフックが存在する場合、何が変わりますか?

フックは同じPod終了猶予期間を消費します。フックの最悪ケースの実行時間を測定し、アプリケーションの予算から差し引いてください。フックの実行時間を制限し、2つの競合するパスでアプリケーションのクリーンアップが重複しないようにします。フックが失敗する可能性や、通常のロールアウト以外でプロセスがシグナルを受信する可能性があるため、アプリケーションは依然としてSIGTERMを処理できなければなりません。

フォローアップ7:本番環境での強制終了をどのように調査しますか?

Podの終了理由とタイムスタンプを、アプリケーションのシャットダウン開始時刻、処理中件数、ジョブリース、子プロセスの状態、および最後に完了したシャットダウン状態と関連付けます。シグナル配信の失敗、ドレインの遅延、スタックした子プロセス、またはブロックされた最終フラッシュを切り分けます。バージョンごとにグレースフルシャットダウンの所要時間と強制終了率を追跡し、カナリアロールアウト中にリグレッションを検知できるようにします。

公開情報ソース

関連する質問