docker-compose-hardening

Category: DevOps Risk: Medium risk niuwoai/skills CC-BY-4.0
package_installshell_execution

name: docker-compose-hardening
description: Docker 与 Compose 部署的收口和加固。当要把服务容器化上线、要审一份 Dockerfile 或 compose 文件、镜像太大、容器反复重启、要限制资源、要处理日志和数据卷时使用。触发词:Dockerfile、docker compose、镜像太大、容器重启、OOMKilled、数据卷、挂载、多阶段构建、镜像瘦身、容器日志、latest 标签、healthcheck。不负责 Kubernetes 编排和服务发布流程(走 go-service-release)。

Docker 部署收口

容器化的常见故障就那么几类:镜像里带了密钥、用了 latest 标签导致版本漂移、没设资源限制拖垮宿主机、数据卷没规划导致数据丢失、日志把磁盘写满。这份检查单围绕这五类。

一、Dockerfile

多阶段构建

编译产物和编译工具链不要出现在同一个镜像里。

FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

要点:

  • 先拷依赖清单再拷源码。 依赖层能被缓存,改一行代码不用重装依赖。
  • 运行阶段用最小基础镜像:Go 用 distroless 或 scratch,Node/Python 用 slim。
  • 基础镜像必须 pin 死版本,禁止 latest。写 golang:1.23-alpine,更严格的写摘要 @sha256:...
  • USER 必须设成非 root。 默认 root 是最常见的容器安全问题。

不要把密钥打进镜像

  • 密钥、证书、.env 一律走运行时环境变量或密钥管理,不进 Dockerfile、不进构建参数、不进镜像层
  • 注意:ARG 传进来的值会留在镜像历史里,docker history 能看到。需要构建期密钥就用 BuildKit 的 --mount=type=secret
  • .dockerignore,至少包含 .git.env*node_modules*.pem*.key。没有 .dockerignoreCOPY . . 会把整个 .git 目录连同历史里的密钥一起打进去。

镜像瘦身

按收益排序:换更小的基础镜像 → 多阶段构建 → 合并 RUN 层并在同层清理缓存 → 删掉构建缓存和文档。

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates \
 && rm -rf /var/lib/apt/lists/*

清理必须和安装在同一个 RUN 里,分开写的话上一层已经把文件固化了,删了也不减体积。

二、Compose 文件

services:
  app:
    image: registry.example.com/app:0.229.0     # 不用 latest
    restart: unless-stopped
    env_file: [.env]                            # .env 不进仓库
    ports: ["127.0.0.1:8080:8080"]              # 只绑本机,外部走反向代理
    depends_on:
      db: { condition: service_healthy }
    healthcheck:
      test: ["CMD", "/app", "healthcheck"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s
    deploy:
      resources:
        limits: { cpus: "2.0", memory: 1g }
    logging:
      driver: json-file
      options: { max-size: "50m", max-file: "3" }
    stop_grace_period: 30s

逐条说明为什么:

  • image 写死版本。 latest 会让「重启一下」变成「悄悄升级到未测试的版本」。
  • 端口绑 127.0.0.1 直接写 "8080:8080" 会绕过宿主机防火墙暴露到公网,这是个很常见且很危险的坑。
  • depends_on 要配 condition: service_healthy 只写服务名只保证启动顺序,不保证依赖真的可用。
  • 必须有 healthcheck 没有健康检查的容器,编排层不知道它是不是活的。健康检查要真检查依赖。
  • 必须有资源限制。 没有 memory 限制时,一个泄漏的容器能把整台机器拖死。
  • 必须配日志轮转。 Docker 默认的 json-file 驱动不限大小,几个月后磁盘就满了。这是容器化最常见的磁盘满原因。
  • stop_grace_period 要大于应用的优雅退出时间。

三、数据与卷

  • 有状态数据必须用命名卷或绑定挂载,绝不能只存在容器内。容器是一次性的,重建就没了。
  • 绑定挂载写清楚只读还是读写,配置文件挂 :ro
  • 数据库容器化要谨慎。 开发环境随意,生产环境的数据库建议用托管服务或独立部署。容器里的数据库在磁盘性能和故障恢复上都更麻烦。
  • 备份针对的是卷,不是容器。备份要定期做恢复演练,没验证过的备份等于没有。

四、运行时安全

    read_only: true
    tmpfs: ["/tmp"]
    cap_drop: ["ALL"]
    security_opt: ["no-new-privileges:true"]
  • 根文件系统只读,需要写的目录单独挂 tmpfs 或卷。
  • 丢掉所有 capability,需要哪个再单独加。
  • 禁止提权。
  • 不要用 privileged: true,也不要挂载 /var/run/docker.sock 到业务容器。挂了等于把宿主机 root 权限给了容器。

五、容器反复重启的排查

docker compose ps                       # 看状态和退出码
docker compose logs --tail 200 <svc>
docker inspect <容器> --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
docker stats --no-stream

按退出码分诊:

现象 常见原因
OOMKilled: true 内存限制太小,或应用泄漏。JVM/Node 的堆上限要跟着容器限制设
退出码 1 + 日志有报错 配置缺失、依赖连不上、迁移失败
退出码 137 被 SIGKILL,通常是 OOM 或停止超时
健康检查一直不过 检查命令写错,或 start_period 太短
启动瞬间退出、无日志 ENTRYPOINT 路径错误,或架构不匹配(arm64 镜像跑在 amd64 上)

最后一条在 Mac 上构建、Linux 上运行时特别常见。构建时明确指定 --platform linux/amd64

六、上线检查

  • 基础镜像和应用镜像都 pin 了具体版本,没有 latest
  • docker history 里看不到任何密钥
  • 容器以非 root 运行
  • 所有服务有 healthcheck 且真的检查依赖
  • 所有服务有 memory 和 cpu 限制
  • 所有服务配了日志轮转
  • 端口没有意外暴露到 0.0.0.0
  • 有状态数据在卷里,且备份做过恢复演练
  • .env 和证书在 .gitignore.dockerignore
  • 回滚方式明确:上一个镜像标签是什么,怎么切回去