このブログ投稿では、MetaのカーネルエンジニアのChris Downが、Linux本番環境でLinuxのシグナルを使う際の落とし穴について、また開発者ができるだけシグナルを使うのを避けるべき理由について説明します。
シグナルとは、何らかの状況に反応してLinuxシステムから生成されるイベントのことです。シグナルは、カーネルからプロセスに、プロセスから別のプロセスに、あるいはプロセスからそのプロセス自体に送信されます。シグナルを受け取ったプロセスが、アクションを実行する場合もあります。
シグナルはUnixのようなオペレーティング環境の核となる部分であり、その黎明期から存在してきました。それは、このオペレーティングシステムの多くのコアコンポーネント(コアダンプやプロセスライフサイクル管理など)を行き来し、全般的に、約50年間にわたって良い働きをしてきました。そのため、プロセス間通信(IPC)にシグナルを利用するのは危険だという人がいるとしたら、今になって車輪を発明しようと躍起になっている人の戯言のように聞こえるかもしれません。とはいえ、この記事の主旨として、シグナルが本番環境のさまざまな問題の原因となった事例を挙げ、その危険を緩和する方法や代替手段を示します。
シグナルは、規格化されていて、利用範囲が広く、オペレーティングシステムに備わっている機能以外に依存先がないため、魅力的に見えるのも当然です。しかしそれを安全に使おうとすると、難しい場合があります。シグナルには数多くの前提条件があり、要件を満たすために十分な注意を払うことが求められます。要件を満たさない場合は、正しく構成するために注意深くならなければなりません。実際、広く知られたアプリケーションも含め、そのような注意が払われていないアプリケーションも少なくないのです。結果として、将来問題が発生した時にデバッグするのが難しくなってしまいます。
一例として、Metaの本番環境で最近起きたインシデントを見てみましょう。シグナルの使用の落とし穴がよく分かります。いくつかのシグナルの歴史や、それがどのように現在の形になったかを簡単に振り返ってから、現在本番環境で見られるニーズや問題点と比較考慮してみます。
少し前のことです。コードベースの整頓作業をしていたLogDeviceチームは、不要なコードや機能を削除していました。廃止された機能の1つに、サービスによって実行された特定の操作について記録する一種のログ機能がありました。この機能は結果的に冗長なものとなったため、不要になり、削除されることになりました。その変更内容については、GitHubのこちらをご覧ください。ここまでは良かったのです。
この変更の後、しばらくは特に何事もなく、本番環境は安定して動作し、通常どおりにトラフィックが配信されていました。数週間後、サービスノードが驚くほど急速に失われていることが報告されました。これは新しいリリースの提供開始に関係したものでしたが、実際のところ 何が 問題だったのかはよく分かりませんでした。一体何が変わったので、うまくいかなくなったのでしょうか?
チームが問題を切り分けた結果、前述のコード変更でログ機能を廃止したことに問題があることが分かりました。なぜでしょうか?そのコードの何が問題だったのでしょうか?答えが分からない場合、そのdiffを見て何が問題なのかを自分で調べてみることをおすすめします。これはすぐには分かりづらく、だれもが犯す可能性のある間違いだからです。
logrotateは、Linuxを使用している時にログのローテーションに使われる標準的なツールです。もう30年近く使われていますが、その概念はシンプルです。ログをローテーションさせたり空にしたりしてログのライフサイクルを管理します。
logrotate自体がシグナルを送信することはないため、logrotateのメインページやそのドキュメントにもシグナルに関する説明はほとんどありません。しかし、logrotateは、ローテーション操作の前後に任意のコマンドを実行することができます。CentOSのlogrotateのデフォルト構成の基本サンプルのように、次のような構成になっているとします。
/var/log/cron /var/log/maillog /var/log/messages /var/log/secure /var/log/spooler { sharedscripts postrotate /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true endscript }
精密なものではありませんが、これで意図したとおりに動作するとしましょう。この構成では、リストに挙げられているファイルのいずれかをlogrotateがローテーションしたら、その後にSIGHUP
を/var/run/syslogd.pid
に含まれているpidに対して送信することになっています。実行中のsyslogd
インスタンスのpidです。
これはsyslogなどの安定している公開APIではうまく動作します。しかし、SIGHUP
の実装が内部的に実装される細かなもので、いつでも変更される可能性がある場合はどうでしょうか?
ここでの問題点の1つは、SIGKILL
やSIGSTOP
のように、ユーザースペースでは捉えられないシグナルには意味が1つしかないのに対して、それ以外のシグナルでは、その意味をどう解釈し、どうプログラムするかは、アプリケーションの開発者とユーザーに委ねられているということです。場合によっては、その違いは主に学問的なものです。例えば、SIGTERM
の場合、「できるだけ早く穏便に終了する」という意味で、ほぼ常識として受け入れられています。しかし、SIGHUP
の場合、その意味はずっとあいまいです。
SIGHUP
は、直列回線用に考案されたものであり、元々は接続の相手側で回線が落ちたことを示すために使われていました。現在でも当然それが引き継がれており、今の時代にそれに相当する事象に対しては、やはりSIGHUP
が送信されています。疑似ターミナルや仮想ターミナル(そして、それをマスクするnohup
などのツール)が閉じられた時です。
Unixの初期には、デーモンのリロードを実装する必要がありました。通常そのためには、少なくとも再起動しないで構成ファイル/ログファイルを再オープンする必要がありました。それを達成するためシグナルが依存関係不要の便利な手段とされていました。もちろん、そのような用途のシグナルなどないわけですが、それらのデーモンには制御ターミナルがなく、SIGHUP
を受け取る理由もないため、特に副作用なく便乗して使える便利なシグナルと考えられていたのです。
しかしこのプランに躓きの元があります。シグナルのデフォルトの状態は「無視」ではなくシグナル固有のものです。例えば、アプリケーションを終了するために、プログラムで必ずしもSIGTERM
を手動で構成する必要はありません。ほかのシグナルハンドラーを設定するのでない限り、単にカーネルが言わばただでプログラムを終了します。ユーザースペースにコードは必要ありません。確かに便利です!
しかし不便な点は、SIGHUP
のデフォルト動作もプログラムの即時終了だということです。元来のハングアップのケースでは、アプリケーションは特にそれ以上何も必要ないのでうまく動作するのでしょうが、新しい意味ではそうではありません。
もちろん、プログラムにSIGHUP
を送信する可能性がある箇所をすべて削除すればいいわけです。問題は、大規模な成熟したコードベースにおいては、それが難しいということにあります。SIGHUP
は、コードベースに対してgrep検索できる、しっかり管理されたIPC呼び出しのようなものではないのです。シグナルはいつどこから来るか分かりませんし、その動作に関するチェックはほとんどありません(最も基本的な「あなたはこのユーザーか、それともCAP_KILL
があるか」のみ)。要するに、もっと明示的なIPCの場合は、このシグナルには特に意味がなく無視すべきだということが分かりますが、シグナルがどこから来るかを見極めるのは難しいということです。
ここまで来れば、何が起きたのか予想がつくでしょう。前述のコード変更をしたLogDeviceのリリースが、運命の午後を迎えました。最初は何事もなかったのですが、深夜、次の日になった時点で何もかもが謎の障害に陥ってしまったのです。マシンのlogrotate構成の次の部分が原因となった箇所です。処理されていない(それゆえに致命的エラーとなった) SIGHUP
がlogdeviceデーモンに送信されています。
/var/log/logdevice/audit.log { daily # [...] postrotate pkill -HUP logdeviced endscript }
logrotate構成でほんの少しの部分を落としてしまうのは、よくありがちなことで、大きな機能を削除している場合は特にそうです。不幸なことに、その存在の痕跡がすべて一度に消えてしまえば、それを確認することもできなくなります。検証するのがこの場合より比較的容易なケースでも、コードの整頓作業でうっかり削除し忘れるというのも、ありがちなことです。それでも多くの場合、破壊的な結果になることは、まずありません。残った断片は、機能的に死んでいるか、何もしないコードに過ぎないからです。
概念的には、このインシデント自体とその解決策はシンプルです。SIGHUP
を送信しないようにし、LogDevice操作の時間を分散させる(つまり、0時きっかりに実行しないようにする)ことです。しかし、ここでフォーカスするべきなのは、この1つのインシデントだけではありません。このインシデントから得られることは、最も基本的かつ不可欠な場合を除き、本番でシグナルを使わないほうがよいという点です。
まず、オペレーティングシステムでのプロセス状態の変化に影響を及ぼすメカニズムとして使う場合 は、 問題ありません。これには、シグナルハンドラーのインストールが不可能で、想定のとおりに動作するSIGKILL
などのシグナルが含まれます。また、SIGABRT
、SIGTERM
、SIGINT
、SIGSEGV
、SIGQUIT
のカーネルデフォルト動作やそれに類するものも含まれます。これらは、全般的にユーザーにとってもプログラマーにとっても分かりやすいものです。
これらのシグナルに共通しているのは、それを受信したなら、カーネル自体の内部でターミナル終了状態へと向かって進んでいくということです。つまり、ユーザースペースシグナルハンドラーのないSIGKILL
やSIGTERM
を受け取ったなら、もはやユーザースペース命令は実行されません。
ターミナル終了状態は重要です。多くの場合それは、現在実行中のスタックとコードの複雑度が徐々に下がっていくことを意味しているからです。目指す状態がそれ以外になると、しばしば複雑度は逆に上がっていき、並行処理やコードフローがごちゃごちゃになって論理的な解析が難しくなってしまいます。
デフォルトで終了することになるシグナルはほかにもあるのに、それには言及していないことにお気づきかもしれません。デフォルトで終了する標準シグナルをすべて以下にリストします(SIGABRT
やSIGSEGV
など、穏便な動作をするダンプシグナルを除く)。
見たところ、これらは穏便に動作しそうですが、そうではないものもいくつかあります。
ターミナルシグナルの約3分の1がそのようなものであり、たいていは疑わしいものです。最悪の場合、プログラムのニーズが変わるにつれてますます危険になっていきます。さらに悪いことに、「ユーザー定義」とされているシグナルでさえ、明示的にそれをSIG_IGN
するのを忘れると大変なことになります。無害であるはずのSIGUSR1
やSIGPOLL
でさえ、インシデントの原因になることがあります。
これは、単に詳しい知識があればよいという問題ではありません。シグナルの動作をどんなに熟知しているとしても、一発で正しく動作するシグナルのコードを書くのは至難の業です。シグナルは、見かけよりもはるかに複雑だからです。
普通プログラマーは、シグナルの内部動作について一日中考えている余裕はありません。いざ実際にシグナルの処理を実装するとなると、微妙な間違いを犯しやすくなります。
ここで言いたいのは、シグナル処理関数の安全性といった「ありきたり」なケースのことではありません。これはほとんどの場合、sig_atomic_t
をたたくか、C++のアトミックなシグナル防護壁を使うだけで解決します。それらは、簡単に検索できる場合がほとんどであり、シグナルの修羅場を一度でも経験したことのある人なら、落とし穴として記憶していることでしょう。それよりはるかに難しいのは、複雑なプログラムの主要部分がシグナルを受け取る際のコードフローを論理的に解析することです。そのためには、アプリケーションライフサイクルのあらゆる部分で、常にまたは意識的にシグナルについて考え続けることが求められます(EINTR
についてはどうなのか、SA_RESTART
で十分なのか?これが中途で終了した場合にどういうフローにするべきなのか?並行処理プログラムになったのでどうしたらいいのか?)。さもなければ、アプリケーションライフサイクルのどこかでsigprocmask
やpthread_setmask
を設定して、コードフローが一切変わらないことをひたすら祈ることになるでしょう(速度が求められるような開発では、全くあてにならない推測です)。signalfd
を使ったり専用スレッドでsigwaitinfo
を実行したりするのは、いくらか助けになるものの、そのいずれも例外的なケースや使用に関して不安材料があるため、おすすめはできません。
経験豊富なプログラマーならここまでで、スレッドセーフなコードを正しく書く面白い例を挙げることさえ難しいことが分かっていただけたと思います。スレッドセーフなコーディングが難しいのであれば、シグナルはそれよりもずっと難しいのです。シグナルハンドラーが依存するのは、アトミックなデータ構造を持つ厳密なロックフリーのコードだけにしなければなりません。実行のメインフローが一時停止されても、どのロックを掛けているかは分からないからです。そして、実行のメインフローがアトミックでない操作をしている可能性があるからです。完全に再入可能である必要もあります。つまり、シグナルが複数回送信された場合には(SA_NODEFER
の場合には1つのシグナルでも)、シグナルハンドラーがオーバーラップする可能性があるため、自分自身の中にネストできるようでなければなりません。それが、シグナルハンドラーでprintf
やmalloc
のような関数を使えない理由の1つとなっています。それらの関数はグローバルなmutexを使って同期を取るからです。シグナル受信時にロックを掛け、それからそのロック部分を必要とする関数を再び呼び出すと、アプリケーションはデッドロックになってしまいます。こうしたことを予測するのは、実に難しいものです。そのため、シグナル処理で以下のような簡単な書き方をしてしまう人が大勢います。
static volatile sig_atomic_t received_sighup; static void sighup(int sig __attribute__((unused))) { received_sighup = 1; } static int configure_signal_handlers(void) { return sigaction( SIGHUP, &(const struct sigaction){.sa_handler = sighup, .sa_flags = SA_RESTART}, NULL); } int main(int argc, char *argv[]) { if (configure_signal_handlers()) { /* failed to set handlers */ } /* usual program flow */ if (received_sighup) { /* reload */ received_sighup = 0; } /* usual program flow */ }
一方の非同期シグナル処理では、signalfd
やほかの試みはシンプルでしかも堅実だと一見思えるかもしれませんが、どの時点で割り込みが発生したかは、シグナル受信後に実行されるアクションと同じくらい重要であるという事実が見過ごされています。そこが問題なのです。例えば、ユーザースペースコードで入出力している、またはカーネル由来のオブジェクト(inodeやFDなど)のメタデータを変更しているとします。このようなケースではたいてい、割り込み時はカーネルスペーススタックにいるでしょう。例えば、スレッドがファイルディスクリプターを閉じようとすると、スレッドは次のようになります。
# cat /proc/2965230/stack [<0>] schedule+0x43/0xd0 [<0>] io_schedule+0x12/0x40 [<0>] wait_on_page_bit+0x139/0x230 [<0>] filemap_write_and_wait+0x5a/0x90 [<0>] filp_close+0x32/0x70 [<0>] __x64_sys_close+0x1e/0x50 [<0>] do_syscall_64+0x4e/0x140 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9
ここの __x64_sys_close
は、ファイルディスクリプターを閉じるためのx86_64版のclose
システムコールです。実行のこの時点では、バックストレージが更新されるのを待っています(それが、このwait_on_page_bit
)。入出力作業はほかの操作に比べてはるかに遅いため、schedule
を利用することにより、レイテンシーの高い操作(ディスクに出力やネットワーク入出力など)を実行しようとしていること、および現時点では、現行プロセスではなく別のプロセスを見つけてスケジュールするべきことをカーネルのCPUスケジューラーに自発的に暗示しています。これは良い方法です。時間のかかる処理が終了して応答が来るのを待たないと続行できないプロセスに時間を費やすよりも、CPUを有効活用するようなプロセスを選んで処理を進めるほうがよいとカーネルに知らせることができます。
ここで、実行していたプロセスにシグナルを送信するとしましょう。送信したシグナルには、ユーザースペースハンドラーが受信スレッド側にあるため、ユーザースペースでプロセスを再開することになります。このレースの結末として考えられることは、カーネルがschedule
から出てきて、さらにスタックをばらし、最終的にESYSRESTART
またはEINTR
のerrnoをユーザースペースに返すことにより、割り込みがあったことを明らかにするというものです。しかし、クローズ処理はどれだけ進んだのでしょうか?現在のファイルディスクリプターの状態はどうなっているのでしょうか?
ではユーザースペースに戻って、シグナルハンドラーを実行しましょう。シグナルハンドラーが終了すると、エラーがユーザースペースlibcのclose
ラッパーに伝播し、さらにはアプリケーションに伝えられて、理論上は、発生した状況にアプリケーションが対応できます。ここで「理論上」と書きましたがそれは、このような状況でシグナルに関して何をするべきかを知ることは実際には困難であり、本番モードのサービスの多くは、ここに示すような例外的ケースをあまりうまく処理しないのです。データ整合性がそれほど重要でないアプリケーションでは、これで良いかもしれません。しかし、データの一貫性や整合性が 重要になる 本番アプリケーションの場合、重大な問題を孕んでいます。カーネルには、どこまで処理が進んだか、何が達成され何が達成されていないか、この状況で何をするべきかについて理解する細かな方法が全くないのです。さらに悪いことに、close
がEINTR
を返した場合、ファイルディスクリプターの状態は次のように不明ということになります。
“If close() is interrupted by a signal [...] the state of [the file descriptor] is unspecified.”
アプリケーションでこれを安全に処理するにはどうすればいいか考えなければなりません。ただ幸運を祈るばかりです。一般に、穏便な動作をするシステムコールの場合でも、EINTR
の取り扱いは複雑です。SA_RESTART
では不十分な理由の大部分を占める微妙な問題は数多く存在します。すべてのシステムコールが再開可能とは限りません。アプリケーション開発者の1人1人が、すべてのコールサイトのすべてのシステムコールのシグナルを得ることの深い意味を理解し、その害をなくすことを期待するのは現実的ではありません。man 7 signal
からの引用:
“The following interfaces are never restarted after being interrupted by a signal handler, regardless of the use of SA_RESTART; they always fail with the error EINTR [...]”
同じように、sigprocmask
を使い、コードフローが変わらないことを期待しても、トラブルの元になるだけです。開発者はたいてい、シグナル処理の境界や、シグナル修正コードを書いたり保存したりする方法を考えることにすべてを費やすわけではありません。sigwaitinfo
による専用スレッドでのシグナルの取り扱いについても、同じことが言えます。簡単に、GDBなどのツールでプロセスのデバッグができなくなってしまいます。微妙に違うコードフローやエラー処理があると、バグやクラッシュにつながり、不具合のデバッグが困難になったり、デッドロックなどの多くの問題が発生したりして、使い慣れたインシデント管理ツールに一目散に頼ることになる可能性があります。
同時並行、再入可能性、アトミック性に関するこの説明で十分に悪いと思うのであれば、マルチスレッドが加われば、ますますやっかいなことになるのは当然です。これが特に重要と言えるのは、多くの複雑なアプリケーションでは、例えばjemallocやGLibなどの一部として、暗黙のうちに複数のスレッドが個別に実行されているからです。それらのライブラリの中には、自力でシグナルハンドラーをインストールして、新たにやっかいな問題を引き起こすものさえあります。
man 7 signal
で、ほぼこの問題について述べられています。
“A signal may be generated (and thus pending) for a process as a whole (e.g., when sent using kill(2)) or for a specific thread [...] If more than one of the threads has the signal unblocked, then the kernel chooses an arbitrary thread to which to deliver the signal.”
もっと簡潔に言えば、「ほとんどの場合、カーネルはシグナルを、sigprocmask
でそのシグナルをブロックしていない任意のスレッドに送信する」ということになります。SIGSEGV、SIGILL、およびtrapsに類するものは、シグナルが該当スレッドに明示的に送られるようにします。しかし、意外にも多くのシグナルでは、tgkill
やpthread_kill
を使っても、スレッドグループの中の1つのスレッドに明示的に送信することができないのです。
したがって、一連の複数スレッドがあるからといって、シグナル取り扱いの全体的な特性を気安く変えることはできません。サービスがsigprocmask
を使ってメインスレッドのシグナルを断続的にブロックする必要がある場合、ほかのスレッドに外部的に通信してそのシグナルの扱い方を知らせる必要があります。そうしないと、シグナルは別のスレッドに呑み込まれてしまい、追跡できなくなります。もちろん、これを避けるため子スレッドでシグナルをブロックすればいいのですが、waitpid
などの基本的なものであったとしても、子スレッド独自のシグナル処理が必要になるなら、結局のところ物事が複雑になってしまいます。
ここで述べたほかの点と同じく、技術的に克服できるような問題ではないのです。しかし、これを適切に動作させるのに必要な同期の複雑さは負担が大きく、バグや混乱などの原因になりかねないという事実を見過ごしてしまいがちです。
シグナルは、カーネルで非同期で伝播します。kill
システムコールは、プロセスやスレッドの当該のtask_struct
に関して保留状態のシグナルが記録されるとすぐに応答を返します。したがって、シグナルがブロックされていなくても、タイミング良く配信される保証はありません。
シグナルがタイミング良く配信される としても 、シグナル発信側に対して、アクションを求めるリクエストのステータスをフィードバックする手段はありません。そのため、意味のあるアクションをシグナルによって配信するべきではありません。シグナルは、「発信したらそれっきり」であり、配信とその後のアクションの成否を報告するメカニズムがないからです。前述のように、一見無害に思えるシグナルも、ユーザースペースで構成されていないなら、危険なものになる可能性があります。
長年Linuxを使っている人なら、プロセスをkillしたいのに、SIGKILL
のようにいつも致命的とされているシグナルでも、プロセスが反応しないという経験がきっとあることでしょう。問題は、ややこしいことに、kill(1)の目的は、プロセスをkillすることではなく、アクションの実行を求めたカーネルへのリクエストをキューに入れることなのです(かつ、いつサービスが実行されるかを示すものはありません)。
kill
システムコールの仕事は、カーネルのタスクメタデータでシグナルを保留状態にすることです。これは、SIGKILLタスクが死ななくても成功します。特にSIGKILL
の場合は、ユーザーモード命令がそれ以上実行されないことがカーネルによって保証されてはいます。それでも、データ破壊の可能性のあるアクションを実行したりリソースを解放したりするために、カーネルモードで命令を実行することが必要になることがあります。そのため、状態がD (割り込み不能スリープ)であっても成功になるのです。killそのものが失敗するのは、送信されたシグナルが無効なものであったり、そのシグナルを送信する権限がなかったり、シグナル送信先としてリクエストしたpidが存在しないために非ターミナル状態を確実にアプリケーションに伝播するのに役立たない時だけです。
シグナル処理のトラブルを防ぐ1つの方法は、シグナルを全く処理しないことです。しかし、SIGTERM
のようなケースに対して何かしなければならない状態処理を扱うアプリケーションの場合は、folly::AsyncSignalHandler
のような高位レベルのAPIを使うのが理想です。その欠点の多くはより直感的に理解できるように既になっているからです。
sigprocmask
を使って、シグナルの利用をプログラムやスレッドの一部分に限定してください。そうすれば、シグナルの正確さのために定期的に調べる必要のあるコードの量が少なくなります。コードフローやスレッド戦略が変わると、マスクに意図した効果がなくなってしまう可能性があることに注意してください。signal(SIGHUP, SIG_IGN); signal(SIGQUIT, SIG_IGN); signal(SIGUSR1, SIG_IGN); signal(SIGUSR2, SIG_IGN);
適切に作成されたプログラムであっても、シグナルの動作は解析するには複雑すぎます。ほかの手段があるのにシグナルを使用すれば、アプリケーションに不必要なリスクをもたらします。一般に、プログラムのユーザースペースの部分とのやり取りにはシグナルを使わないでください。その代わり、プログラムで(inotifyなどにより)イベント自体を透過的に処理するか、発行元に対してエラーを報告することが可能で、かつコンパイル時に列挙可能かつ提示可能なユーザースペース通信(ThriftやgRPCなど)を使うようにしてください。
この記事により、一見シンプルに思えるシグナルが、実際には全くそうでないことを理解していただけたと思います。ユーザースペースソフトウェアのAPIとしての使用を推し進めてきたその美しいシンプルさには、一連の設計上の暗黙的な決定が隠されており、現代の本番環境のユースケースには適さないのです。
ここではっきりさせましょう。シグナルには有効な用途があります。シグナルは、ユーザースペースコンポーネントがない場合に、望ましいプロセス状態についてカーネルと基本的なコミュニケーションを取るのには適しています。例えば、プロセスをkillする場合です。しかし、シグナルがユーザースペースに取り込まれることが想定される場合、シグナル的に正確なコードを一発で書くことは至難の業です。
シグナルは標準化されていて、使用範囲が広く、依存関係がないために魅力的に見えるかもしれませんが、多くの落とし穴があり、プロジェクトが進展するにつれ、ただ問題を増やすだけになるでしょう。同じ目標を、より安全に、微妙な複雑さが少ない、より直感的な方法で達成できる緩和策や代替戦略を見つけるのに、この記事が役立てばうれしく思います。
Meta Open Sourceについて詳しくは、オープンソースサイトをご覧になるか、当社のYouTubeチャンネルを登録するか、Twitter、Facebook、LinkedInで当社をフォローしてください。