Please enable Javascript to view the contents

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

 ·  ☕ 8 分钟

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

Mr. Bean

目标

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 原理,可能是时候补补课了。以下是一些我的参考资料:

寄存器

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

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

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

NR syscall name references %rax arg0 (%rdi) arg1 (%rsi) arg2 (%rdx) arg3 (%r10) arg4 (%r8) arg5 (%r9)
0 read man/ cs/ 0x00 unsigned int fd char *buf size_t count - - -
3 close man/ cs/ 0x03 unsigned int fd - - - - -
59 execve man/ cs/ 0x3b const char *filename const char *const *argv const char *const *envp - - -
60 exit man/ cs/ 0x3c int 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 作为例子:

1
docker pull docker.io/fortio/fortio

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

运行 pod:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
kubectl delete StatefulSet fortio-server-l2

kubectl apply -f - <<"EOF"

apiVersion: apps/v1
kind: StatefulSet
metadata:
  labels:
    app: fortio-server-l2
  name: fortio-server-l2
spec:
  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: 30
EOF

查看 fortio 进程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
ssh labile@192.168.122.55 #  ssh 到运行的 worker node

# get fortio PID
export 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
    fi
done <<< "$fortio_pids"
echo $POD_PID
export PID=$POD_PID

# 可执行文件无依赖,即也不会依赖 glibc
sudo ldd /proc/$PID/root/usr/bin/fortio
	not a dynamic executable
	
ps -f -p $PID
UID          PID    PPID  C STIME TTY          TIME CMD
root        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/fd
total 0
lrwx------ 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 proc
process 3589

(gdb) shell ls -l /proc/3589/fd
total 0
lrwx------ 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 id
set $rax=0x03
# close 的 fd
set $rdi=10
#syscall
# 修改 $rip 指向的指令为 syscall
set {short}$rip = 0x050f
# 执行 $rip 指向的指令
stepi

(gdb) shell ls -l /proc/3589/fd
total 0
lrwx------ 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 11 -> 'socket:[36965]'
..
lrwx------ 1 root root 64 Jun 22 01:25 9 -> 'socket:[36962]'

# 手工逐个 fd 关闭。其实可以考虑用 gdb 内置的 while 循环,不过先手工吧
...
set $rax=0x03
set $rdi=3
# syscall
set {short}$rip = 0x050f
stepi
....

(gdb) shell ls -l /proc/3589/fd
total 0
lrwx------ 1 root root 64 Jun 22 01:24 0 -> /dev/null
l-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.

可以通过以下方法获取进程里的内存块地址:

1
2
3
#argv
pid=$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 0x7fffffffe70b
0x7fffffffe70b:	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 的输出吗?

1
2
3
ps -f -p $PID
UID          PID    PPID  C STIME TTY          TIME CMD
root        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 3589
Mapped 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, 0x7fffffffe70b
0x7fffffffe3b8

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

因程序本身的逻辑不同,不排除有可能找到多个在 stack 的指针指向 0x7fffffffe70b

进程启动环境变量

envargv 同理,这里不说了。

1
2
3
4
5
6
7
#获取 env[][] 内存块地址
pid=$PID && e=(`sudo cat /proc/$pid/stat`) && echo ${e[50]} 
140737488348999

#获取 char* env[] 内存块地址
find 0x7ffffffde000, 0x7fffffffefff, 0x7fffffffe747
0x7fffffffe3e0
# 10 进制地址变为 16 进制
(gdb) p (void*)140737488348999
$4 = (void *) 0x7fffffffe747

可执行文件路径

path 可以直接用上面的 0x7fffffffe70b

执行 execve syscall

# execve 的 syscall id
set $rax=0x3b
# execve 的 三个参数
set $rdi=0x7fffffffe70b
set $rsi=0x7fffffffe3b8
set $rdx=0x7fffffffe3e0
# 修改 $rip 指向的指令为 syscall
set {short}$rip = 0x050f

# 执行 $rip 指向的指令
stepi

# gdb 离场
detach
quit

验证重启

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
$ kubectl logs -f  fortio-server-l2-0

01:24:54 I scli.go:90> Starting Φορτίο 1.54.3 h1:c9WIOtp4A2lSvDLs1Y01S6yNirtAvaBJJnTzcv/9G/M= go1.20.4 amd64 linux
01:24:54 Fortio 1.54.3 tcp-echo server listening on tcp [::]:8078
01:24:54 Fortio 1.54.3 udp-echo server listening on udp [::]:8078
01:24:54 Fortio 1.54.3 grpc 'ping' server listening on tcp [::]:8079
01:24:54 Fortio 1.54.3 https redirector server listening on tcp [::]:8081
01:24:54 Fortio 1.54.3 http-echo server listening on tcp [::]:8080
01:24:54 Data directory is /var/lib/fortio
01:24:54 REST API on /fortio/rest/run, /fortio/rest/status, /fortio/rest/stop, /fortio/rest/dns
01:24:54 Debug endpoint on /debug, Additional Echo on /debug/echo/, Flags on /fortio/flags, and Metrics on /debug/metrics
01:24:54 Fortio 1.54.3 Multi on 8070 server listening on tcp [::]:8070
01: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 linux
03:08:08 Fortio 1.54.3 tcp-echo server listening on tcp [::]:8078
03:08:08 Fortio 1.54.3 udp-echo server listening on udp [::]:8078
03:08:08 Fortio 1.54.3 grpc 'ping' server listening on tcp [::]:8079
03:08:08 Fortio 1.54.3 https redirector server listening on tcp [::]:8081
03:08:08 Fortio 1.54.3 http-echo server listening on tcp [::]:8080
03:08:08 Data directory is /var/lib/fortio
03:08:08 REST API on /fortio/rest/run, /fortio/rest/status, /fortio/rest/stop, /fortio/rest/dns
03:08:08 Debug endpoint on /debug, Additional Echo on /debug/echo/, Flags on /fortio/flags, and Metrics on /debug/metrics
03:08:08 Fortio 1.54.3 Multi on 8070 server listening on tcp [::]:8070
03: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)
分享

Mark Zhu
作者
Mark Zhu
An old developer