Dockerfile 写作精讲 / 16 - 测试与验证
16 - 测试与验证:Hadolint、容器内测试与 CI 集成
16.1 为什么需要测试 Dockerfile
Dockerfile 和镜像与代码一样需要测试:
| 测试维度 | 目标 | 工具 |
|---|
| 语法规范 | 遵循最佳实践 | Hadolint, dockle |
| 安全合规 | 无已知漏洞和配置问题 | Trivy, Grype |
| 功能验证 | 应用在容器中正常运行 | 容器内测试 |
| 构建验证 | Dockerfile 能成功构建 | CI 构建测试 |
| 体积检查 | 镜像体积在预期范围内 | 自定义脚本 |
16.2 Hadolint:Dockerfile 静态分析
安装与使用
# Docker 方式运行
docker run --rm -i hadolint/hadolint < Dockerfile
# 安装二进制
# macOS
brew install hadolint
# Linux
wget -O /usr/local/bin/hadolint \
https://github.com/hadolint/hadolint/releases/latest/download/hadolint-Linux-x86_64
chmod +x /usr/local/bin/hadolint
# 基本使用
hadolint Dockerfile
# 输出 JSON
hadolint --format json Dockerfile
规则级别
| 级别 | 说明 | 示例 |
|---|
error | 必须修复 | DL3009: 删除 apt lists |
warning | 建议修复 | DL3018: 固定包版本 |
info | 信息提示 | DL3006: 固定基础镜像版本 |
style | 代码风格 | DL3003: 使用 WORKDIR 而非 cd |
ignore | 忽略 | 自定义忽略 |
常见规则
# DL3008: 固定 apt 包版本(warning)
# ❌
RUN apt-get install -y curl
# ✅
RUN apt-get install -y curl=7.88.1-10+deb12u5
# DL3009: 清理 apt lists(error)
# ❌
RUN apt-get update && apt-get install -y curl
# ✅
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# DL3025: CMD 使用 JSON 形式(warning)
# ❌
CMD python app.py
# ✅
CMD ["python", "app.py"]
# DL3003: 使用 WORKDIR 而非 cd(style)
# ❌
RUN cd /app && make
# ✅
WORKDIR /app
RUN make
配置文件
# .hadolint.yaml
ignoredRules:
- DL3008 # 忽略"固定包版本"规则
- DL3013 # 忽略"固定 pip 包版本"
trustedRegistries:
- docker.io
- ghcr.io
override:
warning:
- DL3006 # 将"固定基础镜像版本"从 info 提升到 warning
忽略特定行
# 忽略单条规则
RUN apt-get update && apt-get install -y curl # hadolint ignore=DL3008
# 忽略多条规则
RUN apt-get update && apt-get install -y curl # hadolint ignore=DL3008 DL3009
# 忽略文件中所有规则(不推荐)
# hadolint global ignore=DL3008
16.3 Dockle:容器镜像安全检查
# 安装
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
goodwithtech/dockle myapp:latest
# 输出 JSON
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
goodwithtech/dockle --format json myapp:latest
# 退出码(CI 集成)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
goodwithtech/dockle --exit-code 1 --exit-level warn myapp:latest
Dockle 检查项
| 检查项 | 说明 |
|---|
| CIS-DI-0001 | 创建非 root 用户 |
| CIS-DI-0002 | 使用可信基础镜像 |
| CIS-DI-0005 | 启用 Content Trust |
| CIS-DI-0006 | 添加 HEALTHCHECK |
| CIS-DI-0008 | 移除 setuid/setgid 位 |
| CIS-DI-0009 | 使用 COPY 而非 ADD |
16.4 容器内测试
在容器中运行测试套件
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
COPY . .
# 测试阶段
FROM builder AS tester
# 在容器内运行测试
RUN pytest tests/ -v --junitxml=/test-results/results.xml \
--cov=app --cov-report=html:/test-results/coverage
# 生产阶段
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /app/app ./app
CMD ["gunicorn", "app:app"]
# 构建并运行测试
docker build --target tester -t myapp:test .
# 提取测试结果
docker create --name test-results myapp:test
docker cp test-results:/test-results ./test-results
docker rm test-results
Node.js 集成测试
FROM node:20-alpine AS tester
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# 运行 lint
RUN npm run lint
# 运行单元测试
RUN npm test
# 运行构建(验证编译通过)
RUN npm run build
Go 测试 + 覆盖率
FROM golang:1.22-alpine AS tester
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 运行测试并生成覆盖率报告
RUN go test ./... -v -race -coverprofile=coverage.out -covermode=atomic
# 输出覆盖率摘要
RUN go tool cover -func=coverage.out
# 生产构建
FROM tester AS builder
RUN CGO_ENABLED=0 go build -o /server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
ENTRYPOINT ["/server"]
16.5 构建验证测试
镜像体积检查
#!/bin/bash
# check-image-size.sh
MAX_SIZE_MB=100
IMAGE_NAME="myapp:latest"
# 构建镜像
docker build -t "$IMAGE_NAME" .
# 获取镜像大小(字节)
SIZE_BYTES=$(docker image inspect "$IMAGE_NAME" --format='{{.Size}}')
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
echo "Image size: ${SIZE_MB}MB (limit: ${MAX_SIZE_MB}MB)"
if [ "$SIZE_MB" -gt "$MAX_SIZE_MB" ]; then
echo "FAIL: Image exceeds size limit!"
exit 1
fi
echo "PASS: Image size is within limit."
容器启动健康检查
#!/bin/bash
# smoke-test.sh
IMAGE_NAME="myapp:latest"
CONTAINER_NAME="smoke-test-$$"
# 启动容器
docker run -d --name "$CONTAINER_NAME" -p 0:8080 "$IMAGE_NAME"
# 等待启动
sleep 5
# 检查容器是否在运行
if ! docker ps | grep -q "$CONTAINER_NAME"; then
echo "FAIL: Container exited unexpectedly"
docker logs "$CONTAINER_NAME"
exit 1
fi
# 获取随机映射的端口
PORT=$(docker port "$CONTAINER_NAME" 8080 | cut -d: -f2)
# 检查健康端点
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/health")
if [ "$HTTP_CODE" != "200" ]; then
echo "FAIL: Health check returned $HTTP_CODE"
docker logs "$CONTAINER_NAME"
exit 1
fi
echo "PASS: Container is healthy"
# 清理
docker rm -f "$CONTAINER_NAME"
安全检查脚本
#!/bin/bash
# security-check.sh
IMAGE_NAME="myapp:latest"
echo "=== Dockerfile Lint (Hadolint) ==="
hadolint Dockerfile || exit 1
echo "=== Vulnerability Scan (Trivy) ==="
trivy image --exit-code 1 --severity HIGH,CRITICAL "$IMAGE_NAME" || exit 1
echo "=== Security Check (Dockle) ==="
goodwithtech/dockle --exit-code 1 --exit-level warn "$IMAGE_NAME" || exit 1
echo "=== All security checks passed ==="
16.6 CI/CD 集成
GitHub Actions 完整示例
# .github/workflows/docker-ci.yml
name: Docker CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Hadolint
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
build-and-test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build test image
uses: docker/build-push-action@v5
with:
context: .
target: tester
load: true
tags: myapp:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run tests
run: docker run --rm myapp:test
- name: Build production image
uses: docker/build-push-action@v5
with:
context: .
target: production
load: true
tags: myapp:latest
- name: Check image size
run: |
SIZE=$(docker image inspect myapp:latest --format='{{.Size}}')
SIZE_MB=$((SIZE / 1024 / 1024))
echo "Image size: ${SIZE_MB}MB"
[ "$SIZE_MB" -lt 100 ] || { echo "Image too large!"; exit 1; }
- name: Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:latest
severity: HIGH,CRITICAL
exit-code: '1'
- name: Smoke test
run: |
docker run -d --name smoke -p 0:8080 myapp:latest
sleep 5
PORT=$(docker port smoke 8080 | cut -d: -f2)
curl -f "http://localhost:${PORT}/health"
docker rm -f smoke
push:
if: github.ref == 'refs/heads/main'
needs: build-and-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
GitLab CI 示例
# .gitlab-ci.yml
stages:
- lint
- build
- test
- scan
- push
hadolint:
stage: lint
image: hadolint/hadolint:latest
script:
- hadolint Dockerfile
build:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker build -t myapp:$CI_COMMIT_SHA .
test:
stage: test
image: docker:24
services:
- docker:24-dind
script:
- docker build --target tester -t myapp:test .
- docker run --rm myapp:test
trivy-scan:
stage: scan
image: aquasec/trivy:latest
script:
- trivy image --exit-code 1 --severity HIGH,CRITICAL myapp:$CI_COMMIT_SHA
push:
stage: push
image: docker:24
services:
- docker:24-dind
only:
- main
script:
- docker tag myapp:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
- docker push $CI_REGISTRY_IMAGE:latest
16.7 测试策略矩阵
| 测试类型 | 执行阶段 | 工具 | 失败策略 |
|---|
| Dockerfile 代码检查 | PR 提交 | Hadolint | 阻断合并 |
| 构建测试 | PR 提交 | docker build | 阻断合并 |
| 单元测试 | PR 提交 | pytest/jest/go test | 阻断合并 |
| 镜像漏洞扫描 | PR + 主分支 | Trivy | 高危阻断 |
| 镜像体积检查 | PR + 主分支 | 自定义脚本 | 超限阻断 |
| 冒烟测试 | 主分支 | 自定义脚本 | 阻断部署 |
| 安全合规检查 | 主分支 | Dockle | 高危阻断 |
| 签名 | 发布 | Cosign | 阻断发布 |
16.8 扩展阅读
上一章:15 - 镜像瘦身
下一章:17 - Docker Compose 集成 — Compose Build、多服务构建与环境变量。