容器中程序的信号捕捉

一、问题描述

项目中使用了 argo 在 kubernetes 集群中做工作流的调度。argo 提供了工作流的停止功能,其原理大致是检查正在运行的 Pod,向该 Pod 中的 wait 容器发送 USR2 信号,wait 容器收到 USR2 信号后,在主机上的调用 docker kill --signal TERM main_container_id 来停止我们的程序容器, 如果 10s 后容器还未停止,则发送 SIGKILL 来强制终止。但是我在实现 argo 工作流中调度 tfjob 时出现了一些问题。

argo_scheduler_tfjob

在argo停止工作流时,正在运行的 step2 中的 manager 监听了 TERM 信号,以便在工作流停止时同步停止 tfjob。但是事实情况却是 manager 退出了,但是没有收到任何的 TERM 信号。

二、问题剖析

检查这个问题的第一步是弄清楚 docker kill 背后发生了什么,官网的资料中有以下的描述:

Note: ENTRYPOINT and CMD in the shell form run as a subcommand of /bin/sh -c, which does not pass signals. This means that the executable is not the container’s PID 1 and does not receive Unix signals.

当我们用 sh 执行一段 shell script 时,在 shell script 中的可执行文件的 PID 不是1,并且 sh 也不会帮忙转发 TERM 信号,导致我们的可执行文件无法接收到终止信号,并执行清理逻辑。

我们的 manager 确实是用了一段 shell script 来启动的,可能就是因为这个原因导致无法收到 TERM 信号。

三、问题复现

我写了一段很简单的 go 程序,监听了 TERM 信号,然后打印一段文字。

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT)

    s, ok := <-sigs
    if !ok {
        log.Println("信号接收出错")
        os.Exit(1)
    }

    log.Println("收到信号:", s.String())
}

我的 Dockerfile 如下:

FROM alpine:latest
LABEL maintainr="jiangpengfei <jiangpengfei12@gmail.com>"

COPY main /usr/bin/main
COPY run.sh /usr/bin/run.sh
RUN chmod +x /usr/bin/main && chmod +x /usr/bin/run.sh

CMD ["sh", "-c", "/usr/bin/run.sh"]

run.sh 如下:

#!/bin/sh
/usr/bin/main

执行这个容器后,查看容器内的进程:

PID   USER     TIME  COMMAND
    1 root      0:00 {busybox} ash /usr/bin/run.sh
    6 root      0:00 /usr/bin/main
   12 root      0:00 sh
   17 root      0:00 ps

可以发现,run.sh 是 PID 为1, main 程序是6。此时我们使用 docker kill --signal TERM main_container_id 来停止容器,发现确实是没有反应的。因为 TERM 信号会发送给 PID 为 1 的进程。同时也因为 sh 不响应 TERM 信号,也不会转发该信号给子进程,所以容器也不会退出。如果我们使用 docker stop 退出的话,会发现很慢,这是因为 docker stop 会尝试先用 TERM 信号来终止进程,一段时间后发现没有退出的话再使用 KILL 信号。

四、解决方案

这个问题的解决方案有很多,要么让我们的程序进程成为 PID 1,要么让 PID 为 1 的进程转发这个 TERM 信号给我们的子进程。

方法一: 在 shell script 中使用 exec

将我们的 run.sh 改成如下:

#!/bin/sh
exec /usr/bin/main

然后再查看容器内的进程列表:

PID   USER     TIME  COMMAND
    1 root      0:00 /usr/bin/main
   11 root      0:00 sh
   16 root      0:00 ps

可以发现,main 进程的PID 是 1, 我们使用 docker kill --signal TERM main_container_id 来杀死进程,出现如下打印语句:

2020/01/17 23:46:24 收到信号: terminated

可见,exec 可以让我们的 main 进程成为 PID 为 1, 关于 exec 的作用描述如下:

The exec() family of functions replaces the current process image with a new process image.

即使用新进程的镜像替换当前进程的镜像数据,可以理解为exec系统调用并没有创建新的进程,只是替换了原来进程上下文的内容。原进程的代码段,数据段,堆栈段被新的进程所代替。这样我们的 main 进程就顺利成章的替换了 sh 进程成为 PID 为 1 的进程了。

方法二: 直接使用 main 作为镜像入口

这是最简单的方法了,但是很多时候会有限制,因为我们希望在 shell script 中写一些逻辑来调用程序。

方法三: 借助第三方程序

一些第三方的程序专门提供了这样的作用,以它们作为启动的入口,这些第三方程序会 watch 所有它产生的子进程,在这些子进程退出后自动退出,并且在其收到 TERM 信号后发送给子进程。

这里我们用 smell-baron 这个应用作为例子

修改 Dockerfile:

FROM alpine:latest
LABEL maintainr="jiangpengfei <jiangpengfei12@gmail.com>"

COPY main /usr/bin/main
COPY run.sh /usr/bin/run.sh
RUN chmod +x /usr/bin/main && chmod +x /usr/bin/run.sh
RUN wget -O /usr/bin/smell-baron https://github.com/insidewhy/smell-baron/releases/download/v0.4.2/smell-baron.musl && chmod +x /usr/bin/smell-baron

CMD ["/usr/bin/smell-baron", "/usr/bin/run.sh"]

查看容器内的进程:

PID   USER     TIME  COMMAND
    1 root      0:00 /usr/bin/smell-baron /usr/bin/run.sh
    6 root      0:00 /usr/bin/main
   14 root      0:00 sh
   19 root      0:00 ps

使用 docker kill 发现 main 收到了 TERM 信号。

1.Multiple commands can be run, smell-baron will exit when all the watched processes have exited.

2.Whether a spawned process is watched can be configured.

3.smell-baron can be told to signal all child processes on termination, this allows it to cleanly deal with processes that spawn a subprocess in a different process group then fail to clean it up on exit.

容器标准化

概述

我认为容器标准化可以分为两个角度去讲:

一个是容器的使用和镜像的格式需要规范,这叫做OCI(open container initiative),也就是说,不同技术实现的容器,都可以使用同一种方式运行,同一个镜像也可以在不同的容器技术上运行。

另外一个就是因为Kubernetes的流行,Kubernetes推出了一个CRI(container runtime interface)的接口规范,凡是直接或间接实现了这个接口规范的容器都可以作为Kubernetes的默认容器运行时。

OCI和CRI的制定也意味着容器技术迎来了高速发展。

CRI: container runtime interface

CRI是kubernetes推出的容器运行时接口,有了CRI,不论各种容器化技术是如何实现的,都可以用一个共同的接口对外提供服务。CRI中定义了容器和镜像的接口的接口,基于gRPC调用。具体的可以查看api.proto。下面简单的列一下:

// Runtime service defines the public APIs for remote container runtimes
service RuntimeService {
    // Version returns the runtime name, runtime version, and runtime API version.
    rpc Version(VersionRequest) returns (VersionResponse) {}

    // RunPodSandbox creates and starts a pod-level sandbox. Runtimes must ensure
    // the sandbox is in the ready state on success.
    rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse) {}
    // StopPodSandbox stops any running process that is part of the sandbox and
    // reclaims network resources (e.g., IP addresses) allocated to the sandbox.
    // If there are any running containers in the sandbox, they must be forcibly
    // terminated.
    // This call is idempotent, and must not return an error if all relevant
    // resources have already been reclaimed. kubelet will call StopPodSandbox
    // at least once before calling RemovePodSandbox. It will also attempt to
    // reclaim resources eagerly, as soon as a sandbox is not needed. Hence,
    // multiple StopPodSandbox calls are expected.
    rpc StopPodSandbox(StopPodSandboxRequest) returns (StopPodSandboxResponse) {}
    // RemovePodSandbox removes the sandbox. If there are any running containers
    // in the sandbox, they must be forcibly terminated and removed.
    // This call is idempotent, and must not return an error if the sandbox has
    // already been removed.
    rpc RemovePodSandbox(RemovePodSandboxRequest) returns (RemovePodSandboxResponse) {}
    // PodSandboxStatus returns the status of the PodSandbox. If the PodSandbox is not
    // present, returns an error.
    rpc PodSandboxStatus(PodSandboxStatusRequest) returns (PodSandboxStatusResponse) {}
    // ListPodSandbox returns a list of PodSandboxes.
    rpc ListPodSandbox(ListPodSandboxRequest) returns (ListPodSandboxResponse) {}

    // CreateContainer creates a new container in specified PodSandbox
    rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse) {}
    // StartContainer starts the container.
    rpc StartContainer(StartContainerRequest) returns (StartContainerResponse) {}
    // StopContainer stops a running container with a grace period (i.e., timeout).
    // This call is idempotent, and must not return an error if the container has
    // already been stopped.
    // TODO: what must the runtime do after the grace period is reached?
    rpc StopContainer(StopContainerRequest) returns (StopContainerResponse) {}
    // RemoveContainer removes the container. If the container is running, the
    // container must be forcibly removed.
    // This call is idempotent, and must not return an error if the container has
    // already been removed.
    rpc RemoveContainer(RemoveContainerRequest) returns (RemoveContainerResponse) {}
    // ListContainers lists all containers by filters.
    rpc ListContainers(ListContainersRequest) returns (ListContainersResponse) {}
    // ContainerStatus returns status of the container. If the container is not
    // present, returns an error.
    rpc ContainerStatus(ContainerStatusRequest) returns (ContainerStatusResponse) {}
    // UpdateContainerResources updates ContainerConfig of the container.
    rpc UpdateContainerResources(UpdateContainerResourcesRequest) returns (UpdateContainerResourcesResponse) {}
    // ReopenContainerLog asks runtime to reopen the stdout/stderr log file
    // for the container. This is often called after the log file has been
    // rotated. If the container is not running, container runtime can choose
    // to either create a new log file and return nil, or return an error.
    // Once it returns error, new container log file MUST NOT be created.
    rpc ReopenContainerLog(ReopenContainerLogRequest) returns (ReopenContainerLogResponse) {}

    // ExecSync runs a command in a container synchronously.
    rpc ExecSync(ExecSyncRequest) returns (ExecSyncResponse) {}
    // Exec prepares a streaming endpoint to execute a command in the container.
    rpc Exec(ExecRequest) returns (ExecResponse) {}
    // Attach prepares a streaming endpoint to attach to a running container.
    rpc Attach(AttachRequest) returns (AttachResponse) {}
    // PortForward prepares a streaming endpoint to forward ports from a PodSandbox.
    rpc PortForward(PortForwardRequest) returns (PortForwardResponse) {}

    // ContainerStats returns stats of the container. If the container does not
    // exist, the call returns an error.
    rpc ContainerStats(ContainerStatsRequest) returns (ContainerStatsResponse) {}
    // ListContainerStats returns stats of all running containers.
    rpc ListContainerStats(ListContainerStatsRequest) returns (ListContainerStatsResponse) {}

    // UpdateRuntimeConfig updates the runtime configuration based on the given request.
    rpc UpdateRuntimeConfig(UpdateRuntimeConfigRequest) returns (UpdateRuntimeConfigResponse) {}

    // Status returns the status of the runtime.
    rpc Status(StatusRequest) returns (StatusResponse) {}
}

// ImageService defines the public APIs for managing images.
service ImageService {
    // ListImages lists existing images.
    rpc ListImages(ListImagesRequest) returns (ListImagesResponse) {}
    // ImageStatus returns the status of the image. If the image is not
    // present, returns a response with ImageStatusResponse.Image set to
    // nil.
    rpc ImageStatus(ImageStatusRequest) returns (ImageStatusResponse) {}
    // PullImage pulls an image with authentication config.
    rpc PullImage(PullImageRequest) returns (PullImageResponse) {}
    // RemoveImage removes the image.
    // This call is idempotent, and must not return an error if the image has
    // already been removed.
    rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse) {}
    // ImageFSInfo returns information of the filesystem that is used to store images.
    rpc ImageFsInfo(ImageFsInfoRequest) returns (ImageFsInfoResponse) {}
}

共包含了两个服务:
– RuntimeService:容器和Sandbox运行时管理。
– ImageService:提供了从镜像仓库拉取、查看、和移除镜像的RPC。

再看一下CRI的架构图:

cri architecture

在kubernetes中,CRI扮演了kubelet和container runtime的通信桥梁。也因为CRI的存在,container runtime和kubelet解耦,就有了多种选择,比如: docker、 CRI-O、containerd、frakti等等。

OCI: open container initiative

这个是由docker和其他的公司推动的容器标准,为了围绕容器格式和运行时制定一个开放的工业化标准,目前主要有两个标准文档:容器运行时标准 (runtime spec)和 容器镜像标准(image spec)。这两个协议通过 OCI runtime filesytem bundle 的标准格式连接在一起,OCI 镜像可以通过工具转换成 bundle,然后 OCI 容器引擎能够识别这个 bundle 来运行容器

oci

下面引用一下其他博客的文字(https://www.jianshu.com/p/62e71584d1cb):

设计考量

操作标准化:容器的标准化操作包括使用标准容器创建、启动、停止容器,使用标准文件系统工具复制和创建容器快照,使用标准化网络工具进行下载和上传。

内容无关:内容无关指不管针对的具体容器内容是什么,容器标准操作执行后都能产生同样的效果。如容器可以用同样的方式上传、启动,不管是PHP应用还是MySQL数据库服务。

基础设施无关:无论是个人的笔记本电脑还是AWS S3,亦或是OpenStack,或者其它基础设施,都应该对支持容器的各项操作。
为自动化量身定制:制定容器统一标准,是的操作内容无关化、平台无关化的根本目的之一,就是为了可以使容器操作全平台自动化。

工业级交付:制定容器标准一大目标,就是使软件分发可以达到工业级交付成为现实

image spec(容器标准包)

OCI 容器镜像主要包括几块内容:

文件系统:以 layer 保存的文件系统,每个 layer 保存了和上层之间变化的部分,layer 应该保存哪些文件,怎么表示增加、修改和删除的文件等

config 文件:保存了文件系统的层级信息(每个层级的 hash 值,以及历史信息),以及容器运行时需要的一些信息(比如环境变量、工作目录、命令参数、mount 列表),指定了镜像在某个特定平台和系统的配置。比较接近我们使用 docker inspect 看到的内容

manifest 文件:镜像的 config 文件索引,有哪些 layer,额外的 annotation 信息,manifest 文件中保存了很多和当前平台有关的信息

index 文件:可选的文件,指向不同平台的 manifest 文件,这个文件能保证一个镜像可以跨平台使用,每个平台拥有不同的 manifest 文件,使用 index 作为索引

runtime spec(容器运行时和生命周期)

容器标准格式也要求容器把自身运行时的状态持久化到磁盘中,这样便于外部的其它工具对此信息使用和演绎。该运行时状态以JSON格式编码存储。推荐把运行时状态的JSON文件存储在临时文件系统中以便系统重启后会自动移除。

基于Linux内核的操作系统,该信息应该统一地存储在/run/opencontainer/containers目录,该目录结构下以容器ID命名的文件夹(/run/opencontainer/containers//state.json)中存放容器的状态信息并实时更新。有了这样默认的容器状态信息存储位置以后,外部的应用程序就可以在系统上简便地找到所有运行着的容器了。

state.json文件中包含的具体信息需要有:

版本信息:存放OCI标准的具体版本号。

容器ID:通常是一个哈希值,也可以是一个易读的字符串。在state.json文件中加入容器ID是为了便于之前提到的运行时hooks只需载入state.json就- – 可以定位到容器,然后检测state.json,发现文件不见了就认为容器关停,再执行相应预定义的脚本操作。

PID:容器中运行的首个进程在宿主机上的进程号。

容器文件目录:存放容器rootfs及相应配置的目录。外部程序只需读取state.json就可以定位到宿主机上的容器文件目录。

容器创建:创建包括文件系统、namespaces、cgroups、用户权限在内的各项内容。

容器进程的启动:运行容器进程,进程的可执行文件定义在的config.json中,args项。

容器暂停:容器实际上作为进程可以被外部程序关停(kill),然后容器标准规范应该包含对容器暂停信号的捕获,并做相应资源回收的处理,避免孤儿进程的出现。

CRI和OCI的对比

OCI是容器技术的开放性标准,而CRI是Kubernetes为了更方便的支持不同的容器技术,而推出的接口标准,与CRI类似的还有CNI和CSI,分别是网络和存储的接口。

可以看一下这张图:

kubelet cri runtime

kubelet有了CRI的接口,可以通过cri-containerd和containerd通信,也可以通过docker-shim和docker通信。注意这里的cri-containerd在containerd v1.2的时候就已经不再使用了,因为containerd本身就支持了CRI的规范。

同时kubernetes还孵化了cri-o这个项目,cri-o直接打通了cri和oci。runc和kata都是oci的具体实现。

所以,用一句话理解:实现了CRI就可以保证被kubernetes使用,实现了OCI就可以在各种设备上无差别的使用各种镜像。

docker、containerd和runc

containerd从docker中分出来的一部分。containerd是负责管理容器生命周期的常驻进程,而runc则是真正负责容器运行的部分。可以通过以下的图来看三者之间的关系:

docker-containerd-runc

containerd会调用多个runc实例来管理多个容器。docker engine则是提供接口给用户使用。

kubernetes当前支持的CRI后端

containerd

containerd的地址:https://github.com/containerd/containerd

先用官网的图片来看一下containerd的架构:

containerd architecture

containerd处于os和clients之间,它使用CRI API提供给Kubelet调用,使用containerd API提供给containerd client调用,使用Metrics API提供给Prometheus监控数据。然后有一层containerd Service Interfaces提供给上层api使用。注意到其中还有一个container-shim打通了Runtime managerOCI runtime的具体实现,比如runcrunhcskata

containerd实现了以下的特性:

  • OCI Image规范的支持
  • OCI Runtime规范的支持(通过runc等)
  • Image的上传和下载
  • 容器运行时和生命周期的支持
  • 创建、修改和删除网络
  • 管理网络命名空间以及将容器加入到现有的网络命名空间
  • 全部镜像的CAS存储的多租户模式支持

cri-o

项目地址:https://github.com/cri-o/cri-o

cri-o

cri-o项目是Kubernetes CRI接口的实现,同时可以兼容OCI标准的容器运行时。这样的能力就使得它可以作为Docker的轻量级的容器运行时的替代方案,使得Kubernetes可以接入符合OCI标准的所有容器运行时,同时也减少了容器开发者们的额外工作量(只需实现OCI标准即可)。

frakti

项目地址:https://github.com/kubernetes/frakti

frakti

frakti是Kubernetes官方推出的一个容器运行时,但是不同于docker这样的利于linux namespace的技术,它是基于虚拟化技术的容器,因此可以带来更好的环境隔离以及独享的内核。

rkt

项目地址:https://github.com/rkt/rkt/

rkt-vs-docker-process-model

rkt是coreos推出的和Docker抗衡的容器产品,不同于现在的Docker往更大更全的方向,不仅仅是容器功能,更集成了Swarm这样的集群方案,rkt注重的是作为运行在linux系统上的容器组件。上图可以看出Docker的架构要更加的复杂。

docker

官网地址: https://docker.com

docker作为Kubernetes的默认容器运行时,其本身在容器领域也占据了绝对的领导地位。

实现了OCI,可以通过cri-o接入kubernetes的项目

runc

项目地址: https://github.com/opencontainers/runc

opencontainers组织推出了OCI的规范,同时也开发了runc作为OCI规范的实现。runc是docker贡献出来的容器运行时,runc不仅是containerd的默认运行时,同时也可以接入到cri-o中。

Clear Containers

https://github.com/clearcontainers/runtime,项目已经不在维护,推荐迁移到Kata Containers

Kata Containers

https://github.com/kata-containers/runtime

Kata Containers和runc这种技术栈是不同的。runc使用的是linux namespace和cgroup来做环境隔离和资源限制,缺点在于使用的仍然是宿主机的内核,这样一旦受到了内核层的影响,会扩散到所有的容器。而Kata Containers使用的是虚拟化的技术,它实际上是一个虚拟机,但是可以像容器那样使用。

Kata Containers是2017年12月启动的项目,结合了Intel Clear Containers和 Hyper.sh RunV的优点,支持不同的主流架构,除x86_64外,还支持AMD64, ARM, IBM p-series and IBM z-series。

下图是kata Containers和传统容器技术的对比:

katacontainers_traditionalvskata_diagram

主要特点如下:

  • 安全性: 使用专用内核,提供了网络、IO和内存的独立,在虚拟化VT扩展的基础上利用硬件强制隔离
  • 性能: 提供与标准Linux容器一致的性能;提高隔离度,而无需增加标准虚拟机的性能。
  • 兼容性: 支持行业标准,包括OCI容器格式,Kubernetes CRI接口以及旧版虚拟化技术。
  • 简单: 消除了在完整的虚拟机内部嵌套容器的要求;标准接口使插入和入门变得容易

下图是Kata Containers的架构:

katacontainers_architecture_diagram

Kubernetes可以通过Hypervisor VSOCK Socket和容器交互。

gVisor

gVisor提供的是一个沙箱容器环境,可以说是传统容器技术和虚拟机容器技术的折中。它使用Go编写了一个可以作为普通非特权进程运行的内核,这个内核实现了大多数的系统调用。所以相比于namespace和cgroup实现的容器,它可以屏蔽掉容器内应用程序的内核调用。相比于虚拟机实现的容器,它更轻量级(作为系统的一个进程运行)。

gvisor