跳到主要内容

使用 neovim 远程调试 k8s 容器内的 golang 应用

概述

云原生时代,大量应用使用容器化部署在 k8s 内,且使用 golang 编程语言开发的应用越来越多。在开发过程中,如何调试容器内的 golang 应用呢?本文将介绍如何使用 neovim 远程调试 k8s 容器内的 golang 应用。

什么情况下需要远程调试?

复现一些 bug 需要一定的复现条件,且应用之间还有依赖,不方便直接在本地调试,此时最好的方式是直接调试 k8s 容器里正在运行中的 go 进程。

neovim 依赖插件

确保 nvim-dap 插件安装好。

由于远程调试不需要在本地启动 dap server 来调试,所以不需要安装 nvim-dap-go

neovim 配置

local dap = require("dap")
dap.adapters.dapserver = {
type = "server",
host = "127.0.0.1",
port = 40000,
}
local resolved_path = vim.fn.getcwd() .. "/debug-roc/launch.json"
if not vim.loop.fs_stat(resolved_path) then
return
end
require("dap.ext.vscode").load_launchjs(resolved_path, { server = { "go" } })

以上配置仅供参考,可根据实际需求进行修改,相关解释:

  • 加载并解析 vscode 格式的 launch.json 文件来配置项目的调试信息,配置文件默认路径是项目下的 .vscode/launch.json,我这里改成了 debug-roc/launch.json 路径。
  • nvim-dap 插件作为 dap client,需要连接 dap server 进行调试,我这里固定将 server 地址设置为 127.0.0.1:40000
  • load_launchjs 传入的 server 列表是哪些文件类型需要解析 launch.json 配置(执行 :DapContinue 命令弹出的调试选项列表将包含解析到的调试配置)。

编译 delve

调试 go 应用需要使用 delve ,这里我们需要编译一个 dlv 二进制,用于在容器内启动调试服务。

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags '-extldflags "-static"'

这里使用的是静态编译,确保编译出来的二进制不依赖于容器内的动态库,避免因环境依赖问题导致 dlv 无法启动。

然后将编译出来的 dlv 二进制拷贝到正在运行的容器内:

kubectl cp -c <CONTAINER> ./dlv <POD>:/dlv

启动 delve

使用 kubectl 启动 delve,作为 dap server 监听一个端口:

kubectl exec -it -c <CONTAINER> <POD>  -- /dlv dap -l 0.0.0.0:40000

如果怕因 kubectl 断开导致 delve 也退出,可以先 exec 进去然后使用 nohup 命令启动 dlv (依赖容器内包含 nohup 命令):

kubectl exec -it -c <CONTAINER> <POD> -- sh
nohup /dlv dap -l 0.0.0.0:40000 &

端口转发

使用 kubectl 进行端口转发,将 delve 监听的端口转发到本地:

kubectl port-forward pod/<POD> 40000:40000

注意端口要与前面 neovim 的 dap adapter 配置中的 port 一致。

配置 launch.json

根据 neovim 的 dap 相关配置,在相应路径下新增调试配置文件(默认是 .vscode/launch.json,我使用的是 debug-roc/launch.json):

launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "dapserver",
"request": "attach",
"name": "Debug Go (Attach to Remote Process)",
"mode": "local",
"processId": 1
}
]
}
  • typedap.adapters 下新增的 adapter 字段名对应。
  • mode 为 local 是指相对于 dap server 的本地调试,这里 dap server 是 dlv,是远程部署在容器的,而目标调试进程相对于 dlv 来说就是本机进程。
  • processId 是目标调试进程的 pid,这里是 1,因为一般容器内的应用是以 1 号进程启动的,如有不同可根据实际情况调整。

编译容器镜像的注意事项

  1. 尽量使容器内 go 应用二进制和 dlv 二进制使用相同 go 版本进行编译,避免出现一些版本不兼容问题。
  2. 确保容器内的 go 应用在 go build 时没有加上 -trimpath 参数,否则下的断点不起作用。
  3. 确保容器内的 go 应用二进制是由 neovim 所在机器上的 go 命令编译,否则下的断点不起作用。

有些项目 Dockerfile 使用多阶段构建,不依赖本机的 go 命令进行编译,这种情况使得第 3 点条件无法满足,可以考虑在专门为调试新增一个 Dockerfile,使用本机 go 命令编译二进制,然后用 COPY 指令将其拷贝到镜像内。

开始调试

使用 neovim 打开 go 项目,在需要下断点的地方执行 :DapToggleBreakpoint 命令,然后执行 :DapContinue 选择调试配置,选到这里配置的 Debug Go (Attach to Remote Process),然后调试就开始了,想办法按照复现条件触发一下,让进程逻辑走到断点处,然后就可以停在断点处进行调试了。

高端玩法

如果经常需要远程调试,按照前面的做法,启动调试还挺麻烦,下面介绍下高级玩法,配置更复杂,但配置完后可以让每次启动调试变得很简单。

自动编译 dlv 二进制

在项目的 Makefile 中加入 dlv 目标:

dlv:
git clone --depth 1 https://github.com/go-delve/delve
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -C delve/cmd/dlv -o ${PWD}/dlv -ldflags '-extldflags "-static"'
rm -rf delve # 编译完后只保留 dlv 二进制,删除 delve 源码目录

当第一次执行 make dlv 时,会自动下载 delve 源码并编译 dlv 二进制到项目根目录,后面执行 make dlv 时,由于已经有 dlv 二进制了,不会再重复进行下载和编译。

同时将 dlv 二进制加到 .gitignore:

.gitignore
dlv

将 dlv 打包到 debug 镜像

专门为 debug 打包容器镜像,在 Makefile 中判断环境变量,如果设置了 DEBUG,就执行编译 debug 镜像的逻辑。几种思路:

  1. 专门为调试新增一个 Dockerfile,比如 debug.Dockerfile,使用本机 go 命令编译 go 应用二进制,然后用 COPY 指令将其拷贝到镜像内,同时也将 dlv 二进制拷贝到镜像内。示例 Makefile:

    .PHONY: build
    build:
    @if [ "$(DEBUG)" != "" ]; then \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app -ldflags '-extldflags "-static"' ; \
    docker buildx build --platform="linux/amd64" -f debug.Dockerfile -t myapp:debug; \
    else \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o app -ldflags '-extldflags "-static"' ; \
    docker buildx build --platform="linux/amd64" -f Dockerfile -t myapp:latest; \
    fi
  2. 如果 Dockerfile 中本身就用了 COPY 指令拷贝目录到镜像,且目录中包含项目编译出的二进制,此时可以在 Makefile 中控制镜像编译脚本,将 dlv 二进制先拷贝到将要被 COPY 的目录中,然后在编译镜像时由于会 COPY 这个目录,所以 dlv 二进制也就会顺便被打包到镜像内,也不需要修改 Dockerfile 了。示例 Makefile:

    .PHONY: build
    build:
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/app -ldflags '-extldflags "-static"'
    @if [ "$(DEBUG)" != "" ]; then \
    cp dlv bin/dlv
    fi
    docker buildx build --platform="linux/amd64" -t myapp:latest

为调试使用专属部署 yaml

修改 workload 部署 yaml,在需要调试的容器内加 postStart,使用 nohup 启动 dlv dap 服务:

deployment.yaml
          lifecycle:
postStart:
exec:
command: ["/bin/sh", "-c", "nohup /app/bin/dlv dap -l 0.0.0.0:40000 &"]

可以考虑给 Makefile 增加 deploy.debug 目标,部署 debug 专用 yaml 到 k8s 环境中。