3. 容器化部署实战:Dockerfile优化、镜像构建缓存、容器资源限制、数据持久化陷阱

容器化部署,说白了就是把应用连同它的运行环境一起打包。听起来简单,但坑是真不少。我见过太多团队,Dockerfile 写得像流水账,镜像几个 G 大,部署上去动不动 OOM。这一章,我把这几年踩过的坑和总结的经验,一次性倒给你。

核心要点:容器化部署不是「把代码塞进镜像就完事」。Dockerfile 的每一行、镜像的每一层、容器的每一个参数,都藏着性能和安全的关键。

3.1 Dockerfile 优化:从「能用」到「高效」

先看一个反面教材。我接手过一个项目,Dockerfile 长这样:

FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y python3 python3-pip
COPY . /app
WORKDIR /app
RUN pip3 install -r requirements.txt
CMD ["python3", "app.py"]

有什么问题?问题大了去了。

  • 基础镜像太肥:ubuntu:latest 几百兆,换成 python:3.9-slim 能瘦到 100M 左右。
  • 层数过多:每个 RUN 都产生一层。我建议把 apt-get 和 pip 合并到一层。
  • 没有清理缓存:apt-get install 后的 /var/cache/apt 还在镜像里,白白占用空间。
  • COPY 时机不对:把整个项目复制进去,再装依赖。你想想看,只要代码改一行,整个 pip install 缓存就失效了。

优化后的版本:

FROM python:3.9-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.9-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages
COPY . .
CMD ["python3", "app.py"]

这里用了多阶段构建。第一阶段只装依赖,第二阶段只复制产物。最终镜像小,而且依赖层不会因为代码变动而失效。

我的习惯:Dockerfile 里,把「不常变的内容」放在前面(比如系统包、Python 依赖),把「经常变的内容」放在后面(比如业务代码)。这样能最大化利用构建缓存。

3.2 镜像构建缓存:别让缓存成为摆设

Docker 构建镜像时,每一层都会缓存。如果某层没变,就直接用缓存。但很多人没搞明白缓存的失效规则。

举个例子:

COPY package.json /app/
RUN npm install
COPY . /app/

只要 package.json 没变,npm install 这层就会命中缓存。但如果把 COPY . /app/ 写在 npm install 前面,那每次代码变动都会导致 npm install 重新执行——哪怕依赖没变。

为什么会这样?因为 Docker 检查的是「输入文件的内容」。你把整个目录复制进去,任何文件改动都会让这层缓存失效,后续所有层都得重跑。

我曾经踩过的坑:有一次构建镜像花了 15 分钟,排查发现是 COPY . /app/ 放在了前面,导致每次构建都重新下载所有 npm 包。调整顺序后,构建时间降到了 30 秒。

缓存优化的几个原则:

  • 先复制依赖描述文件(package.json、requirements.txt、go.mod 等),再装依赖。
  • 后复制业务代码。
  • 利用 .dockerignore 排除不需要的文件(node_modules、.git、__pycache__ 等),减少缓存失效概率。
  • 多阶段构建:把编译环境和运行环境分开,编译阶段的缓存失效不影响最终镜像。

3.3 容器资源限制:别让一个容器拖垮整个宿主机

默认情况下,容器可以占用宿主机的全部资源。你想想看,如果某个容器内存泄漏,或者某个进程突然吃满 CPU,其他容器怎么办?

我见过一个生产事故:一个 Java 应用没设置内存限制,GC 时把宿主机内存吃光,导致同一台机器上的其他服务全部 OOM 被 kill。从那以后,我要求所有容器必须设置资源限制。

Docker 的资源限制参数:

参数 作用 示例
--memory 限制最大内存 --memory=512m
--memory-swap 限制内存+交换分区总和 --memory-swap=1g
--cpus 限制 CPU 核心数 --cpus=1.5
--cpu-shares 设置 CPU 权重(相对值) --cpu-shares=512
--pids-limit 限制进程数 --pids-limit=100

我建议:生产环境至少设置 --memory 和 --cpus。Java 应用还要额外设置 JVM 参数(-Xmx),让 JVM 知道容器的内存上限,避免 JVM 申请超过容器限制的内存。

举个例子,启动一个 Java 容器:

docker run -d \
  --name my-java-app \
  --memory=512m \
  --memory-swap=512m \
  --cpus=1 \
  -e JAVA_OPTS="-Xmx400m -Xms400m" \
  my-java-image

这里 --memory-swap=512m 表示禁用交换分区(因为内存和交换总和等于内存上限)。为什么禁用交换?因为容器 OOM 时,Docker 会直接 kill 进程,而不是让它在交换分区里苟延残喘——那样性能更差。

3.4 数据持久化陷阱:容器重启后数据去哪了?

容器是短暂的。一旦删除,容器内的所有数据都会消失。这是新手最容易犯的错误。

我遇到过好几次:开发人员在容器里跑数据库,容器重启后数据全没了,然后跑来问我「为什么数据丢了?」——嗯,因为数据写在容器层,容器没了,数据自然也没了。

解决方案:使用 Volume 或 Bind Mount。

方式 说明 适用场景
Volume 由 Docker 管理,存储在 /var/lib/docker/volumes/ 数据库、配置文件等需要持久化的数据
Bind Mount 直接映射宿主机目录 开发环境热更新、日志收集
tmpfs Mount 存储在内存中 敏感数据(如密码文件)

使用 Volume 的示例:

docker run -d \
  --name mysql \
  -v mysql-data:/var/lib/mysql \
  -e MYSQL_ROOT_PASSWORD=root \
  mysql:8.0

这样,即使容器被删除,数据还在 Volume 里。下次启动时挂载同一个 Volume,数据就回来了。

注意:Bind Mount 在 macOS 和 Windows 上性能较差(因为文件共享机制)。生产环境优先使用 Volume。另外,不要把宿主机的重要目录(如 /etc、/var)直接挂载给容器,安全风险太大。

3.5 知识体系总览

下面这张图,把容器化部署的核心知识点串起来了:

容器化部署核心知识体系 容器化部署 Dockerfile 优化 镜像构建缓存 容器资源限制 数据持久化 多阶段构建 层数优化 基础镜像瘦身 依赖前置 .dockerignore 分层缓存策略 内存限制 CPU 限制 进程数限制 Volume 管理 Bind Mount 性能

这张图把四个核心知识点串起来了。你从「容器化部署」出发,沿着箭头走,每个分支都有对应的子知识点。我个人习惯在写 Dockerfile 时,脑子里先过一遍这张图——镜像怎么优化、缓存怎么利用、资源怎么限制、数据怎么持久化。想清楚了再动手,能省不少事。

最后说一句:容器化不是银弹。它解决了环境一致性问题,但也带来了新的复杂度。把 Dockerfile 写好、资源限制设好、数据持久化做好,你的容器化之路才能走得稳。