前阵子帮一个团队排查内部工具容器化的问题,控制台只甩给我一行:
docker: Error response from daemon: OCI runtime create failed: container_linux.go:235: starting container process caused "exec: \"app\": executable file not found in $PATH": unknown第一眼确实容易懵:镜像明明构建成功了,容器怎么起不来?“OCI runtime error”这个前缀又长又拗口,看起来像是运行时层面的严重故障,实际上绝大多数情况只是一条“镜像里某个启动文件不对”的消息被包装成了这种格式。为了讲清楚这类报错,我拿这个具体问题作为例子,把从报错信息到根因定位的完整排查过程拆开来讲,顺带把我处理过的几个典型场景一并复盘。如果你也撞上过container_linux.go:235或者它换行后的某个container_linux.go行号,这篇文章应该能帮你省下不少时间。
1. 先看懂这个报错:runc 在哪个环节卡住了
1.1 一条 docker run 命令背后发生了什么
Docker 本身并不直接创建容器进程,它只是“前台的门面”。一条docker run命令发出去之后,请求会经过dockerd,转给containerd,最后由runc这类 OCI runtime 去真正创建容器。container_linux.go是 runc 源码里的一个文件,专门负责 Linux 容器进程的初始化、启动和退出处理。不同版本的 runc 行号会变,所以网上有人整理过container_linux.go:235的对应关系,但同一份代码在不同发行版里编译后的行号并不完全一致,死磕这个行号意义不大。
这个报错真正想告诉你的是:容器进程在“创建/启动”阶段失败了,而具体失败原因就在starting container process caused后面那一小段里。如果只盯着container_linux.go:235,很容易走偏,甚至去重装 Docker、重置内核参数,最后才发现其实是 Dockerfile 里一个手误。
1.2 报错里真正有用的信息藏在冒号之后
以我上面那条报错为例,把它拆开看:
OCI runtime create failed:runc 创建容器失败,是个兜底描述。container_linux.go:235:源代码位置,说明失败发生在 runc 的 Linux 容器进程启动逻辑里。starting container process caused:后面跟着的是底层系统调用返回的具体原因。exec: "app": executable file not found in $PATH:runc 在容器里执行app这个命令时找不到可执行文件。
前面三部分都是“壳”,最后这一句才是真正需要分析的。常见的后半句大致有几种:
| 后半段出现的关键词 | 真实含义 | 优先排查方向 |
|---|---|---|
executable file not found in $PATH | 命令不存在于 PATH 中 | Entrypoint/CMD 拼写、基础镜像差异 |
no such file or directory | 启动文件或动态库缺失 | 文件是否 COPY 进去、挂载是否覆盖 |
permission denied | 没有执行权限或被安全策略拦截 | 文件权限、selinux/AppArmor、挂载权限 |
exec format error | 架构不匹配或可执行格式不对 | 镜像平台、二进制编译方式 |
拿到这层信息之后,才算真正开始排错。
2. 排查链路:把“通用报错”变成“具体问题”
2.1 用 exit code 和容器状态缩小范围
docker run一次性失败的时候,可以先看容器退出码。运行容器后立刻执行:
docker ps -a假如容器停在Exited (127),那问题基本可以锁定在“启动命令不存在”。因为 127 是 Linux 里“command not found”的经典退出码;如果退出码是 126,一般是“命令存在但不可执行”;如果是 2 或 1,那可能是脚本自身逻辑或者参数有问题。接下来用 inspect 看更完整的运行信息:
docker inspect --format '{{.State.ExitCode}} {{.State.Error}}' <container_id_or_name>State.Error里往往会有比终端控制台更完整的报错文本。另一个很有用的字段是Path和Args,它们记录了 runc 最终尝试执行的程序路径和参数,能够直接揭示容器启动时到底跑了什么。举个我常做的操作:
docker inspect --format 'Path={{.Path}} Args={{.Args}}' <container_id>如果显示Path=/bin/sh但Args=[/app/start.sh],就说明 Entrypoint 是 shell,实际执行的是脚本。这时候如果脚本有问题,通常会留下更重要线索。
2.2 用 docker inspect 验证 Entrypoint 和 Cmd
镜像本身的启动配置可以通过docker image inspect快速确认。我一般直接看两块:
docker image inspect --format '{{json .Config.Entrypoint}}' <image> docker image inspect --format '{{json .Config.Cmd}}' <image>Entrypoint是容器启动时必须执行的命令,Cmd要么作为 Entrypoint 的默认参数,要么在 Entrypoint 为空时充当默认命令。很多报错就是因为这两个字段里写了一个不存在路径。举个例子,如果有人把ENTRYPOINT ["python"]写成了ENTRYPOINT ["Python"],大小写不一致,Linux 文件系统会直接找不到文件;Launch 时报的正是“no such file or directory”,但一开始你甚至会怀疑 Python 没装。
2.3 用 known-good 镜像判断运行时是否健康
如果排除了镜像里的路径和命令配置,仍然报同样的错误,那就需要判断是 Docker 运行时环境问题还是镜像自身问题。我会先跑一个最小的官方镜像,比如:
docker run --rm hello-world这个镜像启动后只打印一句提示就退出。如果它都能正常打印输出,说明 dockerd、containerd、runc 这一层基本没问题,问题大概率出在目标镜像本身;反过来,如果连hello-world都在container_linux.go报错,那就要检查 Docker 版本、内核版本、cgroup 驱动、存储驱动,甚至考虑重启 Docker 引擎。注意,hello-world正常但目标镜像失败,并不能完全排除运行时问题,因为不同镜像可能触发不同的系统调用,但至少能把排查范围砍掉一大半。
3. 逐个击破:入口文件、动态链接库、挂载目录
3.1 Entrypoint / CMD 指向的文件不存在
这是container_linux.go:235这条报错里出现频率最高的一种根因。常见触发方式包括:
- Dockerfile 写了
ENTRYPOINT ["/opt/app/start.sh"],但最后构建阶段根本没有 COPY 这个文件,或者 COPY 路径不对。 - 基础镜像换过。比如原来基于 Ubuntu,改成 Alpine 后,Dockerfile 里还写着
bash,但 Alpine 默认没有 bash。 - 镜像里的
WORKDIR被改过,启动脚本却用了相对路径。
定位时,我会先用一个覆盖 Entrypoint 的命令进入容器,检查目标文件是否存在:
docker run --rm --entrypoint /bin/sh <image> -c "ls -l /opt/app/start.sh"如果报no such file or directory,马上就能确认文件缺失。还有一个容易被忽略的细节:镜像里 Entrypoint 指定的路径和实际路径一致,但容器里只能看到“文件存在”而不可执行,比如权限位是 644。这时启动报错通常是permission denied,而ls -l的输出里x位为空。构建阶段忘记chmod +x是常见操作,修起来也简单,在 Dockerfile 里加一行:
COPY start.sh /opt/app/start.sh RUN chmod +x /opt/app/start.sh3.2 文件在但“跑不起来”:动态链接库与换行符
另一种很坑的情况是文件确实存在、权限也没问题,但 runc 执行时仍然报错。我遇到过两类:
第一类是动态链接库不完整。如果你把一个在一台 Ubuntu 上编译的 Go 或 C 程序直接扔进 Alpine 镜像跑,程序动态链接的 libc 路径对不上,就会报exec format error或者no such file or directory。检查方法是在容器里手动执行一次ldd:
docker run --rm --entrypoint /bin/sh <image> -c "ldd /opt/app/start"如果看到not found字样的动态库,说明运行时库里缺依赖。更好的解决办法是构建时使用纯静态编译:CGO_ENABLED=0,这样程序不再依赖目标镜像的 libc,我后面会再展开。
第二类是脚本文件本身存在,但第一行带着 Windows 的换行符。很多同事在 Windows 下编辑 shell 脚本后直接提交到仓库,构建进镜像之后,脚本第一行变成了#!/bin/sh\r。内核在解析 shebang 时会把/bin/sh\r当作解释器路径,而 Linux 文件系统里并没有这个名字,于是报错居然是“no such file or directory”,明明文件就在那儿。检查方法很简单:
docker run --rm --entrypoint /bin/sh <image> -c "head -1 /opt/app/start.sh | od -c | head -2"输出里如果看到\r,就把脚本换行统一成 LF,或者在构建阶段加dos2unix。这个坑我在真实项目里遇过不止一次。
3.3 挂载目录把启动路径“顶掉了”
镜像本来启动正常,但加了-v之后突然报同样的 OCI runtime error,这是第三个高频场景。比如我在排查某个服务时,习惯把自己宿主机的配置目录挂载到/app/config,但那个服务的 Entrypoint 恰好就在/app/config/start.sh里。挂载之后,宿主机目录把镜像里原本存在的/app/config整个覆盖掉,runc 去执行/app/config/start.sh,发现目标路径已经被宿主机的空目录替换,自然就“找不到文件”了。
遇到这类情况,先看docker inspect里的Mounts字段:
docker inspect --format '{{json .Mounts}}' <container_id>确认挂载点和 Entrypoint 是否存在重叠。如果确实重叠,要么把挂载点迁到/data之类的独立目录,要么把启动脚本放到不会被挂载覆盖的路径。我在真实项目里遇到过团队为了图省事,把整个/opt挂载到宿主机,结果每次发布都要手工确认/opt/app是否还在,迟早出问题。
4. 三个真实案例复盘
4.1 Alpine 镜像里用 bash,报 executable file not found
有一次同事构建镜像,Dockerfile 开头是:
FROM alpine:3.18 RUN apk add --no-cache curl ENTRYPOINT ["bash", "/start.sh"]构建没问题,但docker run立刻报:
oci runtime error: container_linux.go:235: starting container process caused "exec: \"bash\": executable file not found in $PATH": unknown我第一反应是bash没装。Alpine 默认的 shell 是/bin/sh,它不内置 bash。用命令验证了一下:
docker run --rm --entrypoint /bin/sh <image> -c "which bash || echo not-found"输出确实是not-found。修复方案有两种,一是在 Dockerfile 里加apk add --no-cache bash,二是直接把 Entrypoint 改成/bin/sh。当时脚本本身只是简单启动服务,不依赖 bash 特性,所以我选择了后者,镜像体积还能小一点。这个案例的教训是:换基础镜像时,所有启动命令和动态依赖都要重新过一遍,不能只看应用包是否拷贝进去了。
4.2 Shell 脚本第一行被 Windows 换行符污染
另一个案例里,脚本存在、权限正确,但报错是:
starting container process caused "exec: \"/app/entrypoint.sh\": no such file or directory"我反复确认/app/entrypoint.sh就在镜像里,甚至用ls -l都能看到文件。后来用od查看第一行字节,发现末尾跟着0d 0a。由于 Dockerfile 是从 Windows 仓库拉下来的entrypoint.sh,它在 Windows 里编辑后保存为 CRLF。内核执行脚本时,把/bin/sh\r当成解释器去查找,自然找不到。修复是在构建阶段统一转换:
FROM debian:bookworm-slim COPY entrypoint.sh /app/entrypoint.sh RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh从那以后,我再看到“文件明明存在却 no such file or directory”这类报错,就会先怀疑换行符,而不是第一时间怀疑 Docker 或内核。
4.3 挂载路径覆盖启动脚本导致容器秒退
还有一次,服务在本地直接docker run是好的,放到编排系统里就起不来。本地的执行命令是:
docker run -v /data/conf:/conf myapp后来排查发现,镜像里 Entrypoint 写的正是/conf/start.sh,而宿主机/data/conf是个空目录。挂载以后,容器里的/conf/start.sh被宿主机空目录替代,启动进程找不到入口。这类问题很有迷惑性,因为镜像本身没问题,Dockerfile 也没问题,错的只是运行时的“入参”。我还是通过docker inspect看到Mounts和Path才把问题串起来。之后的约定是:启动脚本所在目录必须与用户数据挂载目录分开,如果确实无法分开,镜像里就得多做一层“文件不存在时用默认入口”的容错逻辑。
5. 后续改进:把这类报错挡在构建阶段
5.1 构建镜像时顺手做三件小事
经历了上面这些案例后,我给自己定了几条构建阶段的检查项,能在很大程度上减少“容器起不来”的问题:
- 基础镜像尽量固定 tag,并在 Dockerfile 里写明预期的 shell 和运行环境。不要默认 Alpine 一定适合所有场景,也不要默认 Ubuntu 一定包含所有库。
- 所有 COPY 进镜像的脚本都显式设置可执行权限,或者在
RUN阶段统一chmod +x。 - 在镜像构建最后加一条“启动命令自检”的 RUN 命令,比如:
RUN test -x /opt/app/start.sh && echo "entrypoint ok"这样如果启动文件缺失或权限不对,构建阶段就会报错,根本不会拖到运行时。虽然增加了一点构建时间,但比线上半夜收到告警要划算得多。
5.2 多阶段构建里容易忽略的动态库问题
多阶段构建本身没问题,容易踩坑的是“构建阶段编译出来的二进制,运行阶段镜像却跑不起来”。尤其是使用 CGO 的 Go 程序,或者直接编译 C/C++ 程序时,默认会动态链接 glibc。构建阶段基于 Ubuntu,运行阶段换成 Alpine,非常容易出现缺少ld-linux-x86-64.so.2之类的问题。
我的建议是,如果应用没有强制使用 CGO,尽量用静态编译:CGO_ENABLED=0。如果必须使用动态链接,那就让运行阶段的基础镜像和构建阶段保持同一发行版系,至少 libc 实现要一致。可以选 Debian slim 或 Distroless,而不是强行用 Alpine 去兼容所有二进制。编译完成后在宿主机上先跑一步:
file ./app ldd ./app看到statically linked基本就能安心跨镜像使用;看到一堆动态库路径,就得留心运行镜像里是否有这些路径。
5.3 把“启动自检”留在容器前面
即使构建阶段做了自检,容器运行环境的挂载、环境变量、用户权限仍然可能在最后一刻改变结果。我现在会在启动脚本开头加一小段探测逻辑,比如检查关键文件是否存在、当前用户是否有权限:
if [ ! -x /opt/app/start.sh ]; then echo "entrypoint missing" >&2 exit 127 fi exec /opt/app/run脚本里exec命令尽量放在最后,让容器主进程直接接管,避免 shell 进程成为多余的父进程。这样就算最终仍然失败,docker logs里至少能看到一条明确的错误,而不是只有container_linux.go:235这种让人一头雾水的行号。
如果你也被类似的 OCI runtime 报错卡住,先记住一点:别困在container_linux.go这个文件名里。把冒号后面的starting container process caused真正读懂,再对照docker inspect里的 Path、Args、Mounts 和退出码,九成问题都能在两三步之内定位出来。这是我处理这类报错最核心的心法,也是这篇记录最想留给你的东西。