Skip to content

支持从 k8s 临时 debug 容器 attach 目标 JVM(跨 mount namespace)#3247

Open
killingwolf wants to merge 1 commit into
alibaba:masterfrom
killingwolf:feature/k8s-debug-container-attach
Open

支持从 k8s 临时 debug 容器 attach 目标 JVM(跨 mount namespace)#3247
killingwolf wants to merge 1 commit into
alibaba:masterfrom
killingwolf:feature/k8s-debug-container-attach

Conversation

@killingwolf

Copy link
Copy Markdown

背景 / 问题

使用 kubectl debug 的临时容器(ephemeral container)调试应用容器里的 Java 进程时,attach 会失败,典型报错依次是:

  1. com.sun.tools.attach.AttachNotSupportedException: Unable to open socket file /tmp/.java_pid<pid>
  2. com.sun.tools.attach.AgentLoadException: Agent JAR not found or no Agent-Class attribute
  3. attach 看似成功(Attach process N success),但 arthas-client connect 127.0.0.1:3658Connection refused

原因分析

临时容器与应用容器共享 pid namespace,但 mount namespace 相互独立,而 JDK 的 attach 机制(sun.tools.attach.LinuxVirtualMachine)默认双方在同一文件系统:

  • socket 不可见:目标 JVM 把 attach socket 建在它自己容器的 ${java.io.tmpdir}/.java_pid<pid>,发起方在自己容器的 tmpdir 里找不到。
  • agent jar 不可达loadAgent 让目标 JVM 去打开 agent/core jar 路径,但那是发起方容器的路径,目标侧不存在;当双方 UID 不同(root 的 debug 容器 + 普通用户应用)时,目标进程也无权读取发起方的 /proc/<pid>/root
  • server 静默不起:即便只把 agent/core 两个 jar 递过去,ArthasBootstrap 启动时还要从 arthas home 读取 arthas-spy.jar(注入 bootstrap classloader,缺失即抛异常)、async-profilerarthas.properties 等资源;资源不全会导致 bind 失败,而 AgentBootstrap 的 binding 线程会吞掉该异常,于是 loadAgent“成功”但 telnet 端口根本没监听。

方案

com.taobao.arthas.core.Arthas#attachAgent 中增加跨 mount namespace 支持,全部逻辑由 inDifferentMountNamespace(targetPid)(比较 /proc/self/ns/mnt/proc/<pid>/ns/mnt自动 gating——普通同容器 / 非 Linux 场景完全不受影响:

  1. 暴露 socket:在发起方 tmpdir 建软链指向 /proc/<pid>/root<targetTmp>/.java_pid<pid>
  2. 复制完整 arthas home:由发起方(通常 root)把整个 arthas home(含 arthas-spy.jar/async-profiler/arthas.properties 等)递归复制进目标容器 tmpdir,返回目标侧本地路径;同 UID 时退回 /proc/<selfpid>/root 前缀方案。
  3. 属主对齐:复制的文件属主改为目标进程 uid/gid(chown),保证目标侧普通用户在 stop 时能删除该临时目录。
  4. stop 清理:复制目录用 arthas-<targetPid> 命名(幂等、止漏)并写标记文件;ArthasBootstrap#destroy 见到标记才递归清理临时目录(正常安装无标记,不受影响)。
  5. attach 后健康校验loadAgent 后主动探测 telnet 端口,失败则明确报错并指路目标的 arthas.log,消除“假成功”。
  6. as.sh:当前用户为 root 时跳过“用户与进程属主一致性”检查(root 可 attach 任意进程,也是上述 chown 的前提);非 root 仍保留原校验。

改动清单

  • core/src/main/java/com/taobao/arthas/core/Arthas.java:跨 mount namespace 的 socket 软链、arthas home 复制、属主对齐、tmpdir 探测告警、attach 后健康校验。
  • core/src/main/java/com/taobao/arthas/core/server/ArthasBootstrap.javadestroy() 见标记清理临时 arthas home。
  • common/src/main/java/com/taobao/arthas/common/ArthasConstants.java:新增临时目录标记常量 CROSS_NS_TEMP_HOME_MARKER
  • bin/as.sh:root 用户跳过属主一致性检查。

兼容性

  • 所有新逻辑仅在检测到“与目标 mount namespace 不同”时触发;常规使用(同容器 attach、非 Linux)行为不变。
  • as.sh 仅对 root 放宽,非 root 的原有校验保持不变。

测试

在 k8s 中手动验证:kubectl debug 临时容器(root)attach 普通用户应用容器(共享 pid namespace):

  • attach 成功并进入控制台;
  • stop 后目标 /tmp/arthas-<pid> 被清理;
  • 故意制造 bind 失败时能看到明确报错并指路 arthas.log,而非“假成功”。

(跨 mount namespace 场景较难做单元测试,主要为手动集成验证。)

局限性与说明

  • 这是一套针对 k8s 临时 debug 容器的 workaround,强耦合 JDK attach 的实现细节(.java_pid 命名、tmpdir 约定、.attach_pid+SIGQUIT 触发),JDK 版本变更可能需要适配;冷启动路径依赖经 /proc/<pid>/cwd.attach_pid,受权限影响。
  • 前置条件:仅 Linux、发起方与目标共享 pid namespace、跨 UID 时发起方需 root。
  • 更“正统”的替代方式(运维层面消除双文件系统):kubectl exec 进应用容器、两容器挂共享 emptyDir、或走 tunnel server。本 PR 面向“无法改部署、又需要直接 attach”的场景。

关联 issue

#3246

…ontainer

When attaching from a k8s ephemeral debug container, the debug container
shares the pid namespace but NOT the mount namespace with the target app
container, which breaks the JDK attach mechanism. This adds cross mount
namespace support (auto-gated by comparing /proc/<pid>/ns/mnt), so normal
same-container / non-Linux usage is unaffected:

- expose the target attach socket via a /proc/<pid>/root symlink
- copy the full arthas home into the target container (incl. arthas-spy.jar,
  async-profiler, arthas.properties), chowned to the target uid/gid
- clean up the temp arthas home on stop/destroy (gated by a marker file)
- post-attach telnet port health check with a clear error pointing to arthas.log
- as.sh: skip the user/owner consistency check when running as root

Co-Authored-By: Claude <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Jul 21, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@hengyunabc
hengyunabc requested a review from Copilot July 21, 2026 10:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

该 PR 为 kubectl debug 临时容器(与目标共享 pid namespace,但 mount namespace 不同)场景下的 Arthas attach 增强:通过暴露 attach socket、复制完整 arthas home 到目标容器可见路径,并在 stop/destroy 时清理临时目录,从而避免“attach 假成功但 server 未真正启动”的问题。

Changes:

  • Arthas#attachAgent 中自动检测跨 mount namespace,并实现 socket 软链暴露、arthas home 递归复制 + chown、attach 后端口健康校验
  • ArthasBootstrap#destroy 中基于标记文件清理跨 ns 场景复制的临时 arthas home
  • as.sh 放宽 root 用户的进程属主一致性校验

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.

File Description
core/src/main/java/com/taobao/arthas/core/Arthas.java 跨 mount ns attach 的核心逻辑:socket 暴露、home 复制/属主对齐、启动健康校验
core/src/main/java/com/taobao/arthas/core/server/ArthasBootstrap.java stop/destroy 时基于 marker 清理跨 ns 临时目录
common/src/main/java/com/taobao/arthas/common/ArthasConstants.java 新增跨 ns 临时目录 marker 常量
bin/as.sh root 用户跳过“当前用户=目标进程属主”的一致性检查

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +897 to +921
private void cleanupCrossNsTempHome() {
try {
File home = new File(arthasHome());
File marker = new File(home, ArthasConstants.CROSS_NS_TEMP_HOME_MARKER);
if (!marker.exists()) {
return; // 正常安装(非跨 ns 临时拷贝),不清理
}
deleteRecursively(home);
logger().info("Cleaned up cross-ns temp arthas home: {}", home);
} catch (Throwable e) {
logger().warn("Failed to cleanup cross-ns temp arthas home: {}", e.toString());
}
}

private static void deleteRecursively(File file) {
if (file.isDirectory()) {
File[] children = file.listFiles();
if (children != null) {
for (File child : children) {
deleteRecursively(child);
}
}
}
file.delete();
}
Comment on lines +303 to +316
try (Stream<Path> stream = Files.walk(arthasHome)) {
for (Path src : (Iterable<Path>) stream::iterator) {
Path dest = targetHome.resolve(arthasHome.relativize(src).toString());
if (Files.isDirectory(src)) {
Files.createDirectories(dest);
setPosixPermissions(dest, "rwxr-xr-x");
} else {
Files.createDirectories(dest.getParent());
Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING);
// 放开 other 读权限,确保目标容器里的(普通用户)进程可读;源文件可执行则保留执行位
setPosixPermissions(dest, Files.isExecutable(src) ? "rwxr-xr-x" : "rw-r--r--");
}
}
}
Comment on lines +401 to +430
private static String detectTargetTmpDir(long targetPid) {
boolean cmdlineReadable = false;
try {
String cmdline = new String(Files.readAllBytes(Paths.get("/proc/" + targetPid + "/cmdline")));
cmdlineReadable = true;
for (String arg : cmdline.split("\0")) {
if (arg.startsWith("-Djava.io.tmpdir=")) {
return arg.substring("-Djava.io.tmpdir=".length());
}
}
} catch (Throwable e) {
// ignore
}
try {
String environ = new String(Files.readAllBytes(Paths.get("/proc/" + targetPid + "/environ")));
for (String env : environ.split("\0")) {
if (env.startsWith("TMPDIR=")) {
return env.substring("TMPDIR=".length());
}
}
} catch (Throwable e) {
// ignore
}
if (!cmdlineReadable) {
// 连 cmdline 都读不到(通常是权限不足),无法确认目标是否用了自定义 tmpdir——明确告警而非静默
AnsiLog.warn("Cannot read /proc/{}/cmdline to detect target java.io.tmpdir (permission denied?), "
+ "assuming /tmp. If the target uses a custom tmpdir, attach may fail.", targetPid);
}
return "/tmp";
}
Comment on lines +278 to +286
private static String[] resolveAgentPathsForTarget(long targetPid, String agentJar, String coreJar) {
try {
return copyArthasHomeIntoTarget(targetPid, agentJar, coreJar);
} catch (Throwable e) {
AnsiLog.warn("Copy arthas home into target failed ({}), fallback to /proc/self/root path", e.toString());
String selfRoot = "/proc/" + PidUtils.currentPid() + "/root";
return new String[] { selfRoot + agentJar, selfRoot + coreJar };
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants