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 ns 内の 1 号プロセスが終了すると、カーネルはその pid ns 内の残りのプロセスに SIGKILL を送ります。

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

私たちのテストサービス、Spring Cloud のもので、オフライン後、ノードがレジストリから削除できず、理由がわからず、最終的に問題を調査しました。
本質的にはこうです。POD が削除されると、K8S スケジューラは POD の ENTRYPOINT に SIGTERM 信号を送信し、30 秒(デフォルトのグレースフルシャットダウンのタイムアウト)待機し、応答がなければ SIGKILL で直接終了します。
問題は、私たちの Eureka 版のサービスが start.sh を介して起動されており、ENTRYPOINT ["/home/admin/start.sh"]、コンテナ内のデフォルトは /bin/sh で fork/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 is sent to every processです。理解しましたか?これは O (N) の探索です。問題はありません。さて、ここでの dumb-init の実装にはレースコンディションが存在します。

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

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;
}

tinisigtimedwait関数を使用して信号を受信し、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 を使い、kill で 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 を返します。これによりループが終了し、自殺プロセスが開始されます。

刺激的ですね。この点について、私の師匠は私に次のような issue を提起しました:tini Exits Too Early Leading to Graceful Termination Failure

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

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

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

demo5 のプロセス構造

strace 1808102

strace 1808104

strace 1808105

うん、予想通り、テストは問題ありません。

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

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

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

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

まとめ#

この記事では、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 時を一緒に過ごしました。最後に、皆さんが楽しんで読んでいただけることを願っています。

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