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
| 行 | 规则 | 级别 | 问题 |
|---|---|---|---|
| 1 | DL3006 | warning | 镜像没写版本标签 |
| 2 | DL3009 | info | 装完没删 apt 列表 |
| 3 | DL3059 | info | 连续多个 RUN 可以合并 |
| 3 | DL3014 | warning | apt-get 少了 -y |
| 3 | DL3015 | info | 没加 --no-install-recommends |
| 3 | DL3008 | warning | 装的包没钉版本 |
| 4 | DL3020 | error | 该用 COPY 却写了 ADD |
| 6 | DL3042 | warning | pip 没加 --no-cache-dir |
| 7 | DL3025 | warning | CMD 该用 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.8 | 10 次平均,最快 161.1 |
| hadolint --version | 155.4 | 20 次平均(hyperfine 测) |
| 一次传 50 个文件 | 320 | 输出 450 条 |
| 50 个文件折算单个 | 6.4 | 320 / 50 |
| 50 个逐个调用(折算) | 8140 | 162.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。










