Manjusaka

Manjusaka

コンテナ内の1号プロセスについての議論を続ける

先週の記事では、コンテナ内の 1 号プロセスについての概要を話しましたが、私の師匠である某川(GitHub で彼を探して遊んでください、jschwinger23)の指導と協力のもと、現在主流で広く使用されている 2 つのコンテナの 1 号プロセスの実装である dumb-init と tini について探求し、さらに水文を書いて議論を続けます。

本文#

なぜ 1 号プロセスが必要なのか、我々が望む 1 号プロセスはどのような責任を担うべきか?#

dumb-init と tini に関する議論を続ける前に、まず 1 つの問題をレビューする必要があります。なぜ 1 号プロセスが必要なのか?そして、我々が選択した 1 号プロセスはどのような責任を担うべきか?

実際、コンテナのシナリオにおいて 1 号プロセスを前面にホストする必要がある主なシナリオは 2 つあります。

  1. コンテナ内での Graceful Upgrade バイナリのシナリオでは、主流の方法の 1 つは新しいプロセスをフォークし、新しいバイナリファイルを exec し、新しいプロセスが新しいリンクを処理し、古いプロセスが古いリンクを処理することです。(Nginx はこの方法を採用しています)

  2. 信号の転送やプロセスの回収が正しく処理されていない場合

  3. calico-node のようなシナリオでは、パッケージングの便宜上、複数のバイナリを同じコンテナ内で実行します。

最初のシナリオについては特に言うことはありませんが、2 つ目のテストを見てみましょう。

最もシンプルな Python ファイル、demo1.pyを準備します。

import time

time.sleep(10000)

次に、通常通り bash スクリプトでラップします。

#!/bin/bash

python /root/demo1.py

最後に Dockerfile を作成します。

FROM python:3.9

ADD demo1.py /root/demo1.py
ADD demo1.sh /root/demo1.sh

ENTRYPOINT ["bash", "/root/demo1.sh"]

ビルド後、実行を開始します。まずプロセス構造を見てみましょう。

プロセス構造

問題ありません。では、straceを使って 2049962、2050009 の 2 つのプロセスをトレースし、2049962 の bash プロセスにSIGTERM信号を送ります。

結果を見てみましょう。

2049962 プロセスの trace 結果

2050009 プロセスの trace 結果

2049962 プロセスがSIGTERMを受け取ったとき、2050009 プロセスに転送されなかったことが明確にわかります。手動で 2049962 を SIGKILL した後、2050009 も即座に終了しました。ここで疑問に思う人がいるかもしれません。なぜ 2049962 が終了した後、2050009 も終了するのでしょうか?

これは PID ネームスペース自体の特性によるものです。pid_namespacesの関連情報を見てみましょう。

PID ネームスペースの「init」プロセスが終了すると、カーネルは SIGKILL 信号を介してネームスペース内のすべてのプロセスを終了させます。

現在の PID ネームスペース内の 1 号プロセスが終了すると、カーネルはその PID ネームスペース内の残りのプロセスに SIGKILL を送信します。

さて、コンテナスケジューリングフレームワークと組み合わせると、実際の運用では多くの問題が発生します。以前の私の愚痴を見てみましょう。

私たちのテストサービス、Spring Cloud では、ノードが登録センターから削除できず、困惑しました。最終的に問題を調査した結果、、
本質的には、POD が削除されるとき、K8S スケジューラーは POD の ENTRYPOINT に SIGTERM 信号を送信し、30 秒(デフォルトのグレースフルシャットダウンのタイムアウト)待機します。応答がなければ、SIGKILL で直接終了します。
問題は、私たちの Eureka 版サービスが start.sh を介して起動されているため、ENTRYPOINT ["/home/admin/start.sh"]、コンテナ内のデフォルトは /bin/sh のフォーク /exec モードであり、サービスプロセスが SIGTERM 信号を正しく受け取れず、終了せずに SIGKILL されてしまったことです。

刺激的ですね。信号転送が正常に処理されないだけでなく、アプリケーションで一般的な問題の 1 つは Z プロセスの発生です。つまり、子プロセスが終了した後、正しく回収できないことです。例えば、初期の puppeteer の悪名高い Z プロセスの問題です。このような場合、アプリケーション自体の問題の他に、デーモンプロセスのようなシナリオでは、孤児プロセスが再親化された後、子プロセスを回収する機能を持たないことが考えられます。

さて、上記の一般的な問題を振り返った後、コンテナ内の 1 号プロセスが担うべき責任を再確認しましょう。

  1. 信号の転送

  2. Z プロセスの回収

現在、コンテナシナリオでは、主に 2 つのソリューションが自分のコンテナ内の 1 号プロセスとして使用されています。dumb-inittini。これらの 2 つのソリューションは、コンテナ内の孤児と Z プロセスの処理に関してはまずまずですが、信号転送の実装には言葉にできない問題があります。それでは次に

議論の時間です!

ひどい dumb-init#

ある意味で、dumb-initは完全に虚偽の宣伝の典型です。コード実装は非常に粗雑です。

公式の宣伝を見てみましょう。

dumb-init は PID 1 として実行され、シンプルな init システムのように機能します。単一のプロセスを起動し、その後、受信したすべての信号をその子プロセスにルーティングします。

ここで、dumb-init は Linux のプロセスセッションを使用していると言っています。私たちは知っていますが、プロセスセッションはデフォルトでプロセスグループ ID を共有します。したがって、ここでは、dumb-init が信号をプロセスグループ内の各プロセスに完全に転送できると理解できます。素晴らしい響きですね?

では、テストしてみましょう。

テストコードは以下の通りです。demo2.py

import os
import time

pid = os.fork()
if pid == 0:
    cpid = os.fork()
time.sleep(1000)

Dockerfile は以下の通りです。

FROM python:3.9

RUN wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64
RUN chmod +x /usr/local/bin/dumb-init

ADD demo2.py /root/demo2.py

ENTRYPOINT ["/usr/local/bin/dumb-init", "--"]

CMD ["python", "/root/demo2.py"]

ビルドして実行し、まずプロセス構造を見てみましょう。

demo2 のプロセス構造

次に、いつものように strace を使って 2103908、2103909、2103910 の 3 つのプロセスをトレースし、dumb-initのプロセスに SIGTERM を送信します。

strace 2103908

strace 2103909

strace 2103910

え?dumb-init 先生、何が起こったのですか?なぜ 2103909 が直接 SIGKILL されたのに、SIGTERM を受け取らなかったのでしょうか?

ここで dumb-init の重要な実装を見てみましょう。

void handle_signal(int signum) {
    DEBUG("Received signal %d.\n", signum);

    if (signal_temporary_ignores[signum] == 1) {
        DEBUG("Ignoring tty hand-off signal %d.\n", signum);
        signal_temporary_ignores[signum] = 0;
    } else if (signum == SIGCHLD) {
        int status, exit_status;
        pid_t killed_pid;
        while ((killed_pid = waitpid(-1, &status, WNOHANG)) > 0) {
            if (WIFEXITED(status)) {
                exit_status = WEXITSTATUS(status);
                DEBUG("A child with PID %d exited with exit status %d.\n", killed_pid, exit_status);
            } else {
                assert(WIFSIGNALED(status));
                exit_status = 128 + WTERMSIG(status);
                DEBUG("A child with PID %d was terminated by signal %d.\n", killed_pid, exit_status - 128);
            }

            if (killed_pid == child_pid) {
                forward_signal(SIGTERM);  // send SIGTERM to any remaining children
                DEBUG("Child exited with status %d. Goodbye.\n", exit_status);
                exit(exit_status);
            }
        }
    } else {
        forward_signal(signum);
        if (signum == SIGTSTP || signum == SIGTTOU || signum == SIGTTIN) {
            DEBUG("Suspending self due to TTY signal.\n");
            kill(getpid(), SIGSTOP);
        }
    }
}

これは dumb-init が信号を処理するコードです。信号を受け取ると、SIGCHLD 以外の信号を転送します(注意:SIGKILL はハンドルできない信号です)。信号転送のロジックを見てみましょう。

void forward_signal(int signum) {
    signum = translate_signal(signum);
    if (signum != 0) {
        kill(use_setsid ? -child_pid : child_pid, signum);
        DEBUG("Forwarded signal %d to children.\n", signum);
    } else {
        DEBUG("Not forwarding signal %d to children (ignored).\n", signum);
    }
}

デフォルトでは、直接 kill で信号を送信します。その際、-child_pid は次の特性を持っています。

PID が - 1 未満の場合、sig は - pid のプロセスグループ内のすべてのプロセスに送信されます。

プロセスグループに直接転送するように見えますが、ここでの理由は何でしょうか?前述の通り、kill がプロセスグループに信号を送る動作はsig はすべてのプロセスに送信されるということです。理解しましたか?これは O (N) の反復です。問題はありませんね?さて、ここでの dumb-init の実装にはレースコンディションが存在します。

先ほど言ったように、kill プロセスの動作は O (N) の反復であるため、必然的にプロセスが先に信号を受け取ることがあります。SIGTERM の例を考えてみましょう。dumb-init の子プロセスが先に SIGTERM を受け取り、優雅に終了した後、dumb-init が SIGCHLD 信号を受け取り、wait_pid で子プロセス ID を取得し、自分が直接管理しているプロセスであると判断して自殺します。さて、dumb-init は現在の PID ネームスペース内の init プロセスであるため、PID ネームスペースの特性を再確認しましょう。

PID ネームスペースの「init」プロセスが終了すると、カーネルは SIGKILL 信号を介してネームスペース内のすべてのプロセスを終了させます。

dumb-init が自殺した後、残りのプロセスは直接カーネルによって SIGKILL されます。これにより、上記で見たように、子プロセスは転送された信号を受け取れなくなります!

ここで強調しておきますが、dumb-init が約束する、すべてのプロセスに信号を転送できるというのは完全に虚偽の宣伝です!

さらに注意すべき点は、dumb-init は自分のセッション内のプロセスを管理できると主張していますが、実際にはプロセスグループの信号転送しか行っていません!完全に虚偽の宣伝です!Fake News!

また、上記で述べたように、バイナリのホットアップデートのようなシナリオでは、dumb-init はプロセスが終了した後に直接自殺します。1 号プロセスを使用しないのと全く変わりません!

テストコードを見てみましょう。テストコードは demo3.py です。

import os
import time

pid = os.fork()
time.sleep(1000)

プロセスをフォークし、合計 2 つのプロセスを作成します。

Dockerfile は以下の通りです。

FROM python:3.9

RUN wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64
RUN chmod +x /usr/local/bin/dumb-init

ADD demo3.py /root/demo3.py

ENTRYPOINT ["/usr/local/bin/dumb-init", "--"]

CMD ["python", "/root/demo3.py"]

ビルドして実行し、まずプロセス構造を見てみましょう。

demo3 プロセス構造

次に、古いプロセスを終了させるために、2134836 を SIGKILL し、2134837 の strace の結果を見てみましょう。

strace 2134837

予想通り、dumb-init が自殺した後、2134837 はカーネルによって SIGKILL されました。

したがって、dumb-init が失敗していることを再確認しましょう!さて、次に tini の実装について話しましょう。

フレンドリーに tini について話す#

公平に言えば、tini の実装は、まだいくつかの問題がありますが、dumb-initよりもはるかに繊細です。まずはコードを見てみましょう。

	while (1) {
		/* Wait for one signal, and forward it */
		if (wait_and_forward_signal(&parent_sigset, child_pid)) {
			return 1;
		}

		/* Now, reap zombies */
		if (reap_zombies(child_pid, &child_exitcode)) {
			return 1;
		}

		if (child_exitcode != -1) {
			PRINT_TRACE("Exiting: child has exited");
			return child_exitcode;
		}
	}

まず、tini は信号ハンドラを設定せず、wait_and_forward_signalreap_zombiesの 2 つの関数をループで実行します。


int wait_and_forward_signal(sigset_t const* const parent_sigset_ptr, pid_t const child_pid) {
	siginfo_t sig;

	if (sigtimedwait(parent_sigset_ptr, &sig, &ts) == -1) {
		switch (errno) {
			case EAGAIN:
				break;
			case EINTR:
				break;
			default:
				PRINT_FATAL("Unexpected error in sigtimedwait: '%s'", strerror(errno));
				return 1;
		}
	} else {
		/* There is a signal to handle here */
		switch (sig.si_signo) {
			case SIGCHLD:
				/* Special-cased, as we don't forward SIGCHLD. Instead, we'll
				 * fallthrough to reaping processes.
				 */
				PRINT_DEBUG("Received SIGCHLD");
				break;
			default:
				PRINT_DEBUG("Passing signal: '%s'", strsignal(sig.si_signo));
				/* Forward anything else */
				if (kill(kill_process_group ? -child_pid : child_pid, sig.si_signo)) {
					if (errno == ESRCH) {
						PRINT_WARNING("Child was dead when forwarding signal");
					} else {
						PRINT_FATAL("Unexpected error when forwarding signal: '%s'", strerror(errno));
						return 1;
					}
				}
				break;
		}
	}

	return 0;
}

sigtimedwait関数を使用して信号を受信し、SIGCHLDを転送しないようにフィルタリングします。

int reap_zombies(const pid_t child_pid, int* const child_exitcode_ptr) {
	pid_t current_pid;
	int current_status;

	while (1) {
		current_pid = waitpid(-1, &current_status, WNOHANG);

		switch (current_pid) {

			case -1:
				if (errno == ECHILD) {
					PRINT_TRACE("No child to wait");
					break;
				}
				PRINT_FATAL("Error while waiting for pids: '%s'", strerror(errno));
				return 1;

			case 0:
				PRINT_TRACE("No child to reap");
				break;

			default:
				/* A child was reaped. Check whether it's the main one. If it is, then
				 * set the exit_code, which will cause us to exit once we've reaped everyone else.
				 */
				PRINT_DEBUG("Reaped child with pid: '%i'", current_pid);
				if (current_pid == child_pid) {
					if (WIFEXITED(current_status)) {
						/* Our process exited normally. */
						PRINT_INFO("Main child exited normally (with status '%i')", WEXITSTATUS(current_status));
						*child_exitcode_ptr = WEXITSTATUS(current_status);
					} else if (WIFSIGNALED(current_status)) {
						/* Our process was terminated. Emulate what sh / bash
						 * would do, which is to return 128 + signal number.
						 */
						PRINT_INFO("Main child exited with signal (with signal '%s')", strsignal(WTERMSIG(current_status)));
						*child_exitcode_ptr = 128 + WTERMSIG(current_status);
					} else {
						PRINT_FATAL("Main child exited for unknown reason");
						return 1;
					}

					// Be safe, ensure the status code is indeed between 0 and 255.
					*child_exitcode_ptr = *child_exitcode_ptr % (STATUS_MAX - STATUS_MIN + 1);

					// If this exitcode was remapped, then set it to 0.
					INT32_BITFIELD_CHECK_BOUNDS(expect_status, *child_exitcode_ptr);
					if (INT32_BITFIELD_TEST(expect_status, *child_exitcode_ptr)) {
						*child_exitcode_ptr = 0;
					}
				} else if (warn_on_reap > 0) {
					PRINT_WARNING("Reaped zombie process with pid=%i", current_pid);
				}

				// Check if other childs have been reaped.
				continue;
		}

		/* If we make it here, that's because we did not continue in the switch case. */
		break;
	}

	return 0;
}

次に、reap_zombies関数では、waitpidを使用してプロセスを処理し、子プロセスが待機するか、他のシステムエラーに遭遇した場合はループを終了します。

ここで tini と dumb-init の実装の違いに注意してください。dumb-init は自分の子プロセスを回収した後に自殺しますが、tini はすべての子プロセスが終了した後にループを終了し、自殺するかどうかを判断します。

それではテストしてみましょう。

demo2 の例を使用して、孫プロセスの例をテストします。

FROM python:3.9

ADD demo2.py /root/demo2.py
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini

ENTRYPOINT [ "/tini","-s", "-g", "--"]
CMD ["python", "/root/demo2.py"]

ビルドして実行し、プロセス構造は以下の通りです。

demo2-tini のプロセス構造

次に、いつものように strace を使って、SIGTERM を送信してみましょう。

strace 2160093

strace 2160094

strace 2160095

予想通り、tini の実装には問題がないようです。しかし、次に demo4.py という例を準備します。

import os
import time
import signal
pid = os.fork()
if pid == 0:
    signal.signal(15, lambda _, __: time.sleep(1))
    cpid = os.fork()
time.sleep(1000)

ここでは、time.sleep(1)を使用して、プログラムが SIGTERM を受け取った後に優雅に処理する必要があることをシミュレートします。そして、Dockerfile を準備します。

FROM python:3.9

ADD demo4.py /root/demo4.py
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini

ENTRYPOINT [ "/tini","-s", "-g", "--"]
CMD ["python", "/root/demo4.py"]

ビルドして実行し、プロセス構造を確認します。すぐに終了します。

demo4 プロセス構造

次に、strace を使って SIGTERM を送信します。

strace 2173315

strace 2173316

strace 2173317

すると、2173316 と 2173317 の 2 つのプロセスが、SIGTERM 信号を正常に受け取った後、処理中に SIGKILL されました。これはなぜでしょうか?実際、ここにも潜在的なレースコンディションが存在します。

tini を使用すると、2173315 が終了した後、2173316 が再親化されます。

カーネルの再親化プロセスに従い、2173317 は tini プロセスに再親化されます。

しかし、tini がwaitpidを使用する際、WNOHANGオプションを使用しているため、子プロセスがまだ終了していない場合、すぐに 0 を返します。そのため、ループを終了し、自殺プロセスを開始します。

刺激的ですね。この点について、私の師匠が私に問題を提起しました:tini Exits Too Early Leading to Graceful Termination Failure

私も修正を行いました。具体的には、use new threading to run waitpid(まだ PoC で、単体テストは書いていませんが、処理は少し粗いです)。

実際のところ、考え方は非常にシンプルです。waitpidWNOHANGオプションを使用せず、ブロッキング呼び出しに変更し、新しいスレッドを使用してwaitpidの処理を行います。

テストの結果は以下の通りです。

demo5 プロセス構造

strace 1808102

strace 1808104

strace 1808105

予想通り、テストには問題がありませんでした。

もちろん、ここで注意深い友人は、元の tini もバイナリの更新を処理できないことに気づくかもしれません。その理由は demo5 の理由と一致しています。皆さんもテストしてみてください。

実際、私の処理は非常に粗雑で暴力的です。tini の終了条件を必ず waitpid ()=-1 && errno==ECHILD になるまで待つように変更すれば良いのです。具体的な実装手段については、皆さんで考えてみてください(実際にはいくつかあります)。

最後に、問題の核心をまとめましょう。

dumb-init も tini も、現行の実装では、コンテナという特殊なシナリオにおいて、すべての子孫プロセスの終了を待たずに終了するという同じ誤りを犯しています。実際、解決策は非常にシンプルです。終了条件は必ずwaitpid()=-1 && errno==ECHILDであるべきです。

まとめ#

この記事では、dumb-init と tini について愚痴をこぼしました。dumb-init の実装は確かにひどく、tini の実装ははるかに繊細です。しかし、tini も依然として信頼性のない動作があり、我々が期待するフォークバイナリの更新のような 1 号プロセスを使用するシナリオは、dumb-init と tini の両方で実現できません。また、dumb-init と tini には共通の制限があります。すなわち、子プロセスのプロセスグループの逃避を処理できないことです。(例えば、10 個の子プロセスがそれぞれ異なるプロセスグループに逃げる場合)。

さらに、この記事のテストでは、time.sleep(1)を使用して Graceful Shutdown の動作をシミュレートしましたが、tini もすでに要求を満たせなくなっています。。だから。。

結局のところ、アプリケーションの信号やプロセス回収といった基本的な動作は、アプリケーション自身が管理すべきです。何でもかんでも 1 号プロセスに寄託するのは、運用に対する無責任です。(もし本当に 1 号プロセスが必要なら、tini を使うべきです。dumb-init は絶対に使わないでください)

したがって、exec 裸起き大法は良い、1 号プロセスを使わずに平安を保ちましょう!

水文はこれで終わりです。この水文は問題を提起し、結論を検証し、パッチ PoC を作成するのに、私のほぼ 1 週間の余暇の時間を費やしました(この記事の初稿は午前 4 時過ぎに書き終えました)。最後に、某川さんに感謝し、一緒に深夜 3 時過ぎまで頑張ったことに感謝します。最後に、皆さんが楽しんで読んでいただけることを願っています。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。