Docker镜像越来越大磁盘撑不住了怎么瘦身
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 不进运行镜像,构建产物和运行环境分开,镜像大小也不会离谱。