jimyag's Blog

Kubernetes Ephemeral Container:在线调试正在运行的 Pod

最后更新:

当一个 Pod 已经运行在生产节点上,而问题只在特定请求、特定节点或特定时刻出现时,重新构建镜像、重新部署 Pod 往往会让现场消失。kubectl exec 可以进入正在运行的容器,但它依赖目标镜像中已经存在 shell、ps、curl 或调试符号;对于 distroless 和极简镜像,这个条件经常不成立。

Ephemeral Container 允许通过 Kubernetes API 向已有 Pod 添加临时调试容器,无需重建 Pod。调试容器共享 Pod 网络,可以挂载已有 volume;运行时支持时,还可以加入目标容器的进程命名空间。它用于现场检查,不承担普通应用容器或 init container 的职责。

下面沿 API 追加、kubelet 创建和运行时命名空间设置解释它的实现,再用两组实验区分 Pod 级共享进程命名空间与 --target 指定目标的行为。

Ephemeral Container 从 Kubernetes v1.25 起为 Stable,定位见官方文档。原有 API 校验分析固定到 commit c028ba348dbaea5e8b0df94b2581e70d687a77c8;原实验使用 Kubernetes v1.35.0,新增的 zj 实验与运行时调用分析使用 v1.35.1。两组实验的设置和证据分别列出。

为什么 kubectl exec 不够

kubectl exec 的本质是在目标容器的现有进程环境中执行命令。它很轻量,但要求目标容器已经包含要执行的程序:

1
2
3
4
5
# 进入目标容器的 shell;目标镜像没有 sh 时会失败。
kubectl exec -it <pod-name> -c app -- sh

# 直接执行诊断命令;目标镜像没有 ps 或 curl 时同样会失败。
kubectl exec <pod-name> -c app -- ps

Ephemeral Container 则允许使用另一个调试镜像:

1
2
# 向已经运行的 Pod 注入一个带网络和进程诊断工具的调试容器。
kubectl debug pod/<pod-name> -it --image=nicolaka/netshoot --target=app -- bash

调试镜像和应用镜像可以完全不同。应用可以保持精简、只包含业务二进制;需要排查时,再临时加入包含 sh、ps、DNS 和网络工具的镜像。

这也是 Ephemeral Container 和 sidecar 的分界:sidecar 是 Pod 设计的一部分,应该长期运行并参与应用生命周期;Ephemeral Container 是人为了观察现场临时加入的工具,不应该被 Deployment 模板或业务代码依赖。

先选对 kubectl debug 的用法

kubectl debug 不只用于注入 Ephemeral Container。遇到不同的故障,需要选择不同的调试对象:

  • kubectl debug pod/<pod-name> --image=...:向原 Pod 追加临时容器,适合保留现有网络和进程现场。
  • kubectl debug pod/<pod-name> --copy-to=<debug-pod> --image=...:创建另一个 Pod,适合修改启动命令或更换镜像后排查。它不会复制原进程的内存现场。
  • kubectl debug node/<node-name> --image=...:创建节点调试 Pod,排查范围变成宿主机;这不是向业务 Pod 添加临时容器。

应用反复崩溃时,先读取上一次退出的日志:

1
kubectl logs -n production <pod-name> -c app --previous

目标进程已经退出时,--target=app 无法恢复它。需要改变启动方式才能观察问题时,可以考虑 --copy-to。不过,复制 Pod 不等于复制一套隔离数据:副本可能仍然引用原来的 PVC,其他容器和 init container 也可能执行有副作用的操作。创建前应检查存储引用和启动命令。

三种模式的参数以本机 kubectl debug --help 为准;官方调试指南分别给出了复制 Pod 和节点调试的示例。

API 形态:使用 ephemeralcontainers 子资源

Pod 的普通容器在创建后不能随意添加或删除,Ephemeral Container 是一个例外,但它不是普通的 Pod spec 更新。API 对它提供了单独的子资源:

1
PATCH /api/v1/namespaces/<namespace>/pods/<name>/ephemeralcontainers

因此,下面的 YAML 不能作为创建 Pod 的配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 这个片段只用于说明 API 边界,不能直接 kubectl apply 创建。
apiVersion: v1 # Pod API 版本。
kind: Pod # Pod 类型。
metadata:
  name: invalid-ephemeral-pod # 示例名称。
spec:
  ephemeralContainers: # 该字段只能通过 ephemeralcontainers 子资源更新。
    - name: debugger # 临时调试容器名称。
      image: busybox:1.36.1 # 调试镜像。
      targetContainerName: app # 请求加入已有 app 容器的进程命名空间。
  containers:
    - name: app # Pod 至少需要一个普通容器。
      image: busybox:1.36.1 # 应用镜像。

直接创建这类 Pod 时,API Server 会拒绝 spec.ephemeralContainers。正确做法是先创建不带临时容器的 Pod,再通过 kubectl debug 或直接调用子资源添加它。Kubernetes API 类型的注释在 PodSpec.EphemeralContainers 中明确写出了这一点。

正在加载 GitHub 文件…

添加后,Ephemeral Container 会出现在:

  • .spec.ephemeralContainers:已经加入 Pod 的调试容器定义。
  • .status.ephemeralContainerStatuses:调试容器当前的运行、退出和重启状态。

它们是追加关系。一个调试容器退出后不能通过修改原条目重新启动;如果需要重新调试,要添加一个不同名称的新 Ephemeral Container,或者重新创建 Pod。

kubectl debug 的两个关键参数

--target:选择目标容器

--target=app 会把 targetContainerName 写入 Ephemeral Container。运行时可以据此尝试加入目标容器的 namespace,特别是 PID namespace。它不会把调试容器变成目标容器,也不会复制目标容器的文件系统。

调试容器仍然有自己的 root filesystem。在调试 shell 中读取 /etc,看到的是调试镜像的文件;查看应用文件需要使用已挂载的共享 volume,或下文的 /proc/<pid>/root 路径。

--attach:是否立即连接终端

指定 -i 时,--attach 默认启用;因此交互式排查通常直接写 -it:

1
2
# 进入一个临时 shell;退出 shell 后该 Ephemeral Container 会结束。
kubectl debug -it pod/<pod-name> --image=nicolaka/netshoot --target=app -- bash

自动化验证或需要后台运行一段诊断命令时,可以使用 --attach=false,然后通过 kubectl logs 和 kubectl exec 读取结果:

1
2
3
4
5
6
# 后台执行网络和进程诊断命令,并保持调试容器一段时间。
kubectl debug pod/<pod-name> --image=nicolaka/netshoot --target=app --container=netshoot --attach=false -- bash -lc 'echo netshoot-started; ip addr; ss -lntp; ps; sleep 60'

# 查看 Ephemeral Container 的日志和状态。
kubectl logs <pod-name> -c netshoot
kubectl get pod <pod-name> -o yaml

真实排查中,调试命令应尽量只读。临时容器与目标 Pod 共享的网络、volume 和权限边界可能影响线上业务,不能把 kubectl debug 当成没有副作用的观察接口。

用 netshoot 调试指定 Pod

nicolaka/netshoot 是社区维护的网络排障镜像,不是 Kubernetes 内置镜像。它把 bash、curl、dig、nslookup、ip、ss、tcpdump、traceroute 等常用工具放在一起,适合在应用镜像过于精简时临时注入。netshoot 的 官方 README 也把 kubectl debug <pod> -it --image=nicolaka/netshoot 作为 Kubernetes 使用示例。

最直接的方式是指定 namespace、Pod 和目标容器:

1
2
3
4
5
6
7
# 在 production namespace 中调试指定 Pod 的 app 容器。
kubectl debug -n production pod/web-6d8f9c7b6f-x7k2m \
  -it \
  --image=nicolaka/netshoot \
  --target=app \
  --container=netshoot \
  -- bash

这里有两个容易混淆的 namespace:-n production 指 Kubernetes namespace;--target=app 指 Pod 内的目标容器。Pod 内的容器本来就共享网络 namespace,所以 netshoot 可以直接检查 Pod IP、端口和 DNS;--target 主要用于让运行时尝试加入目标容器的进程 namespace。Kubernetes 调试运行中 Pod 的文档也提醒,运行时需要支持这个能力,否则调试容器可能仍然看不到目标容器的进程。它不会复制 app 容器的 root filesystem,也不会让 netshoot 自动拥有目标容器的全部权限。

如果 Pod 名称由 Deployment 或 StatefulSet 动态生成,可以先用 label selector 选择。不要直接把 selector 的第一个结果交给 kubectl debug,因为 selector 可能匹配多个副本;一个可复用的安全写法是先要求它只匹配一个 Running Pod:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 选择 production namespace 中 label 为 app=web 的唯一 Running Pod。
namespace=production
selector='app=web'
target=app

pods="$(kubectl get pods -n "$namespace" -l "$selector" \
  --field-selector=status.phase=Running \
  -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')"
count="$(printf '%s\n' "$pods" | awk 'NF {count++} END {print count + 0}')"
if [ "$count" -ne 1 ]; then
  echo "expected exactly one Running Pod" >&2
  printf '%s\n' "$pods" | sed '/^$/d' >&2
  exit 1
fi

pod="$(printf '%s\n' "$pods" | awk 'NF {print; exit}')"
kubectl debug -n "$namespace" "pod/$pod" \
  -it \
  --image=nicolaka/netshoot \
  --target="$target" \
  --container=netshoot \
  -- bash

selector 匹配不到 Pod 或匹配到多个 Running Pod 时,脚本会直接退出并打印候选名称,避免误进入错误副本。这里只检查 Pod phase 为 Running,并没有把 Ready 当成注入 Ephemeral Container 的前置条件;如果问题本身就是 readiness 失败,仍然可以调试这个 Pod。

为了减少重复输入,我在 Chezmoi 中维护了一个本地命令 kdebug-pod,源文件是 home/private_dot_local/bin/executable_kdebug-pod。它要求显式传入 namespace,并支持精确 Pod 名称或 selector:

1
2
3
4
5
6
7
8
9
# 精确调试一个 Pod;-t 用于查看 app 的进程。
kdebug-pod -n production -p web-6d8f9c7b6f-x7k2m -t app

# 只对唯一的 Running Pod 执行调试。
kdebug-pod -n production -l 'app=web' -t app

# 不连接终端,在后台执行一组诊断命令。
kdebug-pod -n production -l 'app=web' --detach -- \
  bash -lc 'ip addr; ss -lntp; dig kubernetes.default.svc.cluster.local; sleep 60'

命令默认注入名为 netshoot 的 Ephemeral Container;同一个 Pod 已经注入过同名容器时,可以用 --container 换一个名字。镜像默认使用 nicolaka/netshoot,也可以通过 -i 或 KDEBUG_IMAGE 指定经过组织审核、带固定 tag 或 digest 的镜像。生产环境还需要给操作者授予对应的 pods/ephemeralcontainers RBAC 权限,并审慎限制调试镜像的能力。

进程命名空间:为什么实验里能看到 app

实验目标 Pod 设置了:

1
2
3
# 这个字段让 Pod 内的容器共享 PID namespace。
spec:
  shareProcessNamespace: true # 调试容器可以观察其他容器的进程。

应用容器和调试容器仍然是不同的容器,但 ps 可以看到同一个 Pod 中的进程。kind 实验中的输出类似:

1
2
3
4
PID   USER     TIME  COMMAND
    1 65535     0:00 /pause
    7 root      0:00 sh -c echo app-started; while true; do date; sleep 5; done
   18 root      0:00 bash -lc echo netshoot-started; ip addr; ss -lntp; ps; sleep 60

这里能看到 app 和 netshoot 的 shell,是因为实验显式开启了 shareProcessNamespace。如果没有共享进程命名空间,--target 仍然可以帮助运行时选择 namespace,但不同运行时、不同安全设置下能够观察到的进程范围可能不同;排查脚本不能只依赖某一个 ps 输出。

共享进程命名空间也会扩大观察和操作范围。调试镜像中的用户、能力和安全上下文需要谨慎设置,特别是不能默认给调试容器增加 privileged 或宿主机 namespace。

通过 /proc/<pid>/root 查看应用文件

在 Linux 中,如果调试容器能看到目标进程,而且访问权限允许,可以通过 /proc/<pid>/root 访问该进程看到的根文件系统。官方共享进程命名空间示例使用的就是这个方法。

1
2
3
4
5
6
7
8
9
# 在调试容器内执行,先确认应用进程的 PID。
ps -ef

# 7 只是上文实验中的 PID,实际使用时替换为刚刚查到的应用 PID。
app_pid=7
ls -l "/proc/$app_pid/root/"

# nginx 示例:路径必须在目标进程的文件系统中存在。
cat "/proc/$app_pid/root/etc/nginx/nginx.conf"

这不要求应用镜像包含 shell,也不需要把应用目录复制到调试镜像。目标进程退出后,这条路径便失去对应的进程;应用重启后必须重新确认 PID。

能在 ps 中看到进程,不代表能读它的全部文件。访问仍受 Linux 进程访问检查、文件权限和安全策略限制。遇到 Permission denied 时,应检查目标进程与调试进程的用户身份及安全上下文,按需要申请权限。通过这个路径写文件也可能直接修改应用数据,检查配置时优先使用只读命令。

在 zj 上验证目标进程与文件视图

另一组 Kubernetes v1.35.0 实验使用 Pod 级 shareProcessNamespace。这次在 zj 上另建 kind v0.33.0、Kubernetes v1.35.1 集群,刻意不启用这个字段,只通过 kubectl debug --target=app --profile=general 请求加入目标进程命名空间。

应用启动时在自己的根文件系统写入 /application-only,内容为 app-private-root,随后保持运行。临时容器使用 Python 镜像,命令在 45 秒后自行退出。实际观察是:

检查项结果
调试容器直接访问 /application-only不存在
调试容器读取 /proc/1/root/application-only得到 app-private-root
应用和调试容器的网络 namespace 标识相同
两个容器的实际 UID本次均为 0
临时容器命令结束后状态为 Terminated,定义仍保留在 Pod 中

这次结果确认了两个容易混淆的事实:加入目标 PID namespace 后可以通过目标进程访问它的文件视图,但临时容器自身的根文件系统没有变成应用的根文件系统。实验中的 PID 1 是这次目标进程,不应该作为其他 Pod 的固定 PID。

本次两个镜像都以 root 运行,调试容器使用 general profile,所以不能据此推断非 root 用户或更严格安全策略下也能读取相同路径。--target 选择进程命名空间,不负责把调试进程的 UID 和安全上下文变成应用的配置;权限仍需要单独核对。

实验代码与使用说明见 k8sdev/deep-dive-ephemeral。

Ephemeral Container 的限制

Kubernetes 对 Ephemeral Container 做了有意的限制,因为它不应该成为正式工作负载的一部分:

能力Ephemeral Container
自动重启不支持
resources不允许设置,不能参与新的 Pod 调度保证
ports不允许设置
lifecycle不允许设置
liveness/readiness/startup probe不允许设置
创建时写入 Pod YAML不支持,只能通过子资源追加
删除单个已加入的容器不支持
静态 Pod不支持

源码中的 validateEphemeralContainers 使用允许字段列表拒绝资源、端口、生命周期和探针,并要求 targetContainerName 指向一个已有的普通容器。对应实现位于 validation.go。

正在加载 GitHub 文件…

调度器不会为临时调试容器预留新的资源请求,但调试进程仍会消耗节点上的 CPU、内存和进程数。使用过量可能影响目标 Pod,甚至触发驱逐。

不允许设置 ports 指不能在容器规约中声明端口,并不阻止调试进程监听端口。它与应用共享 Pod 网络,启动 HTTP 服务或代理时仍可能与应用争用相同的监听地址和端口。

kubelet 如何处理它

Ephemeral Container 的生命周期和普通容器不同:

  1. API Server 通过 ephemeralcontainers 子资源保存新的容器定义。
  2. kubelet 发现 .spec.ephemeralContainers 中存在尚未启动的条目。
  3. kubelet 可以在初始化尚未完成时启动 Ephemeral Container,这让管理员可以调试卡在 init 阶段的 Pod。
  4. 容器退出后,kubelet 不会根据 Pod 的 restart policy 重新启动它。

指定 targetContainerName 时,kubelet 先在 Pod 的运行时状态里找到目标容器,取得 container ID。生成 Linux 容器配置时,如果 PID namespace 原本是容器独立模式,就把 CRI 的 mode 改为 TARGET,并填入 TargetId;Pod 已配置共享进程命名空间时则沿用共享模式。运行时根据这些参数创建调试进程。路径见 v1.35.1 的 getTargetID 与 Linux namespace 配置。这解释了为什么 --target 依赖目标容器存在和运行时支持,也解释了它为什么不替换调试镜像的根文件系统。

在 kuberuntime_manager.go 中,临时容器使用单独的启动列表;已有运行或退出记录的条目不会像普通容器那样根据重启策略反复启动。允许在初始化完成前启动的处理与状态判断见原源码快照:

正在加载 GitHub 文件…
正在加载 GitHub 文件…

这条实现路径解释了一个很实用的能力:即使应用 Pod 被 init container 卡住,管理员仍然可以注入一个调试容器,查看 volume、网络和节点运行时提供的现场信息。

一次性执行怎样体现在状态判断中

v1.35.1 的 computePodActions遍历临时容器定义时,只把 FindContainerStatusByName 没有找到状态的条目加入启动列表。判断条件是“没有状态”,不是“当前没有 Running”。所以已经退出、状态为 Terminated 的临时容器不会再次进入这个列表;本文实验观察到退出状态保留,正好对应这个条件。

这个循环位于后续初始化进度检查之前,因此已有沙箱等运行前提满足时,可以在普通 init container 尚未完成的 Pod 中执行调试。它没有承诺在沙箱无法建立、目标容器根本不存在时也能成功 --target。调试 init 故障时,要区分“Pod 还没初始化完”和“要加入的目标进程命名空间还不存在”。

临时容器定义又不能像普通独立 Pod 那样从现有 Pod 中单独删除后重建。再次调试需要追加不同名称的条目,并接受旧定义与退出状态继续保留。这使“这次调试已经结束”和“Pod 从未注入过调试容器”成为两个不同状态;清理终端进程不会还原 Pod 的定义。

--target 只设置所需的进程命名空间目标,调试容器仍使用自己的镜像与挂载配置。于是“能看到应用 PID”和“自己的 / 里有应用文件”可以同时一真一假。通过 /proc/<pid>/root 访问目标根目录,还需要目标进程存在,并满足 Linux 对该访问的权限检查;本次相同 UID 的实验结果不能直接推广到任意安全上下文。

共享进程命名空间的实验结果

原有 Kubernetes v1.35.0 实验得到的结果是:目标 Pod 保持 Running,kubectl debug 成功追加名为 netshoot 的 Ephemeral Container;ip、ss 和 dig 可以在 Pod 网络 namespace 中工作,ps 能看到 app 和 netshoot 的进程;结束调试容器后,restartCount 仍为 0,状态变为 Terminated,不会被 kubelet 自动拉起。

这组实验的代码与使用说明见 k8sdev/ephemeral-container。

调试时的安全边界

RBAC 和准入策略分别检查什么

能执行 kubectl exec,不代表能添加 Ephemeral Container。RBAC 将 pods/exec 和 pods/ephemeralcontainers 视为不同的子资源;可以先检查当前身份在目标 namespace 的授权:

1
2
3
4
kubectl auth can-i get pods -n production
kubectl auth can-i patch pods --subresource=ephemeralcontainers -n production
kubectl auth can-i create pods --subresource=attach -n production
kubectl auth can-i get pods --subresource=log -n production

这些命令分别检查读取 Pod、追加临时容器、建立交互连接和读取日志的权限,不是一条命令通过就覆盖后续全部操作。具体拒绝了哪个请求,以 API 返回的 verb 和 resource 为准。子资源的授权方式见 RBAC 文档。

RBAC 放行后,请求还可能被 Pod Security Admission 或其他准入策略拒绝。Pod Security Standards对 ephemeralContainers 同样检查 privileged、capabilities 和运行用户等字段。遇到这类拒绝,应调整调试容器的安全上下文或使用集群批准的排障方式。

显式选择调试 profile

--profile 为调试容器设置一组安全上下文。baseline、restricted 对应较受限的配置,netadmin 用于需要网络管理能力的排查,sysadmin 会请求 privileged。不要把 --profile=sysadmin 当作所有 Operation not permitted 错误的通用修复。

profile 的默认值会随 kubectl 版本变化,使用前查看本机帮助并显式选择。restricted 也不保证任意镜像都能运行:镜像的用户、文件权限和所需能力仍要与安全上下文兼容。需要细调时,--custom 可以叠加部分容器配置,但不能借此修改 Pod 级配置。具体字段见官方 profile 说明。

实际集群还需要明确:

  • 调试镜像是否来自可信仓库,是否包含不必要的网络或文件修改工具。
  • 是否允许共享 PID、网络、IPC 或宿主机 namespace。
  • 调试容器是否会读取 Secret、token 或其他敏感 volume。
  • 如何审计注入行为,以及如何清理保存了敏感现场的日志或文件。

如果只是查看应用日志,优先使用 kubectl logs;如果目标容器已经有 shell,优先使用 kubectl exec。只有当现有镜像缺少工具、需要观察其他进程,或需要保留运行现场时,才使用 Ephemeral Container。

注入失败和退出后怎么处理

先区分请求被拒绝、容器没启动,以及容器已经退出。kubectl debug 返回了容器名称,并不表示调试进程已经运行:

1
2
3
4
5
6
7
8
9
# 查看 Ephemeral Containers 状态和 Pod 事件。
kubectl describe pod -n production <pod-name>

# 单独输出临时容器的状态,避免与 app 的状态混淆。
kubectl get pod -n production <pod-name> \
  -o jsonpath='{range .status.ephemeralContainerStatuses[*]}{.name}{"\t"}{.state}{"\n"}{end}'

# 容器已启动过时,再查看它的输出。
kubectl logs -n production <pod-name> -c netshoot
  • API 返回 Forbidden:读完整错误,区分缺少子资源权限与违反准入策略。
  • Waiting 且提示 ErrImagePull 或 ImagePullBackOff:检查镜像名称、拉取凭据和节点到镜像仓库的连接。应用镜像能拉取,不代表调试镜像也能拉取。
  • 容器创建失败:对照事件检查 shell 是否存在、安全上下文是否与镜像兼容,以及运行时报告的目标容器错误。
  • 已经 Running,但 ps 看不到应用:确认 --target 的容器名、应用进程是否仍在运行,以及运行时是否支持加入目标 PID namespace。
  • 已经 Terminated:检查退出码和日志。不能通过 attach 或 exec 让它重新启动,需要添加不同名称的临时容器。

如果只是终端断开,临时容器仍在运行,可以重新连接原来的交互会话,或在该容器里启动另一个 shell:

1
2
3
4
5
# 原来以 -it 启动的 shell 仍在运行时,重新连接它。
kubectl attach -n production <pod-name> -c netshoot -it

# 在仍然运行的调试容器中启动另一个 shell。
kubectl exec -n production <pod-name> -c netshoot -it -- bash

退出交互 shell 会结束以这个 shell 为主进程的临时容器;终端断线是否结束进程则要看连接和程序行为,不能只根据本地窗口判断。结束后确认状态为 Terminated,保留条目直到 Pod 正常回收即可。临时容器中的后台进程、共享 volume 上的文件和导出的抓包需要分别检查,不要用删除业务 Pod 代替清理。

#Kubernetes #Ephemeral Container #Pod #Debug