分析POD Exit Code 137,无法优雅下线

分析POD Exit Code 137,无法优雅下线

Deng YongJie's blog 1,993 2023-01-14

分析POD Exit Code 137,容器被强制kill掉原因

pod的生命周期:

https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/pod-lifecycle/

常见的容器退出状态码解释

Exit Code 0
退出代码0表示特定容器没有附加前台进程
该退出代码是所有其他后续退出代码的例外
这不一定意味着发生了不好的事情。如果开发人员想要在容器完成其工作后自动停止其容器,则使用此退出代码。比如:kubernetes job 在执行完任务后正常退出码为 0

Exit Code 1
程序错误,或者Dockerfile中引用不存在的文件,如 entrypoint中引用了错误的包
程序错误可以很简单,例如 “除以0”,也可以很复杂,比如空引用或者其他程序 crash

Exit Code 137
表明容器收到了 SIGKILL 信号,进程被杀掉,对应kill -9
引发SIGKILL的是docker kill。这可以由用户或由docker守护程序来发起,手动执行:docker kill

137 比较常见,如果 pod 中的limit 资源设置较小,会运行内存不足导致 OOMKilled,此时state 中的 ”OOMKilled” 值为true,你可以在系统的 dmesg -T 中看到 oom 日志

Exit Code 139
表明容器收到了 SIGSEGV 信号,无效的内存引用,对应kill -11
一般是代码有问题,或者 docker 的基础镜像有问题

Exit Code 143
表明容器收到了 SIGTERM 信号,终端关闭,对应kill -15
一般对应 docker stop 命令
有时docker stop也会导致Exit Code 137。发生在与代码无法处理 SIGTERM 的情况下,docker进程等待十秒钟然后发出 SIGKILL 强制退出。

不常用的一些 Exit Code
Exit Code 126: 权限问题或命令不可执行
Exit Code 127: Shell脚本中可能出现错字且字符无法识别的情况
Exit Code 1 或 255:因为很多程序员写异常退出时习惯用 exit(1) 或 exit(-1),-1 会根据转换规则转成 255。这个一般是自定义 code,要看具体逻辑。


常见退出状态码

退出代码 0:一般为容器正常退出
退出代码 1:由于容器中 pid 为 1 的进程错误而失败
退出代码 137:由于容器收到 SIGKILL 信号而失败(手动执行或“oom-killer” [OUT-OF-MEMORY])
退出代码 139:由于容器收到 SIGSEGV 信号而失败
退出代码 143:由于容器收到 SIGTERM 信号而失败

查看异常 pod 的状态,注意需要在减少pod或删除pod,超过宽限期的一瞬间才会出现:

kubectl get -n crm-app-api-java pod crm-app-fund-5ffcd4c8b9-b5mts -o yaml|egrep 'exitCode|reason'

           f:reason: {}
            f:reason: {}
    reason: ContainersNotReady
    reason: ContainersNotReady
        exitCode: 137
        reason: Error

其中 ExitCode 即程序上次退出时的状态码,如果不为 0,表示异常退出,我们可以分析下原因。

复现过程:

运行一个nginx,然后把副本数降为0,查看yaml关键字段复现问题。发现是正常的

image-1680141147740

再次测试无法优雅下线的java服务,发现还是出现137 code

image-1680141240493
image-1680141294189

因为pod的字段reason出现的是error,并非oomkill。更进一步确认是否oom问题,查看宿主机内核日志

dmesg |grep oom
journalctl -k | grep -i -e memory -e oom

#并没有发现该容器的omm信息,彻底排除掉oomkill

通过上面的排查,初步判断是否发送了Kill信号,超过宽限期还没正常退出,然后kill -9停止了。尝试调大宽限期的范围,宽限期默认是30s,改成60s

spec:
  terminationGracePeriodSeconds: 60

调大宽限期之后,把副本数增加为1,随后减至0,结果还是出现exitcode 137

image-1680141381547

然后进入容器内部查看进程,发现1号进程运行的是脚本。通过排查和沟通,得知是测试同事使用入口shell命令运行pinpoint-agent脚本,占用了PID为1的进程,导致无法获取进程信号,所以容器超过宽限期而被强制kill掉,无法优雅下线!

image-1680142720902

常见问题:

  1. 在大多数情况下,信号处理不当。Linux内核对以PID 1运行的进程应用特殊的信号处理。当进程在普通Linux系统上被发送一个信号时,内核将首先检查进程是否注册了该信号的任何自定义处理程序,否则将返回到默认行为(例如,在SIGTERM上终止进程)。但是,如果接收到信号的进程是PID 1,它将得到内核的特殊处理;如果它没有为该信号注册处理程序,内核将不会返回到默认行为,并且不会发生任何事情。换句话说,如果您的进程没有显式地处理这些信号,那么发送SIGTERM将没有任何效果。一个常见的例子是执行docker run my-container script的CI作业:将SIGTERM发送到docker run进程通常会终止docker run命令,但让容器在后台运行。
  2. 孤立的僵尸进程没有得到正确的收获。进程在退出时变成僵尸,在其父进程调用wait()系统调用的某个变体之前,它仍然是僵尸。它作为“失效”进程保留在进程表中。通常,父进程将立即调用wait(),并避免long-living僵尸。如果父级在其子级之前退出,则子级是“孤立的”,并且在PID 1下是re-parented。因此,init系统负责孤立僵尸进程上的wait()-ing。当然,大多数进程在随机进程上不会wait(),而这些随机进程恰好与它们相连,因此容器通常以许多根于PID 1的僵尸结束。

总结:

  1. 通常1个pod只运行1个容器1个进程。 容器运行多个进程的情况,容器运行时会发送一个 TERM 信号到每个容器中的主进程,主进程要为子进程传递信号实现优雅退出,否则主进程先退出,而子进程还保留在进程表,就成了僵尸进程,超过pod的终止宽限期而触发kill -9

为什么我的容器收不到 SIGTERM 信号 ?

背景

我们的业务代码通常会捕捉 SIGTERM 信号,然后执行停止逻辑以实现优雅终止。在 Kubernetes 环境中,业务发版时经常会对 workload 进行滚动更新,当旧版本 Pod 被删除时,K8S 会对 Pod 中各个容器中的主进程发送 SIGTERM 信号,当达到超时时间进程还未完全停止的话,K8S 就会发送 SIGKILL 信号将其强制杀死。

业务在 Kubernetes 环境中实际运行时,有时候可能会发现在滚动更新时,我们业务的优雅终止逻辑并没有被执行,现象是在等了较长时间后,业务进程直接被 SIGKILL 强制杀死了。

什么原因 ?

通常都是因为容器启动入口使用了 shell,比如使用了类似 /bin/sh -c my-app 或 /docker-entrypoint.sh 这样的 ENTRYPOINT 或 CMD,这就可能就会导致容器内的业务进程收不到 SIGTERM 信号,原因是:

  1. 容器主进程是 shell,业务进程是在 shell 中启动的,成为了 shell 进程的子进程。
  2. shell 进程默认不会处理 SIGTERM 信号,自己不会退出,也不会将信号传递给子进程,导致业务进程不会触发停止逻辑。
  3. 当等到 K8S 优雅停止超时时间 (terminationGracePeriodSeconds,默认 30s),发送 SIGKILL 强制杀死 shell 及其子进程。

如何解决 ?

  1. 如果可以的话,尽量不使用 shell 启动业务进程。
  2. 如果一定要通过 shell 启动,比如在启动前需要用 shell 进程一些判断和处理,或者需要启动多个进程,那么就需要在 shell 中传递下 SIGTERM 信号了,解决方案请参考 Kubernetes 实用技巧: 在 SHELL 中传递信号

在 SHELL 中如何传递信号?

背景

在 Kubernetes 中,Pod 停止时 kubelet 会先给容器中的主进程发 SIGTERM 信号来通知进程进行 shutdown 以实现优雅停止,如果超时进程还未完全停止则会使用 SIGKILL 来强行终止。

但有时我们会遇到一种情况: 业务逻辑处理了 SIGTERM 信号,但 Pod 停止时好像没收到信号导致优雅停止逻辑不生效。

通常是因为我们的业务进程是在脚本中启动的,容器的启动入口使用了脚本,所以容器中的主进程并不是我们所希望的业务进程而是 shell 进程,导致业务进程收不到 SIGTERM 信号,下面将介绍几种解决方案。

使用 exec 启动

在 shell 中启动二进制的命令前加一个 exec 即可让该二进制启动的进程代替当前 shell 进程,即让新启动的进程成为主进程:

#! /bin/bash
...

exec /bin/yourapp # 脚本中执行二进制

然后业务进程就可以正常接收所有信号了,实现优雅退出也不在话下。

多进程场景: 使用 trap 传递信号

通常我们一个容器只会有一个进程,也是 Kubernetes 的推荐做法。但有些时候我们不得不启动多个进程,比如从传统部署迁移到 Kubernetes 的过渡期间,使用了富容器,即单个容器中需要启动多个业务进程,这时也只能通过 shell 启动,但无法使用上面的 exec 方式来传递信号,因为 exec 只能让一个进程替代当前 shell 成为主进程。

这个时候我们可以在 shell 中使用 trap 来捕获信号,当收到信号后触发回调函数来将信号通过 kill 传递给业务进程,脚本示例:

#! /bin/bash

/bin/app1 & pid1="$!" # 启动第一个业务进程并记录 pid
echo "app1 started with pid $pid1"

/bin/app2 & pid2="$!" # 启动第二个业务进程并记录 pid
echo "app2 started with pid $pid2"

handle_sigterm() {
  echo "[INFO] Received SIGTERM"
  kill -SIGTERM $pid1 $pid2 # 传递 SIGTERM 给业务进程
  wait $pid1 $pid2 # 等待所有业务进程完全终止
}
trap handle_sigterm SIGTERM # 捕获 SIGTERM 信号并回调 handle_sigterm 函数

wait # 等待回调执行完,主进程再退出

完美方案: 使用 init 系统

前面一种方案实际是用脚本实现了一个极简的 init 系统 (或 supervisor) 来管理所有子进程,只不过它的逻辑很简陋,仅仅简单的透传指定信号给子进程,其实社区有更完善的方案,dumb-inittini 都可以作为 init 进程,作为主进程 (PID 1) 在容器中启动,然后它再运行 shell 来执行我们指定的脚本 (shell 作为子进程),shell 中启动的业务进程也成为它的子进程,当它收到信号时会将其传递给所有的子进程,从而也能完美解决 SHELL 无法传递信号问题,并且还有回收僵尸进程的能力。

特性描述

进程信号传递

站在容器的角度,由其运行的第一个程序的PID为1,这个进程肩负着重要的使命:传递信号让子进程退出和等待子进程退出。对于第一点,如果pid为1的进程,无法向其子进程传递信号,可能导致容器发送SIGTERM信号之后,父进程等待子进程退出。此时,如果父进程不能将信号传递到子进程,则整个容器就将无法正常退出,除非向父进程发送SIGKILL信号,使其强行退出,这就会导致一些退出前的操作无法正常执行,例如关闭数据库连接、关闭输入输出流等。

僵尸进程处理 上边提到:PID为1的进程的一个重要使命是等待子进程退出,如果一个进程中A运行了一个子进程B,而这个子进程B又创建了一个子进程C,若子进程B非正常退出(通过SIGKILL信号,并不会传递SIGKILL信号给进程C),那么子进程C就会由进程A接管,一般情况下,我们在进程A中并不会处理对进程C的托管操作(进程A不会传递SIGTERM和SIGKILL信号给进程C),结果就导致了进程B结束了,倒是并没有回收其子进程C,子进程C就变成了僵尸进程。

进程信号模拟 父进程非正常退出(收到SIGKILL信号),若使用了Supervisor类工具,可以将SIGKILL信号传递给子进程,若子进程中想收到SIGTERM信号,就可以通过dumb-init来模拟。

容器里面安装Dumb-init

deb安装

RUN wget https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64.deb
RUN dpkg -i dumb-init_*.deb

二进制安装

RUN wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64
RUN chmod +x /usr/local/bin/dumb-init

PyPI安装

pip install dumb-init

这里以 dumb-init 为entrypoint固定使用dumb-init为容器运行的第一个进程,start.sh作为其子进程,它充当PID 1,并立即将命令作为子进程生成,在接收到信号时正确地处理和转发,下面是 Dockerfile 示例:

FROM ubuntu:latest
RUN apt-get update && apt-get install -y dumb-init
ADD start.sh /
ADD app1 /bin/app1
ADD app2 /bin/app2
ENTRYPOINT ["dumb-init", "--"]
CMD ["/start.sh"]

这是以 tini 为例制作镜像的 Dockerfile 示例:

FROM ubuntu:22.04
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /tini /entrypoint.sh
ENTRYPOINT ["/tini", "--"]
CMD [ "/start.sh" ]

start.sh 脚本示例:

#! /bin/bash
/bin/app1 &
/bin/app2 &
wait

业务代码处理 SIGTERM 信号

要实现优雅终止,首先业务代码得支持下优雅终止的逻辑,在业务代码里面处理下 SIGTERM 信号,一般主要逻辑就是"排水",即等待存量的任务或连接完全结束,再退出进程。

本文给出各种语言的代码示例。

shell

#!/bin/sh

## Redirecting Filehanders
ln -sf /proc/$$/fd/1 /log/stdout.log
ln -sf /proc/$$/fd/2 /log/stderr.log

## Pre execution handler
pre_execution_handler() {
  ## Pre Execution
  # TODO: put your pre execution steps here
  : # delete this nop
}

## Post execution handler
post_execution_handler() {
  ## Post Execution
  # TODO: put your post execution steps here
  : # delete this nop
}

## Sigterm Handler
sigterm_handler() { 
  if [ $pid -ne 0 ]; then
    # the above if statement is important because it ensures 
    # that the application has already started. without it you
    # could attempt cleanup steps if the application failed to
    # start, causing errors.
    kill -15 "$pid"
    wait "$pid"
    post_execution_handler
  fi
  exit 143; # 128 + 15 -- SIGTERM
}

## Setup signal trap
# on callback execute the specified handler
trap 'sigterm_handler' SIGTERM

## Initialization
pre_execution_handler

## Start Process
# run process in background and record PID
>/log/stdout.log 2>/log/stderr.log "$@" &
pid="$!"
# Application can log to stdout/stderr, /log/stdout.log or /log/stderr.log

## Wait forever until app dies
wait "$pid"
return_code="$?"

## Cleanup
post_execution_handler
# echo the return code of the application
exit $return_code

Go

package main

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

func main() {

    sigs := make(chan os.Signal, 1)
    done := make(chan bool, 1)
    //registers the channel
    signal.Notify(sigs, syscall.SIGTERM)

    go func() {
        sig := <-sigs
        fmt.Println("Caught SIGTERM, shutting down")
        // Finish any outstanding requests, then...
        done <- true
    }()

    fmt.Println("Starting application")
    // Main logic goes here
    <-done
    fmt.Println("exiting")
}

Python

import signal, time, os

def shutdown(signum, frame):
    print('Caught SIGTERM, shutting down')
    # Finish any outstanding requests, then...
    exit(0)

if __name__ == '__main__':
    # Register handler
    signal.signal(signal.SIGTERM, shutdown)
    # Main logic goes here

NodeJS

process.on('SIGTERM', () => {
  console.log('The service is about to shut down!');
  
  // Finish any outstanding requests, then...
  process.exit(0); 
});

Java

import sun.misc.Signal;
import sun.misc.SignalHandler;
 
public class ExampleSignalHandler {
    public static void main(String... args) throws InterruptedException {
        final long start = System.nanoTime();
        Signal.handle(new Signal("TERM"), new SignalHandler() {
            public void handle(Signal sig) {
                System.out.format("\nProgram execution took %f seconds\n", (System.nanoTime() - start) / 1e9f);
                System.exit(0);
            }
        });
        int counter = 0;
        while(true) {
            System.out.println(counter++);
            Thread.sleep(500);
        }
    }
}