kubernetes pod中的pause容器解析

The Almighty Pause Container

前言

当您在Kubernetes集群的节点上执行,docker ps时,您会发现一些名为pause的容器正在运行:

$ docker ps
CONTAINER ID        IMAGE                           COMMAND ...
...
3b45e983c859        gcr.io/google_containers/pause-amd64:3.0    "/pause" ...
...
dbfc35b00062        gcr.io/google_containers/pause-amd64:3.0    "/pause" ...
...
c4e998ec4d5d        gcr.io/google_containers/pause-amd64:3.0    "/pause" ...
...
508102acf1e7        gcr.io/google_containers/pause-amd64:3.0    "/pause" ...

您可能很好奇:这些pause容器是什么? 为什么会有这么多这样的容器?

为了回答这些问题,您需要了解Kubernetes中的pod是如何实现的(尤其是基于常用的容器运行时:Docker/containerd),
如果您对pod的实现原理不是很了解,请参考What are Kubernetes Pods Anyway?

我们都知道利用Docker启动运行单一进程的容器很简单, 然而当您想要同时运行多个软件组件时,这个模型可能会变得有点麻烦。

当开发人员创建Docker映像时,您经常会看到这种情况,这些映像使用entrypoint作为入口点来启动和管理多个进程。

对于生产系统,许多人发现将这些应用程序部署在部分隔离且部分共享环境的容器组中更为有用。

Kubernetes针对上述场景的需求,提供了一个称为pods的抽象。它屏蔽了Docker标志的复杂性(如启动个容器可能需要传递多个标识: docker run -itd --name ddd -v /etc/hosts:/etc/hosts nginx),以及管理容器、共享卷等操作。它还隐藏了容器运行时之间的差异,例如,rkt原生支持pod,因此Kubernetes要做的工作较少,但作为Kubernetes的用户,您不必担心这一点。

事实上,Docker原生就具备控制容器组之间的共享级别的能力——通过创建一个父容器,例如:

  1. 创建容器A,作为父容器,容器ID假设为A-ID
  2. 创建容器B,作为子容器,容器ID假设为B-ID,启动时指定PID命名空间标识为--pid=container:A-ID
  3. 创建容器C,作为子容器,容器ID假设为C-ID,启动时指定PID命名空间标识为--pid=container:A-ID

此时,容器B与容器C共享相同的PID命名空间(即容器APID命名空间)

通过上面的例子我们发现,使用原生的容器运行时实现起来比较繁琐,因为首先您得了解创建流程、所使用的标识,并管理这些容器的生命周期。

而在Kubernetes中,pause容器作为您pod中所有容器的父容器
pause容器有两个核心职责:

  • 首先,它作为在pod中共享Linux名称空间的基础容器。
  • 其次,启用PID(进程ID)名称空间共享后,它将作为每个podPID 1进程(根进程),并回收僵尸进程。

接下来我们针对pause容器的职责逐一解析

关于共享命名空间

Linux中,当运行一个新进程时,该进程从父进程继承其名称空间。
在新的命名空间中运行进程的方法是通过与父进程取消共享命名空间,从而创建一个新的命名空间。

下面是使用unshare工具在新的PID、UTS、IPCmount名称空间中运行shell的示例。

$ sudo unshare --pid --uts --ipc --mount -f chroot rootfs /bin/sh

一旦进程运行,您可以将其他进程添加到进程的名称空间中,以形成一个pod。可以使用setns系统调用将新的进程添加到现有的命名空间中。

pod的容器之间共享名称空间也是基于这个原理实现的。

Docker的实现则是将这个过程自动化一些,所以让我们看一个例子,看看如何通过使用pause容器和共享名称空间从头创建一个pod

  1. 首先,我们需要使用Docker启动pause容器,并作端口映射,以便我们可以将容器添加到pod中。
$ docker run -d --name pause -p 8080:80 gcr.io/google_containers/pause-amd64:3.0
  1. 接下来为我们的pod容器,首先我们运行一个nginx容器,调整nginx的代理配置:监听80请求,并将请求转发至本地2368端口。
$ cat <> nginx.conf
> error_log stderr;
> events { worker_connections  1024; }
> http {
>     access_log /dev/stdout combined;
>     server {
>         listen 80 default_server;
>         server_name example.com www.example.com;
>         location / {
>             proxy_pass http://127.0.0.1:2368;
>         }
>     }
> }
> EOF
$ docker run -d --name nginx -v `pwd`/nginx.conf:/etc/nginx/nginx.conf --net=container:pause --ipc=container:pause --pid=container:pause nginx
  1. 接下来创建一个ghost博客应用容器,作为服务端,端口监听为2368
$ docker run -d --name ghost --net=container:pause --ipc=container:pause --pid=container:pause ghost

通过上面的操作,我们将pause容器NetworkPIDIPC命名空间共享给nginxghost容器,即三个容器共享相同NetworkPIDIPC命名空间。
此时,当您访问http://localhost:8080/,实际被代理至ghost服务,流程如下:

a. 容器宿主机访问http://localhost:8080/
b. 请求被转发至pause容器80端口,即nginx容器80端口
c. nginx将请求转发至本地2368端口,即ghost容器2368端口

pause_container.png

显然,原生实现的流程还是比较复杂的(这还没有包括监控、管理这些容器生命周期)

关于回收僵尸进程

Linux中,PID命名空间中的进程是一个树型结构,每个进程有一个父进程。在树的根上只有一个进程没有真正的父进程。这是init进程,其PID1

进程可以使用forkexec系统调用来启动其他进程,此时新进程的父进程就是调用fork syscal的进程。

其中fork用于启动正在运行的进程的另一个副本,exec用于用一个新进程替换当前进程,保持相同的PID

为了运行一个完全独立的应用程序,您需要运行forkexec系统调用。一个进程使用fork用一个新的PID创建一个自己的新副本作为子进程,然后当子进程运行时,它检查它是否是子进程,并运行exec来用您真正想运行的进程替换它自己。

大多数语言都通过一个函数来实现这一点。

每个进程在系统进程表中都有一个条目,它记录进程状态和退出代码的信息。
当子进程完成运行后,它的进程表条目将一直保持到父进程使用wait系统调用检索其退出代码为止。这被称为回收僵尸进程。

什么是僵尸进程?

僵尸进程是指已经停止运行但它们的进程表条目仍然存在的进程,因为父进程没有通过wait系统调用检索它。
从技术上讲,每个终止的进程在很短的一段时间内都是僵尸,但它们可以存活更长时间。

UNIX系统中,一个子进程结束了,但是它的父进程没有等待(调用wait/waitpid)它, 那么它将变成一个僵尸进程.

孤儿进程 & 僵尸进程

  • 孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所接管,并由init进程对它们完成状态收集工作。

  • 僵尸进程:一个进程使用fork系统调用创建子进程,如果子进程退出,而父进程并没有调用waitwaitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。

僵尸进程是怎么产生的?

出现僵尸进程的一种情况是:

父进程编写得很糟糕,省略了wait调用,或者父进程在子进程之前死亡,而新的父进程没有调用wait

当一个进程的父进程在子进程之前死亡时,操作系统将该子进程分配给init进程或PID 1的进程。即init进程接纳子进程并成为其父进程。这意味着,现在当子进程退出时,新的父进程(init)必须调用wait来获取它的退出码,否则它的进程表条目将永远保留下来,成为僵死进程。

在容器中,应用运行的进程必须是init进程。在Docker中,每个容器通常都有自己的PID命名空间,ENTRYPOINT进程是init进程。当A容器在B容器的名称空间中运行时,B容器必须承担init进程的角色,而其A容器作为init进程的子进程添加到命名空间中。

$ docker run -d --name nginx -v `pwd`/nginx.conf:/etc/nginx/nginx.conf -p 8080:80 nginx
$ docker run -d --name ghost --net=container:nginx --ipc=container:nginx --pid=container:nginx ghost

在这个例子中,nginx的角色是PID 1, ghost被添加为nginx的子进程。

ghost自身分叉或使用exec运行子进程,并且ghost进程在ghost子进程完成之前崩溃,那么这些ghost孤儿子进程将被nginx进程接管。当这些孤儿进程完成退出时,它一直等待父进程(nginx进程)使用wait系统调用检索其退出代码。不幸的是nginx并没有被设计成能够作为一个init进程来运行并回收僵尸。

当我们存在很多这种容器组时,将可能导致很多容器内的僵尸进程无法回收。

僵尸进程的危害

僵尸进程会占用进程号,以及未回收的文件描述符占用空间,如果产生大量的僵尸进程,将会导致系统无法分配进程号

pod实现

Kubernetes pod中,容器的运行方式与上述基本相同,但是为每个pod创建了一个特殊的pause容器。

这个pause容器运行了一个非常简单的进程,它不执行任何函数,本质上永远休眠(参见下面的pause()调用)。

其源码实现:

/*
Copyright 2016 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
    http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

#include 
#include 
#include 
#include 
#include 
#include 

static void sigdown(int signo) {
  psignal(signo, "Shutting down, got signal");
  exit(0);
}

static void sigreap(int signo) {
  while (waitpid(-1, NULL, WNOHANG) > 0);
}

int main() {
  if (getpid() != 1)
    /* Not an error because pause sees use outside of infra containers. */
    fprintf(stderr, "Warning: pause should be the first process\n");

  if (sigaction(SIGINT, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0)
    return 1;
  if (sigaction(SIGTERM, &(struct sigaction){.sa_handler = sigdown}, NULL) < 0)
    return 2;
  if (sigaction(SIGCHLD, &(struct sigaction){.sa_handler = sigreap,
                                             .sa_flags = SA_NOCLDSTOP},
                NULL) < 0)
    return 3;

  for (;;)
    pause();
  fprintf(stderr, "Error: infinite loop terminated\n");
  return 42;
}

如你所见,它不仅仅处于休眠状态。它还有另外一个重要的功能。

从上述代码种我们发现,pause容器不仅仅调用pause()使进程休眠,还拥有另外一个重要的功能:

它假定自己为PID 1的角色,当僵尸进程被其父进程孤立时,通过调用wait来获取僵尸进程(见sigreap)。 这样一来就不会在Kubernetes podPID命名空间中堆积僵尸进程了。

关于进程命名空间共享说明

默认情况下,kubernetes同一pod内的容器不共享进程命名空间,需要指定配置。这意味着默认情况下,各个容器需要自己管理僵尸进程。

你可能感兴趣的:(kubernetes pod中的pause容器解析)