hadolint 是 Dockerfile 的静态检查器,Haskell 写的单文件 exe。它不连 Docker daemon,也不构建镜像,只把 Dockerfile 当文本读一遍,按规则库挑出问题:没固定版本、apt 缓存没清、用 ADD 代替 COPY、CMD 用了 shell 形式。内置 shellcheck,RUN 里的 shell 写法也会一起查。

一个 7 行的 Dockerfile 能被它抓出 9 个问题。单次跑 162.8 毫秒,其中 156 毫秒花在进程启动上。

一个 exe,不需要装 Docker

hadolint --version
Haskell Dockerfile Linter 2.15.1

hadolint Dockerfile

Release 页给的是 hadolint-windows-x86_64.exe,改名成 hadolint.exe 放进 PATH 就能用。没有参数时读标准输入,也可以直接给文件名,一次给多个:hadolint Dockerfile Dockerfile.dev。

它不依赖 Docker 环境,这一点在 CI 里很有用:拉代码、跑检查,两步就能卡住有问题的 Dockerfile,不用先起 daemon、也不占构建时间。

7 行 Dockerfile 抓出 9 个问题

下面这份是常见的写法,看代码挑不出毛病,跑一遍全是提示:

FROM ubuntu
RUN apt-get update
RUN apt-get install python3
ADD . /app
COPY requirements.txt /app
RUN pip install -r requirements.txt
CMD python3 /app/main.py
行规则级别问题
1DL3006warning镜像没写版本标签
2DL3009info装完没删 apt 列表
3DL3059info连续多个 RUN 可以合并
3DL3014warningapt-get 少了 -y
3DL3015info没加 --no-install-recommends
3DL3008warning装的包没钉版本
4DL3020error该用 COPY 却写了 ADD
6DL3042warningpip 没加 --no-cache-dir
7DL3025warningCMD 该用 JSON 数组写法

9 条里只有 1 条是 error 级别(DL3020),其余是 warning 和 info。这个分级很关键:默认情况下只要有任意一条就会退出码 1,想让 CI 只拦 error,得显式传阈值。

改好之后一条都不剩

FROM ubuntu:24.04
RUN apt-get update \
 && apt-get install -y --no-install-recommends python3=3.12.3-0ubuntu2 \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
USER 1000:1000
CMD ["python3", "/app/main.py"]

同一份文件按提示改完再跑,输出为空,退出码 0。9 个问题对应的改法都在一起:固定标签、合并 RUN、加 -y、钉版本、删 apt 列表、改 COPY、pip 加 --no-cache-dir、CMD 换数组、补 USER。

把 --failure-threshold error 加上,只有 error 级别才会让退出码变 1。这份改好的文件在两种阈值下都是 0。

162 毫秒里 156 毫秒在启动

场景耗时(毫秒)说明
单次检查一个文件162.810 次平均,最快 161.1
hadolint --version155.420 次平均(hyperfine 测)
一次传 50 个文件320输出 450 条
50 个文件折算单个6.4320 / 50
50 个逐个调用(折算)8140162.8 × 50

只打印版本号就要 155.4 毫秒,说明这 162.8 毫秒几乎全是进程启动和运行时初始化的成本,真正解析那份文件的时间不到 8 毫秒。

所以批量场景差别巨大:一个个循环调用,50 个文件折算下来要 8.1 秒;一次把所有路径传进去,总共 320 毫秒,平均每个 6.4 毫秒。CI 里写成 find . -name Dockerfile -exec hadolint {} + 比 -exec ... \; 快得多,区别只在那个加号。

输出格式和忽略规则

> hadolint -f json Dockerfile.bad
[{"file":"Dockerfile.bad","line":1,"code":"DL3006","level":"warning",
  "message":"Always tag the version of an image explicitly"}, ...]

> hadolint --ignore DL3008 --ignore DL3009 Dockerfile.bad
(输出从 9 条减到 7 条,退出码仍然是 1)

-f json 输出的数组能直接喂给流水线做统计,每条带 file / line / code / level / message 五个字段。除了 json 还支持 sarif、checkstyle、codeclimate 几种,接代码扫描平台用得上。

--ignore 是逐条排除规则码,可以重复写多次。上面排掉 DL3008 和 DL3009 之后剩下 7 条,注意退出码没变,因为还有别的 warning 在。想整体放行就用 --no-fail,或者把阈值提到 error。

长期排除一批规则不必每次都写参数,项目根目录放一个 .hadolint.yaml,里面列 ignored 列表就行,exe 会自动往上找。

它会在这些地方停下

> hadolint nosuchfile
ioException, called at libraries\ghc-internal\src\GHC\Internal\IO\Exception.hs:319:20
退出码 1

> hadolint Dockerfile.good              (有问题的写法)
DL3020 error: Use COPY instead of ADD for files and folders
退出码 1

如果文件不存在,它会直接报错,输出一整条 Haskell 运行时异常栈,里面带着 GHC 内部源码路径,退出码 1。这种报错对写脚本的人不友好,判断文件是否存在要自己做。

另一个要留意:一旦命中任意一条建议,退出码就是 1,连 info 级别的样式提示也算,CI 会直接失败。第一次接入经常被一堆 info 卡住,先看一遍输出再决定阈值。

语法错误也能兜住:把字符串引号写漏,它报 SC1072(这是 shellcheck 的规则码),退出码同样是 1。

适合和不适合

想在不构建镜像的前提下查 Dockerfile。这是它的主场:162.8 毫秒出结果,不需要 Docker daemon,提交前跑一遍就能拦住大部分常见问题。

CI 里批量扫仓库。一次传 50 个文件 320 毫秒,比循环调用快 25 倍。适合放进 pre-commit 或 CI 的第一步。

想查镜像里到底装了什么。它只看 Dockerfile 文本,不看构建结果。基础镜像的实际内容、层的大小、有没有漏洞,这些要靠镜像扫描工具。

想要自动修好。它只报告不修改,9 条提示要一行行改。规则给得很具体,但改代码这件事它不代劳。

还没测的两项:自定义规则(.hadolint.yaml 里写 trusted registry)的实际效果 / 与 docker build 的 --check 对比。前者要设计一套注册表白名单,后者要装 Docker。