k8s 容器热替换/重启主过程 - gdb execve syscall 法

指标

k8s 环境下,在不进行或重启 container 的状况下,重启利用过程(pid:1),甚至从新加载运行新版本的利用。本文以 gdb 作为工具,调用内核的 close / execve syscall,去实现这个指标。

背景

K8s 显然曾经由衰亡转向成熟。大潮过后,是时候思考一下,当初吹过的牛有哪些是真的,哪些是还未对现的。不可否认,k8s 改革了运维的工作形式,这根本是提高的。但对于开发,特地是阻碍问题定位、程序调试办法,显然难度是减少了。

在利用的阻碍问题定位、程序调试时,咱们时常心愿能在雷同的环境下反复重启利用,去察看咱们对配置的批改,或者程序的更新是否真正解决了问题。要实现这个指标,通常须要:

  1. 批改利用代码,跑 CI pipeline 从新打包 docker image,上传。—— 费时费力
  2. 想方法重启容器。—— 环境毁坏了,问题可能重现不了

如果容器启动脚本设计时就反对重启,当然没问题,但大部分状况下,均是不间接反对的,很多时候,利用主过程就间接是 container 的主过程 pid:1 了。

我钻研过三个办法去替换主过程:

  1. gdb 调用 libc 的 execl 。
  2. gdb 调用 syscall execve 。 这个办法比较复杂,但也更通用,这是本文要说的办法。
  3. kill -STOP 挂起主过程的父过程
  4. gdb 主过程的父过程,让它收不到 SIGCHLD

知识点

  • Linux 的一些内存布局
  • Linux 的过程启动参数和环境变量布局
  • Linux /proc/$pid/stat 的小机密
  • syscall 常识
  • x86 一点寄存器常识
  • gdb 大法之魔幻

系列简介

《k8s 容器热替换/重启主过程》 是一个系列的文章,指标都是雷同的,但在不同的状况下应用不同的伎俩:

  • k8s 容器热替换/重启主过程 - gdb exec 法

    • 长处:应用比拟不便
    • 毛病:依赖可执行文件应用了 glibc.so 。 golang 编写生成的可执行文件,通常不应用 glibc.so
  • k8s 容器热替换/重启主过程 - gdb execve syscall 法(本文)

    • 长处: 不依赖 glibc.so,golang 编写生成的可执行文件可用。
    • 毛病:应用有点麻烦,须要理解一些 linux 过程内存布局、一点点 x86 64bit 汇编入门。

正告

因为本文应用了 gdb attach 和非常规办法敞开文件描述符(close(fd))和替换过程执行文件(execve),潜在比拟大的未知危险,请不要在生产环境中应用。我也未充沛验证这个办法的可靠性,和副作用。包含 close 和 execve 是否能洁净清理后任的问题。所以,应用有危险。

思路

  1. gdb attach 过程
  2. 调用 close fd syscall,特地是 socket 相干的,特地是 listen tcp port 的。
  3. 调用 execve syscall ,以雷同的启动参数和环境变量,执行雷同的可执行文件

其中有几个难点:

  • gdb 下,在过程无加载 glibc 时调用 syscall
  • execve 须要一些入参,包含

    • char* filepath :执行文件地位
    • char * argv[] :启动参数
    • char *envp[] : 环境变量

筹备常识

如果你不太理解 syscall 原理,可能是时候补补课了。以下是一些我的参考资料:

  • https://blog.packagecloud.io/the-definitive-guide-to-linux-system-calls/
  • https://www.juliensobczak.com/inspect/2021/08/10/linux-system-calls-under-the-hood.html
  • https://cs61.seas.harvard.edu/site/2021/Kernel/

寄存器

要调用 execve syscall,须要一些参数:

  • char* filepath :执行文件地位
  • char * argv[] :启动参数
  • char *envp[] : 环境变量

详见:https://chromium.googlesource.com/chromiumos/docs/+/master/constants/syscalls.md

NRsyscall namereferences%raxarg0 (%rdi)arg1 (%rsi)arg2 (%rdx)arg3 (%r10)arg4 (%r8)arg5 (%r9)
0readman/ cs/0x00unsigned int fdchar *bufsize\_t count---
...
3closeman/ cs/0x03unsigned int fd-----
...
59execveman/ cs/0x3bconst char *filenameconst char const argvconst char const envp---
60exitman/ cs/0x3cint error_code-----
...

其中,%rax 寄存器寄存 syscall 的 id。%rdi / %rsi / %rdx 别离寄存第一到三个参数。

syscall 机器指令

过程从用户态过程内核态,CPU 须要执行一个计算机指令码

计算机指令名字:syscall

计算机指令编码:0x050f

具体的原理阐明可见:https://thomasw.dev/post/killbutmakeitlooklikeanaccident/

gdb 不间接反对在过程中长期间接 eval(执行) 一个汇编指令。咱们只能另想它法:间接把指令码写到 %rip 寄存器指向的内存地址中。原理是 %rip 就是指向以后线程下一个要执行的指令,而咱们就是想马上执行一个 syscall。

步骤

搭建试验指标环境

环境阐明:

  • node: 192.168.122.55

    • Ubuntu 22.04.2 LTS
    • kernel: 5.4.0-152-generic
    • hostname: worknode5
    • gdb 9.2

我应用 docker.io/fortio/fortio 作为例子:

docker pull docker.io/fortio/fortio

fortio 是一个 http 测试服务端,listen 了一些端口。它是由 golang 编写的动态链接的可执行文件。即不应用 glibc 动静库。

运行 pod:

kubectl delete StatefulSet fortio-server-l2kubectl apply -f - <<"EOF"apiVersion: apps/v1kind: StatefulSetmetadata:  labels:    app: fortio-server-l2  name: fortio-server-l2spec:  podManagementPolicy: OrderedReady  replicas: 1  revisionHistoryLimit: 10  selector:    matchLabels:      app: fortio-server-l2  serviceName: ""  template:    metadata:      labels:        app: fortio-server-l2        app.kubernetes.io/name: fortio-server-l2    spec:      affinity:        nodeAffinity:          requiredDuringSchedulingIgnoredDuringExecution:            nodeSelectorTerms:            - matchExpressions:              - key: kubernetes.io/hostname                operator: In                values:                - worknode5      containers:      - args:        - server        - -M        - 8070 http://fortio-server-l2:8080        command:        - /usr/bin/fortio        image: docker.io/fortio/fortio        imagePullPolicy: IfNotPresent        name: main-app        ports:        - containerPort: 8080          name: http          protocol: TCP        - containerPort: 8070          name: http-m          protocol: TCP        - containerPort: 8079          name: grpc          protocol: TCP        resources: {}        terminationMessagePath: /dev/termination-log        terminationMessagePolicy: File      dnsPolicy: ClusterFirst      imagePullSecrets:      - name: docker-registry-key      restartPolicy: Always      schedulerName: default-scheduler      securityContext: {}      terminationGracePeriodSeconds: 30EOF

查看 fortio 过程

ssh labile@192.168.122.55 #  ssh 到运行的 worker node# get fortio PIDexport POD="fortio-server-l2-0"fortio_pids=$(pgrep fortio)while IFS= read -r fortio_pid; do    HN=$(sudo nsenter -u -t $fortio_pid hostname)    if [[ "$HN" = "$POD" ]]; then # space between = is important        sudo nsenter -u -t $fortio_pid hostname        export POD_PID=$fortio_pid    fidone <<< "$fortio_pids"echo $POD_PIDexport PID=$POD_PID# 可执行文件无依赖,即也不会依赖 glibcsudo ldd /proc/$PID/root/usr/bin/fortio    not a dynamic executable    ps -f -p $PIDUID          PID    PPID  C STIME TTY          TIME CMDroot        3589    3080  0 02:32 ?        00:00:00 /usr/bin/fortio server -M 8070 http://fortio-server-l2:8080    # 查看关上的文件与 socket(包含 listen socket 和 accepted socket )ls -l /proc/$PID/fdtotal 0lrwx------ 1 root root 64 Jun 22 01:24 0 -> /dev/null`l-wx------ 1 root root 64 Jun 22 01:24 1 -> 'pipe:[36078]'lrwx------ 1 root root 64 Jun 22 01:25 10 -> 'socket:[36963]'lrwx------ 1 root root 64 Jun 22 01:25 11 -> 'socket:[36965]'l-wx------ 1 root root 64 Jun 22 01:24 2 -> 'pipe:[36079]'lrwx------ 1 root root 64 Jun 22 01:25 3 -> 'socket:[36951]'lrwx------ 1 root root 64 Jun 22 01:25 4 -> 'anon_inode:[eventpoll]'lr-x------ 1 root root 64 Jun 22 01:25 5 -> 'pipe:[36522]'l-wx------ 1 root root 64 Jun 22 01:24 6 -> 'pipe:[36522]'lrwx------ 1 root root 64 Jun 22 01:25 7 -> 'socket:[36959]'lrwx------ 1 root root 64 Jun 22 01:25 8 -> 'socket:[36960]'lrwx------ 1 root root 64 Jun 22 01:25 9 -> 'socket:[36962]'

敞开文件与 listen socket

因为将启动过程也会 listen 雷同的端口。而 execve 自身只会清理内存和线程,不会敞开文件与 socket,所以得先手工敞开。

(gdb) info procprocess 3589(gdb) shell ls -l /proc/3589/fdtotal 0lrwx------ 1 root root 64 Jun 22 01:24 0 -> /dev/null` l-wx------ 1 root root 64 Jun 22 01:24 1 -> 'pipe:[36078]'lrwx------ 1 root root 64 Jun 22 01:25 10 -> 'socket:[36963]'lrwx------ 1 root root 64 Jun 22 01:25 11 -> 'socket:[36965]'l-wx------ 1 root root 64 Jun 22 01:24 2 -> 'pipe:[36079]'lrwx------ 1 root root 64 Jun 22 01:25 3 -> 'socket:[36951]'lrwx------ 1 root root 64 Jun 22 01:25 4 -> 'anon_inode:[eventpoll]'lr-x------ 1 root root 64 Jun 22 01:25 5 -> 'pipe:[36522]'l-wx------ 1 root root 64 Jun 22 01:24 6 -> 'pipe:[36522]'lrwx------ 1 root root 64 Jun 22 01:25 7 -> 'socket:[36959]'lrwx------ 1 root root 64 Jun 22 01:25 8 -> 'socket:[36960]'lrwx------ 1 root root 64 Jun 22 01:25 9 -> 'socket:[36962]'# 手工一一 fd 敞开。其实能够思考用 gdb 内置的 while 循环,不过先手工吧# close 的 syscall idset $rax=0x03# close 的 fdset $rdi=10#syscall# 批改 $rip 指向的指令为 syscallset {short}$rip = 0x050f# 执行 $rip 指向的指令stepi(gdb) shell ls -l /proc/3589/fdtotal 0lrwx------ 1 root root 64 Jun 22 01:24 0 -> /dev/nulll-wx------ 1 root root 64 Jun 22 01:24 1 -> 'pipe:[36078]'lrwx------ 1 root root 64 Jun 22 01:25 11 -> 'socket:[36965]'..lrwx------ 1 root root 64 Jun 22 01:25 9 -> 'socket:[36962]'# 手工一一 fd 敞开。其实能够思考用 gdb 内置的 while 循环,不过先手工吧...set $rax=0x03set $rdi=3# syscallset {short}$rip = 0x050fstepi....(gdb) shell ls -l /proc/3589/fdtotal 0lrwx------ 1 root root 64 Jun 22 01:24 0 -> /dev/nulll-wx------ 1 root root 64 Jun 22 01:24 1 -> 'pipe:[36078]'l-wx------ 1 root root 64 Jun 22 01:24 2 -> 'pipe:[36079]'

筹备 execve 的参数

要调用 execve syscall,须要一些参数:

  • char* filepath :执行文件地位
  • char * argv[] :启动参数
  • char *envp[] : 环境变量
过程启动的 argv

/proc/[pid]/stat 文件(文档),通知了咱们:

              (48) arg_start  %lu  (since Linux 3.5)  [PT]                     Address above which program command-line arguments                     (argv) are placed.              (49) arg_end  %lu  (since Linux 3.5)  [PT]                     Address below program command-line arguments (argv)                     are placed.              (50) env_start  %lu  (since Linux 3.5)  [PT]                     Address above which program environment is placed.              (51) env_end  %lu  (since Linux 3.5)  [PT]                     Address below which program environment is placed.

能够通过以下办法获取过程里的内存块地址:

#argvpid=$PID && a=(`sudo cat /proc/$pid/stat`) && echo ${a[48]} 140737488348939

能够看看这个 argv 内存块放了什么:

sudo gdb -p $PID# 10 进制地址变为 16 进制(gdb) p (void*)140737488348939$4 = (void *) 0x7fffffffe70b# 查看地址批(gdb) x/100bc 0x7fffffffe70b0x7fffffffe70b:    47 '/'    117 'u'    115 's'    114 'r'    47 '/'    98 'b'    105 'i'    110 'n'0x7fffffffe713:    47 '/'    102 'f'    111 'o'    114 'r'    116 't'    105 'i'    111 'o'    0 '\000'0x7fffffffe71b:    115 's'    101 'e'    114 'r'    118 'v'    101 'e'    114 'r'    0 '\000'    45 '-'0x7fffffffe723:    77 'M'    0 '\000'    56 '8'    48 '0'    55 '7'    48 '0'    32 ' '    104 'h'0x7fffffffe72b:    116 't'    116 't'    112 'p'    58 ':'    47 '/'    47 '/'    102 'f'    111 'o'0x7fffffffe733:    114 'r'    116 't'    105 'i'    111 'o'    45 '-'    115 's'    101 'e'    114 'r'0x7fffffffe73b:    118 'v'    101 'e'    114 'r'    45 '-'    108 'l'    50 '2'    58 ':'    56 '8'0x7fffffffe743:    48 '0'    56 '8'    48 '0'    0 '\000'    80 'P'    65 'A'    84 'T'    72 'H'

还记得 ps 的输入吗?

ps -f -p $PIDUID          PID    PPID  C STIME TTY          TIME CMDroot        3589    3080  0 02:32 ?        00:00:00 /usr/bin/fortio server -M 8070 http://fortio-server-l2:8080    

可见下面是个内存块,放了 char argv[][] 的内容。咱们晓得,有一个在 stack 中的 char* argv[] 指针数组 ,其中每一个 char* argv[x] 元素都会指向这个下面内存块的每个参数的首个字符。所以只有在 stack 中找到这个 char* argv[] 即可:

(gdb) info proc mappings process 3589Mapped address spaces:          Start Addr           End Addr       Size     Offset objfile            0x400000           0x9b1000   0x5b1000        0x0 /usr/bin/fortio            0x9b1000           0xf8b000   0x5da000   0x5b1000 /usr/bin/fortio            0xf8b000           0xfeb000    0x60000   0xb8b000 /usr/bin/fortio            0xfeb000          0x102c000    0x41000        0x0 [heap]        0xc000000000       0xc000400000   0x400000        0x0 ...      0x7ffff7f9b000     0x7ffff7ffb000    0x60000        0x0       0x7ffff7ffb000     0x7ffff7ffe000     0x3000        0x0 [vvar]      0x7ffff7ffe000     0x7ffff7fff000     0x1000        0x0 [vdso]      0x7ffffffde000     0x7ffffffff000    0x21000        0x0 [stack]

可见 statck 的空间是 0x7ffffffde0000x7ffffffff000,留神,不包含 0x7ffffffff000 0x7ffffffff000 - 1 = 0x7fffffffefff。所以:

find 0x7ffffffde000, 0x7fffffffefff, 0x7fffffffe70b0x7fffffffe3b8

可见,0x7fffffffe3b8 即是传给 main() 的 argv 了。

因程序自身的逻辑不同,不排除有可能找到多个在 stack 的指针指向 0x7fffffffe70b
过程启动环境变量

envargv 同理,这里不说了。

#获取 env[][] 内存块地址pid=$PID && e=(`sudo cat /proc/$pid/stat`) && echo ${e[50]} 140737488348999#获取 char* env[] 内存块地址find 0x7ffffffde000, 0x7fffffffefff, 0x7fffffffe7470x7fffffffe3e0
# 10 进制地址变为 16 进制(gdb) p (void*)140737488348999$4 = (void *) 0x7fffffffe747
可执行文件门路

path 能够间接用下面的 0x7fffffffe70b

执行 execve syscall

# execve 的 syscall idset $rax=0x3b# execve 的 三个参数set $rdi=0x7fffffffe70bset $rsi=0x7fffffffe3b8set $rdx=0x7fffffffe3e0# 批改 $rip 指向的指令为 syscallset {short}$rip = 0x050f# 执行 $rip 指向的指令stepi# gdb 离场detachquit

验证重启

$ kubectl logs -f  fortio-server-l2-001:24:54 I scli.go:90> Starting  1.54.3 h1:c9WIOtp4A2lSvDLs1Y01S6yNirtAvaBJJnTzcv/9G/M= go1.20.4 amd64 linux01:24:54 Fortio 1.54.3 tcp-echo server listening on tcp [::]:807801:24:54 Fortio 1.54.3 udp-echo server listening on udp [::]:807801:24:54 Fortio 1.54.3 grpc 'ping' server listening on tcp [::]:807901:24:54 Fortio 1.54.3 https redirector server listening on tcp [::]:808101:24:54 Fortio 1.54.3 http-echo server listening on tcp [::]:808001:24:54 Data directory is /var/lib/fortio01:24:54 REST API on /fortio/rest/run, /fortio/rest/status, /fortio/rest/stop, /fortio/rest/dns01:24:54 Debug endpoint on /debug, Additional Echo on /debug/echo/, Flags on /fortio/flags, and Metrics on /debug/metrics01:24:54 Fortio 1.54.3 Multi on 8070 server listening on tcp [::]:807001:24:54 I http_forwarder.go:288> Multi-server on [::]:8070 running with &{Targets:[{Destination:http://fortio-server-l2:8080 MirrorOrigin:true}] Serial:false Name:Multi on [::]:8070 client:0xc0001f0f00}01:24:54 I fortio_main.go:292> All fortio 1.54.3 h1:c9WIOtp4A2lSvDLs1Y01S6yNirtAvaBJJnTzcv/9G/M= go1.20.4 amd64 linux servers started!     UI started - visit:        http://localhost:8080/fortio/     (or any host/ip reachable on this server)########### after restarted ############03:08:08 I scli.go:90> Starting  1.54.3 h1:c9WIOtp4A2lSvDLs1Y01S6yNirtAvaBJJnTzcv/9G/M= go1.20.4 amd64 linux03:08:08 Fortio 1.54.3 tcp-echo server listening on tcp [::]:807803:08:08 Fortio 1.54.3 udp-echo server listening on udp [::]:807803:08:08 Fortio 1.54.3 grpc 'ping' server listening on tcp [::]:807903:08:08 Fortio 1.54.3 https redirector server listening on tcp [::]:808103:08:08 Fortio 1.54.3 http-echo server listening on tcp [::]:808003:08:08 Data directory is /var/lib/fortio03:08:08 REST API on /fortio/rest/run, /fortio/rest/status, /fortio/rest/stop, /fortio/rest/dns03:08:08 Debug endpoint on /debug, Additional Echo on /debug/echo/, Flags on /fortio/flags, and Metrics on /debug/metrics03:08:08 Fortio 1.54.3 Multi on 8070 server listening on tcp [::]:807003:08:08 I http_forwarder.go:288> Multi-server on [::]:8070 running with &{Targets:[{Destination:http://fortio-server-l2:8080 MirrorOrigin:true}] Serial:false Name:Multi on [::]:8070 client:0xc000254f00}03:08:08 I fortio_main.go:292> All fortio 1.54.3 h1:c9WIOtp4A2lSvDLs1Y01S6yNirtAvaBJJnTzcv/9G/M= go1.20.4 amd64 linux servers started!     UI started - visit:        http://localhost:8080/fortio/     (or any host/ip reachable on this server)