上一篇文章介绍了单机容器网络的基础知识,本篇开始介绍K8s的网络。本文假定你已经对K8s的各种基本概念(比如Node、Pod、Service等)非常了解,如果还不了解,可以参考我之前的博客:Kubernetes架构及资源关系简单总结

K8s的网络主要需要解决4个问题:

  1. 高度耦合的容器间的通信问题(本文)
  2. Pod与Pod之间的通信
  3. Pod和Service之间的通信
  4. 外部系统和Service之间的通信

本文介绍第一个问题:高度耦合的容器间的通信问题。什么是高度耦合?举个例子,比如我使用的博客系统typecho就是由nginx+php+mysql组成。如果要容器化部署的话,那就需要3个容器:nginx、php、mysql。这3个容器单独都无法提供博客功能,只有组合到一起时才可以成为一个完整可用的对外系统,这种就是高度耦合的容器。如果你是使用docker去部署这么一个博客系统,那就需要自己去配置这3个容器之间的网络通信。而K8s在设计之初就考虑到了这个问题,提出了Pod的概念。简单理解,一个Pod有点类似于传统的虚机、物理机,不过非常轻量化,Pod也是K8s里面最小的部署单元。对Pod还不熟悉的朋友可以参考官方文档:Pods,本文就不介绍Pod的细节了,只介绍Pod的网络部分。从最佳实践的角度讲,高度耦合的容器一般部署在一个Pod里面,这样他们就类似于部署在同一个虚机或者物理机上面,可以直接通过本地回环地址(127.0.0.1)进行通信。那这个是如何实现的呢?

秘密就在Pause容器。K8s的pause容器有两大作用:

  1. 它作为Pod内的“系统容器”,提供一个基础的Linux Namespace,以供其它用户容器加入。
  2. 如果开启了PID Namespace共享的话,它还作为Pod的“init进程”(pid=1,也即所有其它容器的父容器,所有其它进程的父进程),负责僵尸进程的回收。

下面分别介绍。

Pause容器的代码

这里先贴一下pause容器的代码,方便后面分析。Github: pause.c:

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

#define STRINGIFY(x) #x
#define VERSION_STRING(x) STRINGIFY(x)

#ifndef VERSION
#define VERSION HEAD
#endif

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(int argc, char **argv) {
  int i;
  for (i = 1; i < argc; ++i) {
    if (!strcasecmp(argv[i], "-v")) {
      printf("pause.c %s\n", VERSION_STRING(VERSION));
      return 0;
    }
  }

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

这段C代码去除注释和空行,还不到50行,功能也非常简单,但其作用却非常大,真的很神奇。

功能1:共享Namespace

在Linux系统中,当我们创建一个新的子进程的时候,该进程就会继承父进程的Namespace,K8s的Pod就是模拟这种方式。创建Pod时,里面第一个被创建的容器永远都是pause容器(只不过是系统自动创建的),pause容器创建好之后,才会创建用户容器。系统(CRI和CNI)会为pause容器创建好Namespace,后面的用户容器都加入pause的Namespace,这样大家就处于同一个Namespace了,类似于大家都在一个“虚拟机”里面部署。对于网络而言,就是共享Network Namespace了。下面用docker模拟一下k8s的Pod过程。

第一步:模拟创建Pod的“系统容器”pause容器

➜ ~ docker run -d --name pause -p 8080:80  --ipc=shareable  registry.aliyuncs.com/k8sxio/pause:3.2
184cfffc577e032902a293bf1848f6353bd8a335f57d6b20625b96ebca8dfaa0
➜  nginx docker ps -l
CONTAINER ID        IMAGE                                    COMMAND             CREATED             STATUS              PORTS                  NAMES
184cfffc577e        registry.aliyuncs.com/k8sxio/pause:3.2   "/pause"            13 seconds ago      Up 11 seconds       0.0.0.0:8080->80/tcp   pause

这里的操作就是创建一个docker容器,并将容器内的80端口映射到外部的8080端口。需要注意的是为了让其他容器可以共享该容器的IPC Namespace,需要增加--ipc=shareable

第二步:创建“用户容器” nginx。nginx默认是监听80端口的,先创建一个配置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;
         }
     }
}

配置非常简单,将所有请求转发到127.0.0.1:2368。然后启动nginx容器,不过该容器共享pause容器的Namespace,而不是像docker默认行为那样创建自己的Namespace:

➜ ~ docker run -d --name nginx -v `pwd`/nginx.conf:/etc/nginx/nginx.conf --net=container:pause --ipc=container:pause --pid=container:pause nginx
660af561bc2d5936b3da727067ac30663780c5f7f73efe288b5838a5044c1331
➜  nginx docker ps -l
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES
660af561bc2d        nginx               "/docker-entrypoint.…"   4 seconds ago       Up 3 seconds                            nginx

此时,curl一下本机的8080(也就是pause容器的80)端口,就可以看到以下信息:

➜  ~ curl 127.0.0.1:8080
<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.19.6</center>
</body>
</html>

也就是说pause容器的80端口上面是nginx服务,这是因为它们都处于一个Network Namespace。这里报502是因为我们再nginx配置里面配了所有请求都转发到2368端口,但这个端口没有服务监听。

第三步:再创建一个“用户容器” ghost(一个博客系统,默认监听2368端口)

➜  ~ docker run -d --name ghost --net=container:nginx --ipc=container:nginx --pid=container:nginx ghost
b455cb94dcec8a1896e55fc3ee9ac1133ba9f44440311a986a036efe09eb9227
➜  ~ docker ps -l
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES
b455cb94dcec        ghost               "docker-entrypoint.s…"   4 seconds ago       Up 3 seconds                            ghost

容器启动后,我们直接在浏览器访问本机的8080端口,就可以看到ghost的web页面了:

ghost

此时,高度耦合的两个容器nginx+ghost就在一个Namespace里面了,特别是处于同一个Network Namespace(可以通过127.0.0.1相互访问),让他们的通信变得极其方便。如果再加上pause容器,那他们3个就是k8s里面的一个Pod了。其关系如下图(图片来自第一篇引用的文章):

pod

这整个过程便是我们在k8s中部署容器时Pod里面发生的过程。

最后再回头看一下pause.c,我们发现该代码最后是一个死循环+pause调用,pause函数的作用是让当前进程暂停,进入睡眠状态,直到被信号中断。先不考虑信号中断的话(下面介绍),这个程序几乎就是无限睡眠。这样设计的原因是在Linux里面,要维持Namespace的存在,就必须有一个属于该Namespace的进程或者文件等对象存在(虽然有手动删除Namespace的命令,但如果有对象存在,删除其实是个假删除,真正删除发生在最后一个对象消亡时)。所以为了维护Pod里面pause创建的Namespace,pause就必须一直存在。

好,最后总结一下,当我们在k8s里面创建容器时,CRI(容器运行时,比如docker)和CNI(容器网络接口,负责容器网络)首先创建好pause容器这个系统容器,这就包含创建并初始化各种Namespace(Network Namespace部分由CNI负责)。之后再创建用户容器,用户容器加入pause的Namespace。其中Network Namespace是默认就共享的,PID Namespace则需要看配置。

功能2:充当系统init进程

在Linux系统中,pid=1的进程我们称之为“init”进程,是内核启动的第一个用户级进程,现在比较新的Linux发行版的init进程就是systemd进程。这个进程有许多工作,其中一个重要工作就是负责“收养孤儿进程”,防止产生太多僵尸进程。简单介绍一下相关的基本概念:Linux系统维护了一个进程表,记录每个进程的状态信息和退出码(exit code),当一个进程退出时,它在表中的信息会一直保留,直到其父进程调用wait(包括waitpid)获取其退出码。所谓僵尸进程就是进程已经退出了,但它的信息还在进程表里面。正常情况下,进程退出时父进程会马上查询该表,并回收子进程的相关资源,所以僵尸进程的持续状态一般都很短。但如果(1)父进程启动子进程之后没有调用wait或者(2)父进程先于子进程挂掉了,那子进程就会变成僵尸进程。如果是第2种情况,即父进程先于子进程死掉了,那操作系统就会把init进程设置为该父进程所有子进程的父进程,即init进程收养了该父进程的所有子进程。当这些子进程退出时,init进程就会充当父进程的角色,从而避免长时间的僵尸进程。但对于第1种情况,一般认为是代码有缺陷,这种情况因为子进程的父进程存在(只是没有调用wait而已),init进程是不会做处理的。此时子进程会成为僵尸进程长期存在,如果要消除这种僵尸进程,只能kill掉父进程。

而pause容器的第二个功能就是充当这个init进程,负责回收僵尸进程。从上面的代码可以看到,pause启动的时候会判断自己的pid是否为1。不过如果要实现该功能,则Pod内的所有容器必须和pause共享PID Namespace。在K8s 1.8版本之前默认是开启PID Namespace共享的,之后版本默认关闭了,用户可以通过--docker-disable-shared-pid=true/false自行设置。开启PID Namespace的好处就是可以享受pause回收僵尸进程的功能,并且因为容器同处于一个PID Namespace,进程间通信也会变得非常方便。但也有一些弊端,比如有些容器进程的PID也必须为1(比如systemd进程),这就会和pause容器产生冲突,另外也涉及一些安全问题。这里只讨论回收僵尸进程的这个功能。为了更确切的观察,我们再启动一个busybox容器,并加入到之前pause容器的Namespace里面:

➜  ~ docker run -idt --name busybox  --net=container:pause --ipc=container:pause --pid=container:pause busybox:1.28
5b6a92bab7c0861b4bebc96115c43ae502b38f788a9b40a9d6c5f8bc77f8fc2d

➜  ~ docker exec -it busybox sh
/ # ps -ef
PID   USER     TIME  COMMAND
    1 root      0:00 /pause
    7 root      0:00 nginx: master process nginx -g daemon off;
   35 101       0:00 nginx: worker process
   46 root      0:00 sh
   59 1000      0:23 node current/index.js
  127 root      0:00 sh
  133 root      0:00 ps -ef

可以看到,pause容器是系统内的“init进程”(pid=1)。然后看一下上面pause.c,里面有对SIGCHLD信号的处理,该信号是子进程退出时发给父进程的。注册的信号处理函数sigreap里面调用了waitpid来等待所有的子进程(第一个参数为-1),这样就可以实现僵尸进程的回收了。那能否用其它进程作为init进程呢?比如不使用特殊的pause容器,而是直接创建一个nginx容器,然后ghost加入nginx进程?一般是不行的,因为普通的进程里面不一定有wait所有子进程的操作。不过在容器化里面,僵尸进程的问题已经不是很严重了,因为最佳实践都是一个容器里面一个进程,当进程挂掉的时候,容器也就销毁了。可能在传统的docker里面,有时为了方便高耦合的几个应用通信,会在一个容器里面启动多个进程,但在k8s里面基本不会有这种场景,因为有Pod这种设计的存在(这真是一个过分优秀的设计)。

总结

K8s通过Pod这个优秀的设计解决了高度耦合容器之间的通信,其机制也非常简单:就是增加了一个非常简单但却很精妙的pause容器,该容器由系统创建维护,对用户透明,它负责创建好Linux Namespace,用户容器直接加入即可。同时,该容器还有回收僵尸进程的作用。不得不说,Kubernetes的很多设计真的非常符合Unix/Linux设计哲学:小而简单(small, simple)!

Reference: