zhangguanzhang's Blog

dlv命令行的远程调试 golang 进程步骤(包含容器进程)

字数统计: 2.3k阅读时长: 10 min
2021/07/20

前情提要

记录下 dlv 的远程调试,建议不要在代码里加 fmt 去调试。不谈 goland 啥的远程调试,本文章目前只写 dlv 的命令行配合远端调试。

一些前提须知

符号链接路径

1
2
3
4
5
6
7
8
9
10
11
package main

import (
"fmt"
"os"
)

func main() {
f, _ := os.Open("asdasdasd")
fmt.Println(f.Name())
}

上面代码你编译了后,在其他机器上运行,panic 的堆栈信息会是你机器上的路径信息,路径信息是保留的,例如下面的是我在 windows 上交叉编译仍到 Linux 上执行的:

1
2
3
4
5
6
7
8
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x497d50]

goroutine 1 [running]:
os.(*File).Name(...)
D:/Install/Go/src/os/file.go:55
main.main()
D:/github_dir/go/dlv-test/main.go:10 +0x50

可以通过下面的编译选项去掉(项目路径也就是$PWD显示的不要带空格,否则会编译报错):

1
go build -gcflags="all=-trimpath=$PWD -N -l" -asmflags "all=-trimpath=$PWD"  main.go

使用上面的参数编译完后的,这里注意下下面的 D:/Install/Go,后面文章会用到。

1
2
3
4
5
6
7
8
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x0 addr=0x0 pc=0x649857]

goroutine 1 [running]:
os.(*File).Name(...)
D:/Install/Go/src/os/file.go:55
main.main()
main2.go:10 +0x57

dlv 命令行远端调试

很多时候线上机器都是 Linux ,源码在本地,而且机器上不一定会有 golang,也就是说 dlv debug main.go满足的条件实际上并不多。这里主要讲下 dlv execdlv attach
exec 是用 dlv 运行编译完的二进制文件,golang 关闭 cgo 编译的就是静态编译了,运行机器上有无 golang 都能运行。attach 是调试一个已经运行的进程。
这里我是在linux上运行一个编译好的 gin-demo,然后在目标机器上准备一个 dlv 的二进制文件,用 dlv attach 开一个 server,然后我们在本地有源码的 windows 上 dlv connect 连上去调试。

这里以我本地的 windows 和 远端的 Linux 做测试。windows 上和 linux 上都已经安装了 dlv 了,并把路径加到 PATH 里了,windows 我下了 git bash。

demo

根据实际开发流程来,windows 上项目编辑文件 main.go,代码随便写的,不要吐槽。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "github.com/gin-gonic/gin"

func main() {
var count int
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
count++
c.JSON(200, gin.H{
"message": "pong",
"count": count,
})
})
r.Run()
}
1
go mod init test

然后推送到代码仓库上,手动或者 robot 触发 CI 构建

ci 的编译

Dockerfile 如下,为了避免 dlv 调试出现 Warning: debugging optimized function,我们需要在 -gcflags= 里加 -N -l ,为了防止变量被 Dockerfile 解析,$PWD 全部换成了pwd${LDFLAGS}是注入一些 version 信息之类的,可以看我文章git工作流下golang项目的version信息该如何处理

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

FROM golang:1.16.10 as mod
LABEL stage=mod
ARG GOPROXY=https://goproxy.cn,https://mirrors.aliyun.com/goproxy/,https://goproxy.io,direct
WORKDIR /root/myapp/

COPY go.mod ./
COPY go.sum ./
RUN go mod download

FROM mod as builder
LABEL stage=intermediate0
ARG LDFLAGS
ARG GOARCH=amd64
COPY ./ ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${GOARCH} \
go build -o gin-demo \
-gcflags="all=-trimpath=`pwd` -N -l" \
-asmflags "all=-trimpath=`pwd`" \
-ldflags "${LDFLAGS}" main.go


FROM alpine:3.13.5

LABEL MAINTAINER="zhangguanzhang zhangguanzhang@qq.com" \
URL="https://github.com/zhangguanzhang/xxxx"

COPY --from=builder /root/myapp/gin-demo /gin-demo

ENV TZ Asia/Shanghai

RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \
apk update && \
apk add --no-cache \
curl \
ca-certificates \
bash \
iproute2 \
tzdata && \
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo Asia/Shanghai > /etc/timezone && \
if [ ! -e /etc/nsswitch.conf ];then echo 'hosts: files dns myhostname' > /etc/nsswitch.conf; fi && \
rm -rf /var/cache/apk/* /tmp/*

ENTRYPOINT ["/gin-demo"]

然后编译完了,模拟下 CD 部署在 Linux 机器上运行

1
docker run --name t1 --rm -p 8080:8080 test

这里我用容器演示,虽然容器的 pid namespaces 隔离了,但是实际上容器里的所有进程还是会在宿主机上有 pid 对应的。

1
2
$ ps aux | grep gin-dem[o]
root 15598 0.0 0.1 708540 5128 ? Ssl 21:10 0:00 /gin-demo

Linux 上的准备

拷贝 Linux 的 dlv 可执行文件放到目标机器的 PATH 下,推荐按照 LFS 的规范放 /usr/local/bin/ 下,可以先通过 go install 安装,1.16 后者高于 1.16 版本:

1
2
3
4
5
6
7
8
export GOPROXY=https://goproxy.cn,https://mirrors.aliyun.com/goproxy/,https://goproxy.io,direct
export CGO_ENABLED=0
go install github.com/go-delve/delve/cmd/dlv@latest

echo `go env GOPATH`/bin/

# 拷贝 dlv
cp `go env GOPATH`/bin/dlv /usr/local/bin/

http 接口之类的 server 类调试

比如我这个 demo 就是要调试接口内部的,也就是运行后触发的主要运行段是在接口内,所以我们不需要 dlv run, 而是下面这样 attach 执行

1
2
dlv attach $(pgrep gin-demo) --listen=:2345 --headless=true --log=true  \
--log-output=debugger,debuglineerr,gdbwire,lldbout,rpc --accept-multiclient --api-version=2

$(pgrep gin-demo) 也可以换成具体的 pid 。

非接口类服务容器内调试

可能我们的服务一开始就要打断点,也就是 main 开始之类的地方就打断点,用 attach 就不现实了。我们的思路是先把容器起来,然后把 dlv 拷贝进去,dlv exec 执行二进制文件
如果是 docker-compose 可以把容器的命令替换了,类似下面

1
2
3
...
entrypoint: ['sh']
tty: true

k8s 的话也和上面差不多,然后把探针啥的给取消或者 initDelay 调久点。下面我用 docker run 模拟容器下操作,需要添加 ptrace 权限,否则 dlv 执行会报错 could not launch process: fork/exec /gin-demo: operation not permitted

1
2
3
4
5
6
7
$ docker run --name t1 --rm -tid  --cap-add=SYS_PTRACE --entrypoint sh test
$ docker cp $(which dlv) t1:/bin/
$ docker exec t1 ip a s eth0
96: eth0@if97: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
valid_lft forever preferred_lft forever

容器里执行 dlv ,注意添加 --allow-non-terminal-interactive

1
2
3
4
docker exec -ti t1  \
dlv exec /gin-demo --listen=:2345 --allow-non-terminal-interactive --headless=true --log=true \
--log-output=debugger,debuglineerr,gdbwire,lldbout,rpc --accept-multiclient --api-version=2

,然后在另一个 tty 窗口我们利用 socat 转发成宿主机的端口,没有就安装下。开启 socat 转发:

1
socat  -d -d TCP-LISTEN:2345,reuseaddr,fork,bind=0.0.0.0 TCP:172.17.0.2:2345

本机上开始调试

和 os 无关,我这里是 windows 而已,dlv 也要放在 PATH 里,或者绝对路径运行 dlv。要在源码目录运行,因为 docker build 里容器编译的时候去掉源码的前缀路径,所以 dlv 调试按照链接找文件都会按照相对路径找,所以我们需要在windows 上的源码路径里执行。

1
dlv connect 192.168.2.111:2345

连上之后,没有 Linux 的(dlv) 这样的 format,这是 dlv 引用的库在 windows 上的 bug,只要出现了Type 'help' for list of commands.说明成功连上了。

执行下 sources 看下链接路径:

1
2
3
4
5
/usr/local/go/src/...
/usr/local/go/src/...
/usr/local/go/src/..
...
main.go

前面的是 golang 的内部包,最后面的是我们项目路径的,因为我们 build 的时候 trim 掉前缀路径了,所以看着都是相对路径。/usr/local/go 是 golang 的 docker 镜像里的 GOROOT,我们本地 windows 的 go root 可以通过 cmd 或者 git bash 里使用下面的命令查看:

1
2
$ go env GOROOT
D:\Install\Go

源码包已经相对路径存在了,但是 golang 的自带包还没有,我们需要配置映射路径,接着在 dlv 的交互里执行下面的:

1
config substitute-path /usr/local/go D:\Install\Go

然后我们在 main.go:10 打个断点(也可以包名.方法内的相对第几行打断点),然后 c 执行:

1
2
3
b main.go:10
Breakpoint 1 (enabled) set at 0x891be0 for main.main.func1() main.go:10
c

然后我们触发下请求,例如在 Linux 上直接用 curl 触发请求:

1
curl localhost:8080/ping

命令会阻塞住,因为 server 端没回复,我们回到我们的 windows 上,界面有打印下面的。

1
2
3
4
5
6
7
8
9
10
11
12
> main.main.func1() main.go:10 (hits goroutine(33):1 total:2) (PC: 0x891be0)
5: func main() {
6: var count int
7: r := gin.Default()
8: r.GET("/ping", func(c *gin.Context) {
9: count++
=> 10: c.JSON(200, gin.H{
11: "message": "pong",
12: "count": count,
13: })
14: })
15: r.Run()

我们打印下 count 的值

1
2
p count
1

然后 c,让代码 continue 。然后能看到我们的 Linux 上的 curl 命令收到 server 端的请求了。

dlv 里常用的命令如下:

1
2
3
4
5
6
config max-string-len 1000  # 配置打印变量的输出长度,防止被折叠显示
b file.go:数字 # 文件行数打断点
c # 执行到下一个断点
so # 直接执行完所在的当前函数
s # 单步执行
n 数字 # 执行到后面的n行那里

dlv 运行的时候会读取配置文件路径,像上面的config max-string-len 1000config substitute-path /usr/local/go D:\Install\Go 都可以配置在文件里。

参考

CATALOG
  1. 1. 前情提要
  2. 2. 一些前提须知
    1. 2.1. 符号链接路径
  3. 3. dlv 命令行远端调试
    1. 3.1. demo
    2. 3.2. ci 的编译
    3. 3.3. Linux 上的准备
      1. 3.3.1. http 接口之类的 server 类调试
      2. 3.3.2. 非接口类服务容器内调试
    4. 3.4. 本机上开始调试
  4. 4. 参考