Manjusaka

Manjusaka

簡單聊聊容器中的一號進程

新年了,決定趁著有時間的時候多寫幾篇技術水文。今天的話,準備來簡單聊聊容器中我們每天都會接觸,但是時常又會被我們忽略的一號進程

正文#

容器技術發展到現在,其實形態上已經發生了很大的變化。根據不同的場景,既有傳統的 Docker1, containterd2 這樣傳統基於 CGroup + Namespace 的容器形態,也有像 Kata3 這樣基於 VM 的新型的容器形態。本文主要著眼在傳統容器中一號進程上。

我們都知道,傳統容器依賴的 CGroup + Namespace 進行資源隔離,本質上來說,還是 OS 內的一個進程。所以在繼續往下聊容器相關的內容之前,我們需要先來簡單聊聊 Linux 中的進程管理

Linux 中的進程管理#

簡單聊聊進程#

Linux 中的進程實際上是個非常大的話題,如果要展開聊,實際上這個話題可以聊一整本書 = =,所以為了時間著想,我們還是把目光聚集在最核心的一部分上面(實際上是因為很多東西我也不懂)。

首先來講,在內核中利用一個特殊的結構體來維護進程有關的相關信息,比如常見的 PID,進程狀態,打開的文件描述符等信息。在內核代碼中,這個結構體是 task_struct4, 其大概結構大家可以看一下下圖

task_struct

而通常而言,我們會在系統上跑很多個進程。所以內核用一個進程表 (實際上 Linux 中管理進程表的有多個數據結構,這裡我們用 PID Hash Map 來舉例)來維護所有 Process Descriptor 相關的信息,詳見下圖

PID Hash Table

OK,這裡我們大概了解了進程中的基本結構,現在我們來看我們常見使用進程的一個場景:父子進程。我們都知道,我們有時會在一個進程中,通過 fork5 這個 sys call 來創建出一個新的進程。通常來說,我們創建的新的進程是當前進程的子進程。那么在內核中怎麼表達這種父子關係呢?

回到剛剛提到 task_struct, 在這個結構體中存在這樣幾個字段來描述父子關係

  1. real_parent:一個 task_struct 指針,指向父進程

  2. parent: 一個 task_struct 指針,指向父進程。在大多數情況下,這個字段的值和 real_parent 一致。在有進程對當前進程使用 ptrace6 等情況的時候,和 real_parent 字段不一致

  3. children:list_head, 其指向一個由當前進程所創建的所有子進程的雙向鏈表

這裡大家可能還有點抽象的話,給大家一個圖就能看清楚了

Relation Between Process

實際上,我們發現,不同進程之間的父子關係,反應到具體的數據結構之上,就形成了一個完整的樹形結構(先記住這點,我們稍後會再提到這裡)

到現在為止,我們已經對 Linux 中的進程,有了最簡單一個概念,那麼接下來,我們會聊聊我們在進程使用中常遇到的兩個問題:孤兒進程 && 殭屍進程

孤兒進程 && 殭屍進程#

首先來聊聊 殭屍進程 這個概念。

如前面所說,我們內核有進程表來維護 Process Descriptor 相關信息。那么在 Linux 的設計中,當一個子進程退出後,將保存自己的進程相關的狀態以供父進程使用。而父進程將調用 waitpid7 來獲取子進程狀態,並清理相關資源。

那麼如上所說,父進程是有可能需要拿到子進程相關的狀態的。那么也就導致為了滿足這一設計,內核中的進程表將一直保存相關資源。當殭屍進程多了以後,那麼將造成很大的資源浪費。

首先來看一個簡單的殭屍進程的例子

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main() {
  int pid;
  if ((pid = fork()) == 0) {
    printf("Here's child process\n");
  } else {
    printf("the child process pid is %d\n", pid);
    sleep(20);
  }
  return 0;
}

然後我們編譯執行這段代碼,然後配合 ps 命令查看一下,發現我們的確造了一個 z 進程

Z Process Demo

OK 我們再來看一個正確處理子進程退出的代碼

#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/signalfd.h>
#include <sys/wait.h>

#define MAXEVENTS 64
void deletejob(pid_t pid) { printf("delete task %d\n", pid); }

void addjob(pid_t pid) { printf("add task %d\n", pid); }

int main(int argc, char **argv) {
  int pid;
  struct epoll_event event;
  struct epoll_event *events;
  sigset_t mask;
  sigemptyset(&mask);
  sigaddset(&mask, SIGCHLD);
  if (sigprocmask(SIG_SETMASK, &mask, NULL) < 0) {
    perror("sigprocmask");
    return 1;
  }
  int sfd = signalfd(-1, &mask, 0);
  int epoll_fd = epoll_create(MAXEVENTS);
  event.events = EPOLLIN | EPOLLEXCLUSIVE | EPOLLET;
  event.data.fd = sfd;
  int s = epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sfd, &event);
  if (s == -1) {
    abort();
  }
  events = calloc(MAXEVENTS, sizeof(event));
  while (1) {
    int n = epoll_wait(epoll_fd, events, MAXEVENTS, 1);
    if (n == -1) {
      if (errno == EINTR) {
        fprintf(stderr, "epoll EINTR error\n");
      } else if (errno == EINVAL) {
        fprintf(stderr, "epoll EINVAL error\n");
      } else if (errno == EFAULT) {
        fprintf(stderr, "epoll EFAULT error\n");
        exit(-1);
      } else if (errno == EBADF) {
        fprintf(stderr, "epoll EBADF error\n");
        exit(-1);
      }
    }
    printf("%d\n", n);
    for (int i = 0; i < n; i++) {
      if ((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) ||
          (!(events[i].events & EPOLLIN))) {
        printf("%d\n", i);
        fprintf(stderr, "epoll err\n");
        close(events[i].data.fd);
        continue;
      } else if (sfd == events[i].data.fd) {
        struct signalfd_siginfo si;
        ssize_t res = read(sfd, &si, sizeof(si));
        if (res < 0) {
          fprintf(stderr, "read error\n");
          continue;
        }
        if (res != sizeof(si)) {
          fprintf(stderr, "Something wrong\n");
          continue;
        }
        if (si.ssi_signo == SIGCHLD) {
          printf("Got SIGCHLD\n");
          int child_pid = waitpid(-1, NULL, 0);
          deletejob(child_pid);
        }
      }
    }
    if ((pid = fork()) == 0) {
      execve("/bin/date", argv, NULL);
    }
    addjob(pid);
  }
}

OK, 我們現在都知道了,子進程退出後需要由父進程正確的回收相關的資源。那么問題來了,我們父進程先於子進程退出了怎麼辦。實際上這是一個很常見的場景。比如說大家去用兩次 fork 實現守護進程。

我們常規的認知來說,我們父進程退出後,這個進程所屬的所有子進程會進行 re-parent 到當前 PID Namespace 的一號進程上,那麼這樣的答案是正確的麼?對,也不對,我們首先來看一個例子

#include <stdio.h>
#include <sys/prctl.h>
#include <sys/types.h>
#include <unistd.h>

int main() {
  int pid;
  int err = prctl(PR_SET_CHILD_SUBREAPER, 1);
  if (err != 0) {
    return 0;
  }
  if ((pid = fork()) == 0) {
    if ((pid = fork()) == 0) {
      printf("Here's child process1\n");
      sleep(20);
    } else {
      printf("the child process pid is %d\n", pid);
    }
  } else {
    sleep(40);
  }
  return 0;
}

這是一個很典型的兩次 fork 創建守護進程的代碼(除了我沒寫 SIGCHLD 處理(逃)。我們來看下這段代碼的輸出

Daemon Process Output1

我們能看到守護進程的 PID 是 449920

然後我們執行 ps -efjps auf 兩個命令看一下結果

Daemon Process Output2

我們能看到,449920 這個進程在父進程退出後沒有 re-parent 到當前空間的一號進程上。這是為什麼呢?可能眼尖的同學已經注意到這段代碼中一個特殊的 sys call prctl8。我們給當前進程設置了 PR_SET_CHILD_SUBREAPER 的屬性。

這裡我們來看一下內核裡的實現

/*
 * When we die, we re-parent all our children, and try to:
 * 1. give them to another thread in our thread group, if such a member exists
 * 2. give it to the first ancestor process which prctl'd itself as a
 *    child_subreaper for its children (like a service manager)
 * 3. give it to the init process (PID 1) in our pid namespace
 */
static struct task_struct *find_new_reaper(struct task_struct *father,
					   struct task_struct *child_reaper)
{
	struct task_struct *thread, *reaper;

	thread = find_alive_thread(father);
	if (thread)
		return thread;

	if (father->signal->has_child_subreaper) {
		unsigned int ns_level = task_pid(father)->level;
		/*
		 * Find the first ->is_child_subreaper ancestor in our pid_ns.
		 * We can't check reaper != child_reaper to ensure we do not
		 * cross the namespaces, the exiting parent could be injected
		 * by setns() + fork().
		 * We check pid->level, this is slightly more efficient than
		 * task_active_pid_ns(reaper) != task_active_pid_ns(father).
		 */
		for (reaper = father->real_parent;
		     task_pid(reaper)->level == ns_level;
		     reaper = reaper->real_parent) {
			if (reaper == &init_task)
				break;
			if (!reaper->signal->is_child_subreaper)
				continue;
			thread = find_alive_thread(reaper);
			if (thread)
				return thread;
		}
	}

	return child_reaper;
}

這裡我們總結一下,當父進程退出後,所屬的子進程,將按照如下順序進行 re-parent

  1. 線程組裡其餘可用線程(這裡的線程有所不一樣,可以暫時忽略)

  2. 在當前所屬的進程樹上不斷尋找設置了 PR_SET_CHILD_SUBREAPER 進程

  3. 在前面兩者都無效的情況下,re-parent 到當前 PID Namespace 中的 1 號進程上

到這裡,我們關於 Linux 中進程管理的基礎介紹就完成了。那麼我們將來聊聊容器中的情況

容器中的一號進程#

這裡,我們將利用,Docker 作為背景聊聊這個話題。首先,在 Docker 1.11 之後,其架構發生了比較大的變化,如下圖所示

Docker Arch since version 1.11

那麼我們拉起一個容器的的流程如下

  1. Docker Daemon 向 containerd 發送指令

  2. containerd 創建一個 containterd-shim 進程

  3. containerd-shim 創建一個 runc 進程

  4. runc 進程將根據 OCI 標準,設置相關環境(創建 cgroup,創建 ns 等),然後執行 entrypoint 中的設定的命令

  5. runc 在執行完相關設置後,將自我退出,此時其子進程(即容器命名空間內的一號進程)將被 re-parent 給 containerd-shim 進程。

OK,上面 step 5 操作,就需要依賴我們上節中講到的 prctlPR_SET_CHILD_SUBREAPER

自此,containerd-shim 將承擔容器內進程相關的操作,即便其父進程退出,子進程也會根據 re-parent 的流程托管到 containerd-shim 進程上。

那麼,這樣是不是就沒有問題了呢?

答案很明顯不是。來給大家舉一個實際上的場景:假設我一個服務需要實現一個需求叫做優雅下線。通常而言,我們會在暴力殺死進程之前,利用 SIGTERM 信號實現這個功能。但是在容器時期有個問題,我們一號進程,可能不是程序本身(比如大家習慣性的會考慮在 entrypoint 中用 bash 去裹一層),或者經過一些特殊場景,容器中的進程,全部已經托管在 containerd-shim 上了。而 contaninerd-shim 是不具備信號轉發的能力的。

所以在這樣一些場景下,我們就需要考慮額外引入一些組件來完成我們的需求。這裡以一個非常輕量級的專門針對容器的設計的一號進程項目 tini9 來作為介紹

我們這裡看一下核心的一些代碼

int register_subreaper () {
	if (subreaper > 0) {
		if (prctl(PR_SET_CHILD_SUBREAPER, 1)) {
			if (errno == EINVAL) {
				PRINT_FATAL("PR_SET_CHILD_SUBREAPER is unavailable on this platform. Are you using Linux >= 3.4?")
			} else {
				PRINT_FATAL("Failed to register as child subreaper: %s", strerror(errno))
			}
			return 1;
		} else {
			PRINT_TRACE("Registered as child subreaper");
		}
	}
	return 0;
}

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

這裡我們能很清楚看到兩個核心點

  1. tini 會通過 prctlPR_SET_CHILD_SUBREAPER 來接管容器內的孤兒進程

  2. tini 在收到信號後,會將信號轉發給子進程或者是所屬的子進程組

當然其實 tini 本身也有一些小問題(不過比較冷門)這裡留一個討論題:假設我們有這樣一個服務,在創建 10 個守護進程後自己退出。在這十個守護進程中,我們都會設置一個全新的進程組 ID (所謂進程組逃逸)。那麼我們怎麼樣將信號轉發到這十個進程上(僅供討論,生產上這麼幹的人早被打死了)

總結#

可能看到這裡,可能有人要噴我不講武德,說好的容器內一號進程,但是花了大半篇幅來講 Linux 進程 233333.

實際上傳統容器基本可以認為是在 OS 中執行的一個完整進程。討論容器中的一號進程離不開討論 Linux 中進程管理的相關知識點。

希望通過這篇技術水文能幫大家對容器中一號進程有個大概的認知,並能正確的使用和管理他。

最後祝大家新年快樂!(希望新年我能不以寫水文為生,呜呜呜呜)

Reference#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。