Docker镜像越来越大,磁盘撑不住了怎么瘦身

线上机器磁盘被 Docker 吃满,这事在业务跑久之后很常见。刚开始看起来只是几个服务镜像,每个几百 MB,后来 CI/CD 一直构建,老镜像、 dangling layer、build cache、日志、volume 全堆在一起,几十 GB 很快就没了。

实际使用中发现,很多人第一反应是扩容磁盘,但扩容只能缓一阵。真正要处理的是两块:镜像本身怎么变小,宿主机上的 Docker 数据怎么清理。前者影响后续每一次部署,后者解决当前磁盘告警。

先确认到底是谁占了空间

不要一上来就删。Docker 占用磁盘通常不只是 image,还有 container、volume、build cache、日志。先看整体情况:

docker system df

这个命令会把 Images、Containers、Local Volumes、Build Cache 分开列出来。比较常见的情况是 Images 看着不大,Build Cache 却十几 GB;或者容器日志一直写,/var/lib/docker/containers 下面单个 json-log 几 GB。

再看 Docker 根目录占用:

du -h --max-depth=1 /var/lib/docker | sort -h

如果 overlay2 特别大,大概率是镜像层、容器可写层、构建缓存堆起来了。如果 containers 特别大,优先查日志。如果 volumes 特别大,不要随便删,里面可能是数据库、上传文件、业务持久化数据。

几个常见占用来源的判断

Images 大:镜像构建太频繁,旧 tag 没清理,基础镜像过重。

Build Cache 大:Dockerfile 经常变化,构建缓存层不断累积,CI 机器尤其明显。

Containers 大:容器没有清理,或者容器日志无限增长。

Volumes 大:业务数据、数据库数据、缓存数据,删之前必须确认挂载关系。

overlay2 大但 system df 看不清:可能有已停止容器、残留 layer,或者某些进程还占着已删除文件句柄。

先把宿主机救回来:清理 Docker 垃圾数据

磁盘已经报警时,先处理可安全清理的部分。最温和的是删掉停止状态的容器:

docker container prune

再删 dangling image,也就是没有 tag 的中间镜像:

docker image prune

如果确认旧镜像都不需要,可以清理未被容器使用的镜像:

docker image prune -a

这里补充一点,docker image prune -a 在生产机器上要小心。它不会删除正在被容器使用的镜像,但会删掉当前没容器引用的镜像。某些回滚流程依赖本地旧镜像,如果删掉了,回滚时就要重新拉取,网络慢的时候会很尴尬。

BuildKit 缓存可以这样清:

docker builder prune

如果 CI 构建机缓存特别大,可以按时间清,比如只清理 72 小时以前的缓存:

docker builder prune --filter until=72h

最猛的是:

docker system prune -a --volumes

这个命令会清理未使用的镜像、停止容器、网络、构建缓存,还会带上 volume。生产环境不建议直接带 --volumes,除非确认 volume 里没有业务数据。很多事故不是 Docker 本身的问题,是清理命令下得太爽。

容器日志经常是隐形大户

Docker 默认 json-file 日志如果不限制大小,会一直写。线上 Java、Node.js、PHP、Go 服务只要日志输出到 stdout/stderr,最终都会落到宿主机的容器日志文件里。

可以这样找大日志:

find /var/lib/docker/containers -name "*-json.log" -type f -exec du -h {} + | sort -h

如果看到单个日志 5GB、10GB,不用惊讶。业务出错时疯狂刷栈,一晚上写爆磁盘很正常。

建议在 /etc/docker/daemon.json 加日志限制:

{ "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" } }

改完重启 Docker:

systemctl restart docker

多说一句,重启 Docker 会影响容器,生产环境要安排窗口。已经存在的容器不一定马上应用新日志配置,稳妥做法是重新创建容器。

如果已经有大日志文件,不建议直接 rm 文件,因为容器进程可能还持有文件句柄。可以用 truncate 清空:

truncate -s 0 /var/lib/docker/containers/容器ID/容器ID-json.log

镜像为什么会越来越大

镜像变大通常不是某一个原因,而是 Dockerfile 写法、基础镜像选择、构建产物处理、依赖缓存共同造成的。

一个典型例子:在镜像里 apt install 一堆包,然后没有清理 apt cache;npm install 后把 node_modules、源码、测试文件、构建缓存都塞进去;Java 项目把 Maven 本地仓库也 COPY 进去了;Python 项目把 pip cache 留在层里。每个点看着几十 MB,加起来就很夸张。

还有一个容易忽略的点:Docker 镜像是分层的。某一层里 COPY 了一个 500MB 文件,下一层再 rm 掉,这个文件在历史层里依然存在,镜像大小不会真正降下来。

错误写法:删了但没瘦

RUN wget http://example.com/big.tar.gz
RUN tar -xf big.tar.gz
RUN rm -f big.tar.gz

这种写法里 big.tar.gz 已经进入第一层,后面 rm 只是让当前层看不见它,镜像历史层还在。

更合理的写法:同一层下载、使用、删除

RUN wget http://example.com/big.tar.gz \
    && tar -xf big.tar.gz \
    && rm -f big.tar.gz

同一条 RUN 里完成临时文件处理,最终层不会保留下载包。

基础镜像先换掉,收益通常最大

镜像瘦身里,基础镜像选型影响非常明显。很多业务直接用 ubuntu、centos,结果一个基础层就几百 MB。对于运行时镜像,能用 slim 就别用完整版,能用 alpine 要看兼容性,不要盲目上。

常见基础镜像大概是这个量级,不同版本会有差异:

ubuntu:22.04 通常 70MB 左右;debian:bookworm 100MB 左右;debian:bookworm-slim 50MB 左右;alpine 5MB 到 10MB;node:20 可能 1GB 左右;node:20-slim 大约 200MB 到 300MB;node:20-alpine 大约 130MB 左右;openjdk 传统镜像经常几百 MB,eclipse-temurin 的 jre 或 alpine 版本会小很多。

实际使用中发现,Node.js 项目从 node:latest 换成 node:20-slim,镜像从 1.2GB 降到 350MB 并不稀奇。Go 项目用多阶段构建后,最终镜像甚至可以压到几十 MB。

不过 alpine 不是万能。它使用 musl libc,有些依赖 glibc 的程序会出现兼容问题,比如部分 Python 包、图像处理库、某些商业 SDK。遇到这种情况,debian-slim 往往比 alpine 更省心。

多阶段构建是镜像瘦身的主力

多阶段构建的思路很简单:构建环境里需要编译器、SDK、依赖缓存、源码,但运行环境只需要最终产物和必要运行时。把这两部分拆开,镜像自然就小了。

Go 项目示例

FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server

FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/server /app/server
EXPOSE 8080
CMD ["/app/server"]

Go 如果能静态编译,最终镜像可以非常小。再极致一点可以用 scratch:

FROM scratch
COPY --from=builder /app/server /server
CMD ["/server"]

但 scratch 没有 shell、没有 CA 证书、没有时区文件,排障不方便。业务要访问 HTTPS,记得复制 ca-certificates。生产里更常见的是 alpine 或 distroless,体积和可维护性折中。

Node.js 项目示例

FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

Node.js 镜像大的核心往往是 node_modules。devDependencies 不要进生产镜像,npm cache 也要清。pnpm、yarn 同理,构建缓存不要留在最终镜像里。

Java 项目示例

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml ./
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]

Java 项目不要把 Maven 镜像直接当运行镜像。maven 镜像里有完整构建工具链,运行时根本用不上。JDK 和 JRE 也要区分,纯运行服务优先用 JRE 类镜像。

.dockerignore 经常被忽略,但效果很直接

很多镜像变大,是因为 COPY . . 把不该进镜像的东西全带进去了,比如 .git、日志文件、本地缓存、测试数据、node_modules、target、dist、.env、上传目录。

一个常用 .dockerignore 可以这样写:

.git
.idea
.vscode
node_modules
npm-debug.log
yarn-error.log
dist
target
*.log
.env
tmp
cache
coverage

这里要注意 dist、target 是否需要排除,取决于构建方式。如果是在 Docker 里构建,通常应该排除本地产物;如果是在 CI 外部构建好再 COPY 产物,就不要误排。

.dockerignore 不只是让镜像变小,还能让构建上下文变小。远程 Docker daemon、CI 构建时尤其明显,本来上传 800MB 上下文,处理后可能只剩几十 MB。

RUN 指令合并,不是越少越好,但临时文件要同层清掉

以前很多文章强调减少镜像层数量,现在 Docker 存储和构建缓存已经比早期成熟,层数不是唯一重点。更关键的是不要把临时文件、缓存、安装包留在层里。

Debian/Ubuntu 系镜像安装软件建议这样写:

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

--no-install-recommends 很有用,能少装不少推荐包。apt-get update 产生的 /var/lib/apt/lists 也要同层清掉。

Alpine 则是:

RUN apk add --no-cache curl ca-certificates

Python 项目:

RUN pip install --no-cache-dir -r requirements.txt

Node.js 项目:

RUN npm ci --omit=dev && npm cache clean --force

这些写法看起来细碎,但在长期维护的镜像里差别很明显。

不要把配置、密钥、业务数据塞进镜像

镜像应该尽量只包含运行程序和必要依赖。配置用环境变量、ConfigMap、挂载文件,业务数据用 volume 或对象存储。把上传文件、模型文件、日志目录打进镜像,镜像会越来越大,发布也越来越慢。

实际场景里见过把 8GB 静态资源直接 COPY 进镜像的做法。每次发版推镜像都很痛苦,Registry 存储也涨得快。更合理的方式是静态资源走 CDN 或对象存储,容器只保留服务代码。

如果是 AI 模型、地图包、游戏资源这类大文件,要看更新频率。更新频率低的可以做独立资源镜像或挂载数据盘;更新频率高的不要跟应用镜像绑死,否则每次业务小改动都要重新分发几个 GB。

镜像 tag 和 Registry 也要管

磁盘撑不住不一定只发生在业务机器,也常发生在私有 Registry。CI 每次构建一个 tag,按 commit、branch、时间戳全保留,几个月后 Registry 存储直接爆。

建议保留策略按环境区分。开发分支镜像保留 7 到 15 天,测试环境保留最近 20 到 50 个版本,生产环境保留最近若干稳定版本和关键回滚版本。具体保留多少要看发布频率和回滚习惯。

Harbor 这类 Registry 支持 retention policy 和 garbage collection。注意只删 tag 不一定立刻释放磁盘,还要跑 GC。跑 GC 前确认业务低峰,Registry 存储后端也要有备份策略。

构建顺序会影响缓存命中,也影响磁盘

Dockerfile 里 COPY 顺序很关键。依赖文件变化频率低,业务代码变化频率高,所以依赖安装应该尽量放在 COPY 业务代码之前。

Node.js 例子:

COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

如果写成 COPY . . 再 npm install,每次改一行代码都可能导致依赖层缓存失效,CI 重新下载依赖,构建缓存越积越多,构建时间也更长。

Java Maven、Gradle、Python pip、Go mod 都类似,先复制依赖描述文件,下载依赖,再复制源码。

线上机器磁盘太小,别硬扛

镜像瘦身能解决很多问题,但宿主机规格也要匹配业务。比如一台机器上跑 20 个容器,还有频繁发布和本地日志,40GB 系统盘很容易紧张。容器环境建议预留足够空间给 /var/lib/docker,或者单独挂载数据盘。

如果业务还在选机器,尤其是游戏服、海外节点、高防业务这类对带宽、稳定性、防护都有要求的场景,可以看看129云。比如泉州电信-D型有 8C、16G DDR4 ECC、120G SSD、40Mbps 峰值和 100Gbps 单机防御,适合需要基础防护又希望成本可控的服务;香港大宽带-C型有 300Mbps 峰值、60G SSD 数据盘和 1TB 流量,更偏海外访问和带宽型业务。选型不确定可以直接问客服,热线 400-9177118。

把 Docker 数据目录迁到独立磁盘

如果系统盘已经很小,业务又短期不方便迁移,Docker root dir 可以迁到独立数据盘。比如把新盘挂到 /data/docker,再修改 daemon.json:

{ "data-root": "/data/docker" }

操作前要停 Docker:

systemctl stop docker

迁移原数据:

rsync -aHAX /var/lib/docker/ /data/docker/

改配置后启动:

systemctl start docker

确认 Docker Root Dir:

docker info | grep "Docker Root Dir"

这里风险点在于迁移期间容器要停止,rsync 参数也不能乱省。生产机器建议先做快照或备份。数据库容器尤其要确认数据卷位置,别把 volume 当普通缓存处理。

用 docker history 看镜像到底胖在哪一层

镜像瘦身不能靠感觉。可以用 docker history 看每一层大小:

docker history your-image:tag

如果某一层突然几百 MB,就去查对应 Dockerfile 指令。常见大层包括 COPY . .、apt install、npm install、pip install、mvn package。

也可以用 dive 这类工具分析镜像层:

dive your-image:tag

dive 能看到每层新增了哪些文件,哪些文件后面被删除但仍占空间。排查“明明 rm 了为什么镜像还大”特别直观。

生产环境里常用的瘦身取舍

极致小镜像不一定就是最好维护的镜像。scratch、distroless 体积很漂亮,但没有 shell,线上排障时 exec 进去啥也干不了。对于成熟链路,比如日志、指标、链路追踪都完整,distroless 很合适;如果团队还依赖进容器临时 curl、cat 配置、看进程,那 debian-slim 可能更现实。

镜像瘦身大概可以按这种方式取舍:

Go 服务:多阶段构建,运行时用 alpine、distroless 或 scratch。需要 HTTPS 访问时处理 CA 证书。

Node.js 服务:用 slim 或 alpine,生产镜像只装 dependencies,不带 devDependencies,不带源码测试文件。

Java 服务:构建用 Maven/Gradle 镜像,运行用 JRE 镜像。可以考虑 jlink 定制 runtime,但维护成本更高。

Python 服务:优先 slim,谨慎 alpine。科学计算、图像处理、加密库较多时,alpine 可能带来编译和兼容问题。

PHP 服务:运行镜像里不要带 composer cache、构建依赖和测试目录。扩展编译依赖安装后要清理。

CI/CD 机器要定期清理,不然迟早爆

业务节点可以保守清理,CI 构建机就应该更主动。因为 CI 机器会频繁 build,不断产生 build cache、中间镜像、临时容器。

可以放一个定时任务,比如每天凌晨清理 3 天前的构建缓存:

docker builder prune -af --filter until=72h

再清理未使用镜像:

docker image prune -af --filter until=168h

如果是专用 CI 节点,没有业务容器和重要 volume,清理策略可以激进一些。但混部机器不要照搬,尤其不能随手加 --volumes。

还要关注 GitLab Runner、Jenkins workspace、临时制品目录。很多时候不是 Docker 单独撑爆磁盘,而是 workspace、缓存、镜像三者一起涨。

一个真实排查节奏大概是这样

机器告警磁盘 95%,先 df -h 看分区,确认是不是 / 被打满。然后 docker system df 看 Docker 占用结构。发现 Build Cache 30GB,Images 18GB,Containers 2GB,Volumes 5GB。

这时不碰 volume,先 docker builder prune --filter until=72h,释放十几 GB。再 docker image prune -a,释放旧镜像。然后查日志文件,发现某个容器 json-log 6GB,临时 truncate 清空,接着给 daemon.json 加 max-size 和 max-file。

磁盘恢复后再看镜像本身。docker history 发现 COPY . . 那层 900MB,进一步检查构建上下文,发现 .git、node_modules、coverage、测试视频文件都被复制进去了。加 .dockerignore 后镜像从 1.4GB 降到 420MB。再把 node:latest 换成 node:20-slim,生产阶段 npm ci --omit=dev,最终压到 260MB 左右。

别让镜像瘦身影响安全和可观测

清理包的时候不要把 CA 证书、时区、必要字体、健康检查工具误删。比如某些服务生成 PDF 需要字体,镜像一瘦身,把 fontconfig 和字体文件删了,PDF 乱码;某些服务访问 HTTPS,缺 ca-certificates 后直接证书校验失败。

安全扫描也要纳入流程。镜像越旧,漏洞越多。瘦身不是长期固定某个老版本基础镜像,而是选更合适的基础镜像并持续更新。Trivy、Grype、Harbor scanner 都可以做基础扫描。

另外,latest tag 少用。基础镜像升级要可控,建议固定大版本或具体 digest。否则今天构建和明天构建内容不同,排查问题会很麻烦。

最后给一段比较稳的 Node.js Dockerfile

FROM node:20-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

配套 .dockerignore:

.git
node_modules
dist
coverage
*.log
.env
.DS_Store
tmp
cache

这类写法不算极致,但在多数业务里够稳:构建缓存能命中,devDependencies 不进运行镜像,构建产物和运行环境分开,镜像大小也不会离谱。