
面试必问进入docker原理:吃透Moby源码,告别背八股文
看了一堆教程还是不会写项目?这是很多后端工程师的痛点。
你敲过无数次 docker exec -it container_id /bin/bash,也背熟了 docker ps 的参数,但面试官一句“说说进入容器底层发生了什么”,你就卡壳了。
别慌。今天不背八股,直接拆 Docker (Moby) 官方源码仓库 里的核心逻辑。
这篇干货,专门解决你“只会用,不敢问”的尴尬,把 进入docker 这个高频考点,从现象到本质讲透。
入口定位:从 CLI 到 Daemon 的调用链
很多初学者以为 docker 命令是直接操作内核的。错。
docker CLI 只是一个客户端。它通过 HTTP/Unix Socket 与 dockerd (Daemon) 通信。
当你执行 docker exec 时,真正的动作发生在 Daemon 端。CLI 层:解析参数,构造 JSON 请求。
API 层:Daemon 接收请求,路由到 exec handler。
容器层:找到目标容器的运行时环境。
Runtime 层:调用 runc 或 cri-o 创建新的进程。这里有个关键细节:exec 不是创建新容器,而是在已有容器的命名空间中创建一个新进程。
这就解释了为什么 exec 进去的环境变量、挂载卷,和容器主进程完全一致。
核心片段:Moby 源码中的 Exec 实现
让我们直接看 官方源码仓库 moby/moby 中的关键代码。
片段一:API 请求处理入口
这是 containerd 或 libcontainer 交互前的第一道关卡,位于 daemon/daemon.go 或相关 handler 中。
// 伪代码,基于 Moby 源码逻辑简化
// 位置: api/server/router/container/container_routes.gofunc (s *Router) execStart(ctx context.Context, w http.ResponseWriter, r *http.Request) {// 1. 从 URL 中获取容器 ID// 注意:这里的 ID 是完整或前缀id := httprouter.Params(ctx).Get(id) // 2. 获取 exec 配置 (如命令, 环境变量, TTY 设置)// config 是从请求 Body 反序列化出来的config := execConfig{}if err := json.NewDecoder(r.Body).Decode(config); err != nil {httpError(w, err)return}// 3. 核心逻辑:调用 daemon 的 ExecCreate// 这一步会在容器的 PID 命名空间中注册一个新的 Exec 实例execID, err := s.backend.ExecCreate(id, config)if err != nil {httpError(w, err)return}// 4. 返回 Exec ID 给客户端// 客户端拿到这个 ID,后续用于 Start 和 Attachw.Header().Set(Docker-Container, id)w.Header().Set(Docker-Exec-ID, execID)w.WriteHeader(http.StatusOK)
}逐行解析:ExecCreate:这是关键。它并没有立即启动进程,只是“创建”了一个执行上下文。为什么?因为 exec 可能涉及复杂的 TTY 分配、流复用,需要先准备资源。
execID:这是一个独立的 UUID。你可以对同一个容器创建多个 exec 会话,每个都有独立的 execID。
为什么分两步? Create 和 Start 分离,是为了让客户端可以提前建立连接(Attach),避免启动后的 IO 丢失。这在长连接调试中至关重要。片段二:底层进程启动 (libcontainer)
真正的“进入”发生在 libcontainer 库中。这是 Docker 与 Linux 内核交互的核心。
// 伪代码,基于 libcontainer 源码逻辑
// 位置: libcontainer/factory_linux.gofunc (l *factory) StartContainer(c *container, configs *configs.Config) error {// 1. 设置进程属性// 这里决定了新进程继承哪些命名空间p, err := l.newProcess(configs)if err != nil {return err}// 2. 关键:进入命名空间// 调用 Linux 系统调用 setns 或 clone// 将当前进程加入容器已有的 PID, MNT, NET 等命名空间if err := p.Start(); err != nil {return err}// 3. 执行命令// 这里会 fork 一个子进程,并执行 config.Cmd (例如 /bin/bash)// 父进程 (dockerd) 会监控子进程状态return nil
}逐行解析:newProcess:这一步会读取容器的配置,特别是 Namespaces 字段。对于 exec,它会复用容器主进程的 PID 命名空间,但可能新建 UTS 或保持原有 NET。
p.Start():这是真正的“魔法”。底层调用了 clone() 系统调用,并指定了 CLONE_NEWPID 等标志。但注意,对于 exec,我们通常不新建 PID 命名空间,而是加入现有的。
进程关系:exec 进去的进程,其父进程是 containerd-shim 或 dockerd,而不是容器的主进程。这在 ps 命令中可以看到,它们的 PID 通常在 1 之后。设计思想:为什么这样设计?
很多候选人会问:“为什么不直接 chroot 进去?”
这是 面试必问 的深度问题。隔离性与一致性:
chroot 只改变根目录,不隔离进程、网络、用户 ID。而 Docker 依赖 Namespace 和 Cgroups。exec 必须确保新进程完全继承容器的隔离环境,否则就会破坏容器的安全性。状态管理:
ExecCreate 和 ExecStart 分离,允许 Docker 管理 exec 会话的生命周期。如果用户断开连接,Docker 可以清理残留的进程,避免僵尸进程。流复用 (Stream Multiplexing):
在 docker exec -it 中,stdin, stdout, stderr 需要复用同一个 Socket。Moby 源码中使用了自定义的帧格式(类似 Docker Hub 的协议),将多路流打包成一个流传输。这解释了为什么你 exec 进去后,cat /dev/zero 会阻塞,因为 stdout 被占用了。对比 kubectl exec:
Kubernetes 的 exec 是通过 Kubelet - CRI - Container Runtime 实现的。原理类似,但多了一层 API Server 的鉴权。Docker 直接是 Local API,更轻量。
手写简化版:理解底层逻辑
为了让你彻底理解,我们写一个简化版的 enterContainer 函数,模拟 libcontainer 的核心逻辑。
# 简化版模拟,使用 Python 调用 Linux 系统调用 (仅概念演示)
# 实际生产中请使用 Go 或 Cimport os
import ctypes
import ctypes.util# 加载 libc
libc = ctypes.CDLL(ctypes.util.find_library(c))# 定义系统调用号
SYS_clone = 56 # x86_64
SYS_setns = 308 # x86_64def enter_namespace(ns_fd):模拟进入指定命名空间参数: ns_fd - 命名空间文件描述符# 调用 setns 系统调用# 第二个参数是命名空间类型,0 表示自动检测ret = libc.syscall(SYS_setns, ns_fd, 0)if ret != 0:raise Exception(Failed to enter namespace)print(fSuccessfully entered namespace with fd: {ns_fd})def simulate_exec(container_ns_fd, command):模拟在容器命名空间中执行命令# 1. 进入命名空间enter_namespace(container_ns_fd)# 2. 执行命令# 注意:这里实际会 fork 并 exec# 为了安全,这里只打印print(fExecuting: {command} inside container)# 3. 退出命名空间 (实际中进程会一直留在里面)# setns 是单向的,除非新建进程,否则无法直接“退出”print(Process will remain in the namespace)# 使用示例
# 假设我们已经获取了容器的 /proc/pid/ns/pid 文件描述符
# simulate_exec(12345, /bin/bash)关键点:setns:这是 Linux 2.6.37 引入的系统调用,允许进程加入现有的命名空间。
单向性:一旦进程进入命名空间,它就无法“退出”回到宿主机的命名空间。这就是为什么 docker exec 进去后,你 exit 只是结束进程,而不是“出来”。
文件描述符:命名空间是通过 /proc/pid/ns/type 下的符号链接来引用的。docker exec 实际上就是打开这些链接,然后调用 setns。应用场景与避坑指南
1. 调试生产环境
场景:生产容器 CPU 100%,如何排查?
错误做法:docker exec 进去后,直接 top。
问题:top 显示的是容器内的 PID,但 CPU 使用率是全局的。你需要知道哪个进程在占用。
正确做法:docker stats container_id 查看整体资源。
docker exec container_id top 查看内部进程。
关键:使用 nsenter 命令,而不是 docker exec。
# 获取容器的 PID
pid=$(docker inspect --format '{{.State.Pid}}' container_id)
# 使用 nsenter 进入,保持宿主机上下文
nsenter -t $pid -m -u -i -n -p -- /bin/bash为什么用 nsenter? 因为 docker exec 会创建一个新进程,其父进程是 dockerd,可能会受到 Cgroups 限制。nsenter 是从宿主机进程直接切换,更底层,更适合高级调试。2. 权限问题
坑:docker exec 进去后,whoami 显示 root,但无法写入某些文件。
原因:容器的 UID/GID 映射。宿主机上的 root 是 UID 0,但容器内可能映射为其他 UID。
解决:检查 /etc/passwd 在容器内的映射。
使用 --user 参数指定用户:
docker exec -u 1000:1000 container_id /bin/bash3. TTY 分配失败
坑:docker exec -it 报错 the input device is not a TTY。
原因:容器内没有分配 TTY,或者 SSH 客户端不支持 TTY。
解决:确保容器镜像包含 tty 相关库。
使用 --no-tty 参数(仅限非交互命令)。
检查 dockerd 配置,确保 exec 支持 TTY。高频考点总结exec 与 run 的区别:run 创建新容器,exec 在已有容器中创建新进程。
命名空间继承:exec 进程继承容器的所有命名空间(PID, MNT, NET, UTS, IPC, USER)。
进程模型:exec 进程的父进程是 containerd-shim,不是容器主进程。
流复用:Docker 使用自定义帧格式复用 stdin/stdout/stderr。
nsenter vs docker exec:nsenter 更底层,适合调试;docker exec 更便捷,适合日常操作。结尾互动
你在项目里踩过这个坑吗?比如 exec 进去后环境变量丢失,或者 nsenter 权限不足?
评论区聊聊,我会挑几个典型问题单独拆解。
记住:不要只背 docker exec -it,要理解背后的 Namespace 和 Cgroups 机制。这才是 面试必问 的真正考点。