容器交互与调试实战:从docker exec到nsenter的排障全指南
我叫它容器黑洞——就是那个docker exec -it进去之后ls命令没有vim命令没有连curl都不认识唯一能用的工具是一个精简到极致的sh或者压根没有 shell。第10章把这部分内容单独拿出来讲我认为非常有必要因为现代 DevOps 和微服务开发里容器交互与调试已经不再是知道几个命令就能应付过去的技能它是一套完整的如何在一个隔离进程里安全、精准、高效地定位问题的思维方式。这篇文章我会围绕容器交互与调试的完整实操链路展开——从 为什么 docker attach 容易踩坑 这种基础直觉一路讲到 为什么在容器里装 vim 是一种罪恶、为什么 tcpdump 都要精简着用最后还会聊一聊调试容器网络时最常见的谜之现象。这篇文章既是给刚入容器坑的初学者掰开揉碎讲原理也是给有几年经验的工程师当作排查手册来翻的。所有内容我都会用我实际敲过的命令、实际翻过的车来佐证。1. 容器交互的两条主线docker exec 与 docker attach 的底层逻辑差异1.1 docker exec 是另起炉灶docker attach 是接入现场很多初学者有一个根深蒂固的误解以为docker exec和docker attach差不多都是进到容器里操作。但实际用起来这两个命令的行为差异会让你栽很多跟头。我用一句话概括它们的关系exec 是另起炉灶attach 是接入现场。docker exec的本质是在一个已经处于运行状态的容器里通过 Docker 守护进程的 API 调用创建一个新的进程并且把新的进程加入到容器的进程命名空间PID namespace、网络命名空间Network namespace、挂载命名空间Mount namespace等里面去。它不是一个进入容器的操作而是一个在容器环境中启动一个新程序的操作。所以当你执行docker exec -it container_id /bin/bash的时候你实际上是让 Docker 在这个容器里跑了一个新的 bash 进程并且把这个进程的标准输入、标准输出、标准错误跟你当前终端绑在了一起。好处非常明显这个 bash 进程跟容器原来的 PID 1即主进程无关你在这个 bash 里做的任何操作包括设置环境变量、启动临时进程、安装临时工具都不会影响容器本身的主进程状态——除非你主动去 kill 它。而docker attach呢它是把当前的终端直接接到容器的主进程PID 1的三个标准流上。也就是说你 attach 进去之后看到的输出是主进程 stdout/stderr 的输出你键入的内容是直接喂给主进程 stdin 的。这就有个致命的问题如果你在 attach 状态下按了 CtrlC这个信号会直接发送给容器的主进程而很多容器主进程并不优雅地处理 SIGINT于是容器直接退出。我见过太多朋友在生产环境里把跑了好几个月的容器给 attach 挂了就是这个原因。如果你只是想去看一眼容器里的情况永远优先用docker exec这是我要强调的第一条血泪经验。进一步拆解的话docker exec还有一个隐藏的参数设计值得注意-i和-t是彼此独立的语义。-iinteractive保持标准输入打开这样你才能往里面敲命令-ttty则分配一个伪终端pseudo-TTY这样命令的输出才会按终端格式排版比如ls会用多列展示vim这类依赖终端交互的程序才能跑。如果不加-t你会发现ls的输出是一行行的裸文件名颜色、对齐全部丢失因为进程不知道自己在终端里。检测一个程序是否运行在伪终端下几乎全靠这个 TTY 标志——很多 CI/CD 系统里跑 docker exec 时经常遇到 the input device is not a TTY 的报错根源就是忘了加-t或者-i。1.2 进入容器后环境变量如何继承注意 exec 的清理机制很多时候我们exec进去之后发现咦SPRING_PROFILES_ACTIVE 环境变量怎么没生效或者 为什么 /etc/profile 里的 alias 没加载这里面其实藏着一个容易被忽略的细节docker exec里的新进程并不是通过 login shell 启动的而是继承容器的初始环境变量并叠加你在 exec 命令中显式指定的环境变量。它不会去读取 /etc/profile、~/.bashrc 这类 shell 配置文件——除非你显式地以交互式登录 shell比如bash -l来启动。所以当你在容器里执行env时看到的变量其实就是容器被创建时通过docker run -e或者 Dockerfile 里ENV指令注入的那份环境配置你在 exec 里export的变量只会对当前这个 exec 会话存在够不着容器主进程。这一点在调试分布式配置中心、链路追踪 Agent比如 SkyWalking、Pinpoint初始化失败的问题时经常会遇到。我曾经排查过一个 Java 应用内存参数老是不生效的问题后来发现是因为运维同学在docker exec进去后用命令行启动了一个子进程做测试子进程自己读到了正确变量但应用主进程根本没收到因为主进程是容器启动时直接由 CMD 拉起来的。容器进程的环境变量拓扑是主进程一份、exec 子进程一份的平行关系绝不会自动同步。2. 调试一个容器的完整排查链路从 docker logs 到进程级观测2.1 docker logs 的底层实现与 docker attach 的微妙关系排查容器问题第一步永远是看日志。但这个环节也藏着不少只知道用、不知道原理的争议点。docker logs container_id输出的是什么是容器主进程关联的标准输出stdout和标准错误stderr的内容。关键点在于Docker 的日志驱动logging driver会在进程写出这些流时截获并重定向到宿主机上的日志文件中默认是 json-file位于/var/lib/docker/containers/container_id/container_id-json.log这就是为什么你docker exec进去看 /var/log/ 下老是没有动静的根源——容器应用如果直接把日志写进了文件而不是 stdout/stderr那么 docker logs 什么都看不到。这一点在调试运行在 Kubernetes 里的容器时尤其重要因为kubectl logs调用的底层机制和docker logs完全一样只跟随容器主进程的 stdout/stderr。如果你在代码里用了 Log4j2 的 File Appender或者输出到文件系统的 Nginx access_log在紧急排查的时候会误以为容器没日志实际上日志都在磁盘里。所以设计一个符合容器哲学的应用第一件事就是把所有日志打到 stdout 和 stderr 去。很多人在使用docker logs -f时一旦连接断开会以为日志漏了。要纠正这个预期docker logs 默认读的是日志文件就算你的-f追踪断开了重新连上再去读之前的内容都还在。真正的问题是日志轮转——默认 json-file 日志驱动不会自动轮转如果不加--log-opt max-size10m --log-opt max-file3这类参数一个长时间运行的容器可能把宿主机的磁盘撑爆。我自己的服务器上就因一次漏配日志轮转导致容器目录吃掉了70多G空间。排查方法也简单用du -sh /var/lib/docker/containers/*/按目录大小排个序就一清二楚。docker logs还有一个隐藏功能——--since和--until时间窗口过滤。我经常用它来追昨晚3点到3点半到底发生了什么比 grep 整份日志高效得多。不过它依赖 json-file 驱动的时间戳字段如果你的自定义日志每一行都没有时间戳这个参数的意义会大打折扣。2.2 docker top / docker stats用宿主机视角看容器进程登录到容器内部用ps命令你会发现一个有趣的现象ps显示的进程列表跟宿主机上的进程列表高度重合——这不是幻觉而是因为 Linux 的 /proc 文件系统是挂载在 PID 命名空间里的容器内的 PID 1 在宿主机上可能是一个几百甚至上千的编号。这样你就会面临一个问题宿主机和容器内的 PID 对不上号排查起来两头懵。好在这个问题 Docker 官方已经给了解决工具就是docker top container_id。它的原理是直接读取命名空间内的进程表并把容器内 PID 和宿主机 PID 做一个双向映射展示。我曾经在一个容器里跑了一个 Node.js 应用它 fork 出了8个子进程其中一个子进程把 CPU 吃满了但容器内我找不到它对应的宿主机线程后来用docker top对照top -Hp才定位到具体线程 ID。我一直认为 docker top 是容器调试中性价比最高的命令没有之一——你几乎不需要任何额外工具就能拿到容器内PID - 宿主机PID - 启动命令的关键链路。docker stats则更偏向资源占用观测。它会主动连接 cgroup 文件系统读取容器的 CPU、内存、网络 I/O、块 I/O 数据。要注意的是默认docker stats展示的内存使用量包括 page cache所以某些内存型应用如 Redis、MySQL看起来内存占用奇高不要急着下结论先看缓存是否可回收。docker stats的实时刷新率也可以定制默认是1秒加--no-stream可以只取一次快照这在脚本监控场景里比实时刷新更实用。2.3 docker inspect结构化地拆解容器的真实状态如果说docker logs是看症状docker inspect就是看病例档案。它会把容器的完整配置、状态、挂载、网络、资源限制等以 JSON 格式输出。我平时排查疑难杂症时docker inspect几乎是必用的下面几个字段是最常查的.State.Status: 容器运行状态注意区分running、restarting、paused、exited、dead。.State.ExitCode: 上一次退出码0 是正常退出137 代表被 SIGKILL 杀掉143 代表被 SIGTERM 杀掉130 则是 CtrlCSIGINT最常见。.RestartCount: 重启次数如果这个数字在不停增长说明容器在进入崩溃循环。.NetworkSettings.IPAddress: 容器当前的 IP 地址不过需要注意的是在用户自定义的 bridge 网络里这个 IP 可能不是唯一入口要结合网络别名和 service name 看。.Mounts: 挂载点的源路径、目标路径和读写权限排查为什么容器里改了文件宿主机没变这类问题特别有效。.Config.Env: 启动时的环境变量快照用于对比运行时是否传入了错误配置。.HostConfig.Privileged: 是否以特权模式运行很多容器内权限报错都源于这个字段。有人会问docker inspect输出太冗长了有没有办法只取我要的字段有。用 Go template 语法比如docker inspect -f {{.State.Pid}} container_id可以直接取到容器主进程在宿主机上的 PID。这是一个非常实用的小技巧尤其在你要把容器内的端口状态和宿主机的进程网络栈做映射时。配合nsenter命令甚至可以不用安装 nsenter 镜像直接在宿主机进入某个容器的命名空间执行任意命令。这在容器里连/bin/sh都没有的场景比如 distroless 镜像下简直是救命稻草。3. 容器网络调试与端口排障三个最容易忽视的坑3.1 为什么在容器里 localhost 不通以及 bridge 网络的原理进入容器交互会话后很多人的第一个网络测试就是curl http://localhost:8080。如果你的容器跑的是默认 bridge 网络并且服务监听在0.0.0.0:8080或者127.0.0.1:8080通常能通。但一旦你的服务监听在特定网卡比如容器的 eth0 的 IP上localhost 就不通了。本质上容器内观察到的 localhost 是容器自己的 lo 接口和宿主机上的 lo 接口完全隔离宿主机的监听端口不会镜像到容器内的 localhost 上。所以我在宿主机上能 curl进了容器反而不通是非常正常的事情别急着怀疑网络被防火墙拦截。更常见的坑是端口映射的代理地址。默认情况下docker run -p 8080:80会把宿主机的所有网卡0.0.0.0上的 8080 端口转发到容器的 80 端口这是通过 docker-proxy一个用户态进程和 iptables DNAT 规则共同完成的。当你从宿主机 curl 127.0.0.1:8080 时实际上流量经过了 docker-proxy 或 iptables能不能通取决于内核参数和防火墙链的配置。很多人在宿主机上启用了 firewalld但给 docker0 和容器网段的流量设置的规则是 DROP导致从外部访问容器端口全失败。这个坑我踩过之后就养成了一个习惯排端口映射问题先看宿主机 iptables 的 FORWARD 链再看容器内服务的监听地址两个条件缺一不可。3.2 容器内没有网络工具时如何做最小化网络诊断如果你exec进的是一个精简镜像比如基于scratch或者alpine未安装完整包你会发现想跑curl、ping、telnet、nc全都提示 command not found。这种情况下我有几个非常高效的替代方案查看/proc/net/tcp和/proc/net/udp来判断端口监听状态。这两份文件是内核直接导出的 TCP/UDP 连接表我写过一个小脚本用cat /proc/net/tcp加awk过滤就能在完全没有 netstat 的情况下看到端口监听情况。注意里面 IP 和端口都是十六进制表示的比如0100007F:1F90表示 127.0.0.1:8080。用/dev/tcp这个 bash 内置特性做 TCP 连接测试。exec 3/dev/tcp/127.0.0.1/3306可以在一个文件描述符上建立 TCP 连接echo 3发送数据cat 3读取响应。这个技巧在容器里没有 nc 时非常好用而且不需要任何额外工具。如果你连 bash 也没有那可以尝试在宿主机用nsenter进入容器的网络命名空间然后用宿主机的网络工具比如curl、ss、ip来诊断。命令大致是nsenter -t $(docker inspect -f {{.State.Pid}} cid) -n ss -tlnp这条命令能直接以容器网络的视角列出监听端口对我来说已经属于日常操作级别。另外要提一句很多人一遇到容器网络问题就下意识 ping 一下但容器里的 ping 依赖 ICMP 协议而默认 bridge 网络下容器对外的 ICMP 可能被主机防火墙过滤。ping 不通 绝对不能等价于网络不通尤其当你要连的是 3306、6379、9200 这类业务端口时最直接的验证是 TCP 连接测试而不是 ICMP。3.3 调试容器间的域名解析/etc/resolv.conf 与 Docker 内嵌 DNS在一个自定义 bridge 网络中Docker 会内嵌一个 127.0.0.11 的 DNS 服务容器内的 /etc/resolv.conf 会被自动改写为指向这个地址。它的作用是做容器名的解析——通过同一个 docker network 里的别名alias或者--network-alias来互相访问。但你创建容器时如果用了--dns参数覆盖了默认 DNS或者把新容器连接到一个已有网络就会出现明明在一个网络里却解析不了对方容器名的情况。我自己遇到最经典的一次问题是A 容器连接了 B 容器所在的 network但 B 容器启动的时候并没有--network-alias配置于是 A 容器里ping b_container_name怎么都不通。后来发现自定义网络中 Docker 是将容器名解析为每个容器在该网络的 IP但只有同时连接到这个网络的容器才具备这个解析能力。如果你用的是docker run --link这种旧式关联在新版本 Docker 上虽然还在用但已经被标记为遗留功能很多 DNS 相关的行为会和自定义 network 不一致。所以我的建议是调试跨容器访问之前先确认两边都在同一个用户自定义网络里并且用docker network inspect验证容器的 IP、别名和 DNS 配置是否如预期。4. 在容器里做应用级交互调试从环境变量到实时跟踪4.1 环境变量注入的优先级命令行、Compose、还是 Dockerfile容器交互调试时最频繁的操作之一就是修环境变量。因为容器是典型的不可变基础设施改配置文件不如改环境变量来得干净。但环境变量本身的注入来源和优先级恐怕不是每个人都说得清楚。我按实际生效顺序总结如下通过docker run -e或者 compose 文件的environment:段传入的变量优先级最高会直接覆盖 Dockerfile 里ENV指定的同名变量。Dockerfile 里的ENV指令声明的变量优先级其次。如果你在docker run里没给同名变量就用这里的值。镜像构建时ARG声明的构建参数只有在构建阶段暴露给 RUN 指令对运行阶段完全不可见除非你把它赋值给 ENV。这个顺序的坑在于许多人会在 Dockerfile 里写ENV JAVA_OPTS-Xmx512m但忘记了运行时已经通过编译平台的默认参数注入了另一个 JAVA_OPTS导致内存配置看起来没生效。排查这类问题时最快的手段是docker inspect -f {{range .Config.Env}}{{println .}}{{end}} container_id——一次性把所有运行时环境变量打出来不用进容器。另一种更符合容器哲学的做法是在docker run里临时覆盖变量并启动一个新容器在相同镜像下做对比验证比如docker run --rm -e JAVA_OPTS-Xmx1g -it my_image env | grep JAVA_OPTS看到输出即刻关闭绝不会污染你的原容器。4.2 容器与宿主机之间的文件交互拖文件进出的正确姿势容器交互调试中还有一个高频需求把宿主机上的调试包、JAR 包、崩溃现场 dump 文件拷贝进容器或者把容器里的日志、堆转储文件拉回宿主机分析。最直接的命令是docker cp。它支持从宿主机拷入容器docker cp ./app.jar container_id:/tmp/也支持从容器拷出docker cp container_id:/var/log/app.log ./。docker cp的机制比较朴素直接通过 Docker API 在容器文件系统和宿主机文件系统之间复制文件不需要容器里安装任何服务端工具。它的一个特性是如果容器正在运行你会拿到该文件此刻的瞬时状态等于活体取样如果容器已停止你依然可以拷贝因为文件系统层还存在。这个特性在做故障复现时特别好用——挂掉的容器我也可以把里面的配置拉出来看不需要把它启动起来。要注意的是docker cp默认的属主和权限处理有时候不那么符合直觉拷入容器的文件经常变成 root:root 或保留原 uid如果你在容器内用非 root 用户跑应用还需要chown或者通过exec进去调整权限。除了docker cp还有一层比它更底层的交互逻辑挂载卷volume和 bind mount。如果你在设计阶段就知道某个目录需要经常交换文件那就直接把这个目录挂载到宿主机的一个指定路径这是效率最高的做法。不过挂载卷有个双刃剑效应——权限映射问题。容器内 uid 和宿主机 uid 经常不一致导致写文件时出现 Permission denied。处理办法是让容器内进程以宿主机的特定 uid比如 1000运行或者在宿主机上 chown 对应目录归属。这属于容器设计层面的交互策略而不是临时调试手段。4.3 在跑起来的容器里临时装调试工具合适吗很多人一遇到容器里缺工具第一反应是apt-get update apt-get install vim。我的观点很明确在生产容器里尽量不要这么干。原因有三你安装的包会临时写到容器的可写层增加容器的文件系统体积和配置漂移风险。下一次容器重建这次安装的包就全部消失这是一种伪持久化。很多生产容器镜像基于极简基座比如 distroless、alpine 的瘦身版根本没有 apt/dpkg 可用你强行apt-get只会收获一行sh: apt-get: not found。容器的不可变性和审计要求决定了临时安装工具会让运维同事在复盘时完全无法还原现场。那调试需要工具怎么办我有几套替代方案使用docker run --rm -it --network container:target_container启动一个带全套诊断工具的临时容器连接目标容器的网络命名空间。这样你的临时容器可以共享对方的网络视角用nmap扫端口、用curl发请求而目标容器本身保持干净。使用docker run --rm -it --pid container:target_container进入目标容器的 PID 命名空间此时你可以用ps看对方进程用strace -p附加到对方的进程做系统调用跟踪——这个操作亲测非常有用尤其是在定位应用卡死、进程挂起这类疑难问题时。利用nsenter在宿主机上直接进入容器的 mount namespace 和 PID namespace借助宿主机的调试工具链来完成操作。回想我自己的一次生产事故排查一个 Java 服务在容器里频繁 Full GCjstat 无法使用容器里没装 JDK我正是用上述第三种方式——在宿主机上用nsenter进入容器的 PID namespace然后直接用宿主机的jmap版本匹配抓堆。整个过程没有对容器做任何一字节的写入唯一记住的是容器和宿主机之间namespace 是一道墙但这道墙的钥匙其实就在 Docker 命令行和 nsenter 里。5. 容器生命周期与状态机为什么容器会停下来、退出码告诉你什么5.1 退出码背后的信号学退出137与强制杀死退出143与优雅停止排查一个反复退出或者陷入 CrashLoopBackOff 状态的容器退出码是第一手信息。这里我整理一份常用退出码对照表它是我处理容器故障时反复查的表格退出码含义常见触发场景0正常退出程序执行完成主动退出1一般性错误程序启动失败、配置错误2误用 shell 内建命令脚本语法错误、参数传错126命令无法执行文件没有执行权限或不是可执行文件127命令找不到如sh: xxx: not found128n由信号 n 杀掉1289137SIGKILL、12815143SIGTERM130由 SIGINT 终止CtrlC137被 SIGKILL 强制杀死内存溢出被 kill、docker stop 超时后强制 kill143被 SIGTERM 终止docker stop 默认先发 SIGTERM等待宽限期137 这个退出码尤其要警惕。很多人看到 137 以为是 OOM内存溢出导致的但 OOM 通常会在 dmesg 或者/sys/fs/cgroup/memory.events里留下记录而且容器退出码确实是 137。但如果宿主机被调度器因为磁盘、CPU 或内存压力主动 kill退出码同样是 137。所以一个稳妥的确认方式是docker inspect看.OOMKilled字段——如果为 true基本可以断定是容器内存超限。如果为 false再看宿主机的 system log 或者主机的 dmesg确认是否有 cgroup 或节点层的回收动作。任何一个退出码都要结合日志和宿主机状态综合分析单看退码下结论极易误判。5.2 负责任地停止一个容器SIGTERM 宽限期与 SIGKILL 兜底容器调试中很多问题的根源其实是对停止容器这个动作的不理解。docker stop默认行为是先向容器主进程发送 SIGTERM然后等待容器退出在指定的停止超时时间默认10秒之后如果容器还没有退出再发送 SIGKILL 强制杀死。这个超时时间可以用-t参数调比如docker stop -t 30 container_id。这就带来一个交互调试的经典难题你的应用在收到 SIGTERM 后有 10 秒时间做清理动作比如关闭数据库连接池、持久化缓存、释放端口但如果你的应用根本没实现信号监听那 SIGTERM 就等于什么都没发生直到超时被 SIGKILL。结果就是容器退出码变成 137而不再是预期的 15 或 143。排查的时候如果发现我明明docker stop了为什么容器是 137先别怀疑 Docker去查你应用有没有真正处理 SIGTERM。从另一个角度Kubernetes 里的 pod 删除也会经过类似流程kubelet 先发 SIGTERM宽限期terminationGracePeriodSeconds默认30秒过了就 SIGKILL。所以容器交互调试的能力直接决定了你能否让云原生环境里的服务做到优雅停机。我自己就见过不少服务因为没处理 SIGTERM导致每次发布都出现短时间 5xx 错误——其实是在强制 kill 的瞬间已有的请求还没来得及返回。5.3 容器重启策略docker restart 与 --restart 的差别调试容器时很多人会遇到明明我 restart 了为什么配置没生效的困惑。这里要分清两个概念docker restart container_id是停止容器再启动容器宿主机上的容器配置比如环境变量、挂载点不会重新读取它只会把同一个容器的文件系统、网络配置和启动命令重新执行一遍。而--restart策略比如--restartalways、--restarton-failure:5、--restartunless-stopped是 Docker 守护进程级别的自动重启策略它会根据退出原因决定是否拉起容器。这里有一个很多人踩过的坑当你对正在运行的容器执行docker stop时即使设置了--restartalways容器也不会被自动重启因为 Docker 将手动 stop视为主动操作明确排除在自动重启之外。但是如果容器是因为崩溃非0退出码退出的--restartalways就会立刻拉起它。这个语义差异在容器交互调试时特别重要——如果你怀疑容器没有自动恢复是因为没配--restart先看看是不是手动 stop 导致的误判。6. 进阶调试姿势从容器视角进入底层把 Namespace 变成利器6.1 利用 nsenter在宿主机直接操作容器的 Namespace前面多次提到 nsenter这里我专门完整演示一下。nsenter 是一个 Linux 原生命令作用是把当前进程加入到指定进程的命名空间里。结合 Docker 提供的容器 PID我们可以在宿主机上直接对容器进行降维打击式调试完全绕开容器内工具缺失的问题。常用组合之一进入容器的网络命名空间查看监听端口和网络状态。# 获取容器主进程在宿主机的 PID pid$(docker inspect -f {{.State.Pid}} container_id) # 进入该容器的网络命名空间执行 ss 命令 nsenter -t $pid -n ss -tlnp-t指定目标 PID-n指定进入网络命名空间。这样ss看到的就是容器网卡的监听情况。比如容器内服务端口是 8080但你在宿主机上怎么都连不上就可以先用这条命令确认容器内是否真的在监听再排查端口映射。常用组合之二进入容器的 PID 命名空间查看完整进程树。nsenter -t $pid -p ps -ef这在你需要确认容器内到底跑了几个进程、主进程是否还活着、有没有产生僵尸进程时非常有用。容器里的僵尸进程Zombie问题是容器化应用最容易忽视的深水区因为它不会直接导致宕机但会积累出资源泄漏。很多 Java 程序 fork 出的子进程没有正确 reap进入容器里ps一看全是defunct这就是没有专门的 init 进程比如 tini、dumb-init来清理僵尸导致的。常用组合之三进入容器的挂载命名空间。不过这个操作对生产环境有风险建议只在分析镜像内容时使用nsenter -t $pid -m ls /usr/local挂载命名空间的切换意味着你可以看到容器视角的文件系统结构但它不像 IPC、网络 namespace 那么无害——如果宿主机上的进程通过这种方式直接写容器文件系统的文件可能会绕过容器镜像的层管理打破不可变容器的假设。建议平时用docker exec就好除非容器里完全没有 shell 才考虑这种方式。6.2 镜像调试死容器docker run --rm 启动一个临时副本有时候我们拿到一个容器的状态是而已退出想看看里面到底装了什么环境、有什么文件但又不希望动到原容器。这时可以用docker run --rm -it --entrypoint /bin/sh image_name临时启动一个基于同一镜像的新容器做探索。把这个技巧做进一步扩展可以用它来做差异对比比如 A 环境报错B 环境正常怀疑是镜像版本问题分别基于两个镜像启动临时容器对比里面的/etc/app/config.yml、/usr/local/lib的库文件列表再用diff -r做差异分析。这种临时副本式的调试方式干净、隔离、不产生遗留非常适合在交互调试阶段频繁使用。要注意的是如果目标镜像的入口脚本本身包含了启动逻辑并且你不想执行它就一定要用--entrypoint覆盖默认入口否则docker run还是会执行镜像的 CMD。一个我常用的覆盖写法是docker run -it --rm --entrypoint /bin/sh nginx:latest # 进去以后随便翻nginx 进程不会起来但 /usr/share/nginx/html 都能看到这样调试静态资源配置文件的生效情况特别清晰。6.3 构建与运行的交互鸿沟为什么 Dockerfile 里能跑容器起来就崩最后一类非常典型的容器交互与调试难题是镜像构建阶段build正常运行阶段run却崩溃。这个阶段的知识基本上属于容器的系统级别交互。原因通常可以归为几类构建时依赖了构建上下文或网络比如RUN curl了一个动态版本号的文件但运行时下载不到同一版本。也就是构建态可重复性没有保障。运行时依赖了宿主机资源比如容器内的服务绑定 CPU 核心数但宿主机 cpuset 只给了1个核心应用初始化就失败。权限模型差异比如容器内以非 root 用户运行但尝试绑定 80 端口。小于 1024 的端口需要 root 权限这在容器里同样适用。解决办法是加NET_BIND_SERVICE能力或者直接映射高端口。挂载目录覆盖问题宿主机挂载了一个空目录到容器内的数据目录导致容器启动时找不到数据文件。这种情况比较隐蔽因为docker logs往往只会透出一点点错误而docker inspect的 Mounts 字段能让你一眼看到挂载源是宿主机空目录。也正因为这些问题我养成了一个习惯每次拿到新镜像第一步不是docker run而是docker history看构建记录docker inspect看镜像暴露的端口、Entrypoint、CMD 和环境变量。把容器当成一个可以拆解的电子设备读清文档再通电能少踩很多雷。7. 容器调试常用命令速查与习惯养成7.1 几条值得刻进肌肉记忆的命令指令集不在多在精。我把自己常用的容器交互与调试命令整理成下面这份速查清单覆盖了80%以上的场景场景命令说明进入容器 shelldocker exec -it cid /bin/bash优先用 bash没有则 sh查看日志docker logs -f --tail200 cidtail 防止刷屏查看进程与资源docker top cid容器内 pid 与宿主机 pid 对照查看状态详情docker inspect cidJSON 输出配合 -f 提取字段查看网络docker network inspect net_name查看网络内所有容器及 IP拷贝文件docker cp cid:/path ./local双向均可临时启动副本docker run --rm -it --entrypoint /bin/sh image用于死容器或镜像分析停容器优雅检查docker stop -t 30 cid给足宽限期看磁盘占用du -sh /var/lib/docker/containers/*/排查日志爆盘看资源实时占用docker stats --no-stream cid单次快照进入网络命名空间nsenter -t $(docker inspect -f {{.State.Pid}} cid) -n ss -tlnp容器内无工具时使用进入 PID 命名空间nsenter -t $(docker inspect -f {{.State.Pid}} cid) -p ps -ef容器内无 ps 时使用把这张表存成笔记日常调试基本可以脱离现查百度/谷歌的状态。7.2 从能连上到能定位建立一套属于自己的容器问题排查SOP经验最值钱的部分不是某个命令怎么用而是排查问题的顺序。我个人的容器交互调试 SOP 大致是四步定性看状态、看退出码、看重启次数用docker ps -a和docker inspect确定问题类型——是启动失败、运行崩溃还是资源枯竭。看日志用docker logs --tail200看最近日志如果有-f跟进持续观察。关注关键词比如 Exception、Error、panic、OutOfMemory、Connection refused。查环境确认环境变量、挂载目录、网络配置是不是和预期一致利用docker inspect里的.Config、.HostConfig、.Mounts快速比对。进现场如果前三步还不够再用docker exec或nsenter进入容器查看进程列表、监听端口、文件系统内容必要时借助临时调试容器、strace、tcpdump 做深层次分析。这套流程我用了很多年唯一一次翻车是在看日志这个环节——因为应用把日志同时写文件又打 stdout而docker logs只提供了 stdout 的部分导致我第一次误判进度。后来我在日志采集的源头就约定了一个规则容器内应用只允许用 stdout/stderr 打日志任何文件日志都要通过 stdout 重定向或者 sidecar 方式采集。这样做的收益在调试阶段是完全值得的。7.3 容器交互调试的终极习惯优先考虑不可变性与可复现性如果让我总结这些年容器调试经历中最重要的一个认知转变那就是不要依赖把容器改好而要追求把容器重建好。一个典型例子是当你在容器里通过apt-get install装了个调试工具确实解决了眼前的问题但这个工具在下次重建容器后就会消失如果当时没把修复方案沉淀到 Dockerfile 或启动脚本里下次遇到同样问题还得再装一次这是一种隐性技术债。更稳妥的方式是把所有调试得到的修复措施回写到代码、配置和镜像构建流程里让交互调试成为设计方案的前置验证而不是终点。所以在每次完成容器交互与调试工作后我会强制自己做一次记录本次容器里改了什么、临时补了什么、最终哪条命令或配置是治本手段。这些记录积累下来其实就是团队内部最能落地的容器运维知识库。毕竟真正的高手不是能进容器而是知道进了容器之后该做什么以及做完了之后该怎么把现场还原成干净状态。