4. Dockerfile最佳实践:多阶段构建、减少镜像层数、使用.dockerignore、安全注意事项

聊到Dockerfile,很多人觉得不就是写几行指令嘛。但说实话,我见过太多「能用就行」的Dockerfile了。镜像体积几个G,构建时间十几分钟,跑起来还一堆安全漏洞。嗯,今天咱们就好好聊聊,怎么写一个真正能上生产的Dockerfile。

4.1 多阶段构建:把「厨房」和「餐桌」分开

多阶段构建是我个人最推崇的实践之一。说白了,就是让你在构建阶段用「大而全」的环境,在运行阶段用「小而精」的镜像。

为什么会这样?因为Node.js应用在构建时需要一堆依赖——TypeScript编译器、Webpack、各种devDependencies。但运行时呢?只需要编译后的代码和运行时依赖就够了。

核心思想:构建阶段用完整工具链,运行阶段只保留最小产物。

来看一个实际例子。我以前维护过一个Express项目,最初Dockerfile是这样的:

FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]

这个镜像有多大?1.2GB。里面包含了TypeScript编译器、所有devDependencies、甚至还有npm缓存。实际上线根本用不到这些东西。

改成多阶段构建后:

# 第一阶段:构建
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# 第二阶段:运行
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/index.js"]

镜像体积直接降到180MB。我在项目中遇到过类似情况,当时团队还觉得多阶段构建太麻烦。结果一对比,没人再提「麻烦」这回事了。

小技巧:第一阶段用完整版node镜像,第二阶段用-alpine版本。这样既保证了构建兼容性,又控制了运行体积。

4.2 减少镜像层数:每一层都是「包袱」

Docker镜像是由一层层文件系统叠加而成的。每一条RUN、COPY指令都会产生一个新层。层数多了,不仅镜像体积大,构建和拉取速度也会变慢。

我见过最夸张的Dockerfile,光RUN指令就写了十几条:

RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN npm install
RUN npm cache clean --force

这会产生5个层。其实完全可以合并:

RUN apt-get update && apt-get install -y curl git \
    && npm install \
    && npm cache clean --force \
    && rm -rf /var/lib/apt/lists/*

你看,一个RUN搞定。而且我习惯在最后加一句清理操作,把apt缓存删掉。这些缓存留在镜像里完全没用,只会增加体积。

注意:合并指令时要注意顺序。把变化频率低的指令放前面,这样可以利用Docker的缓存机制,加快构建速度。

还有一个常见的坑——COPY和RUN的顺序。我曾经看到有人这样写:

COPY . .
RUN npm install

这会导致一个问题:只要源代码有任何改动,npm install这层缓存就失效了。每次都要重新安装依赖,构建时间从几秒变成几分钟。

正确的做法是:

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

先把依赖文件拷进来,安装完依赖,再拷源代码。这样只要package.json没变,npm install这层就能命中缓存。

4.3 使用.dockerignore:别把「垃圾」带进镜像

.dockerignore的作用和.gitignore类似。它告诉Docker:构建时忽略哪些文件。

很多人不写.dockerignore,结果把node_modules、.git、日志文件全打包进镜像了。你想想看,本地开发环境有几百MB的node_modules,传到镜像里干嘛?运行环境根本用不到。

我一般会在项目根目录放一个.dockerignore:

node_modules
.git
.gitignore
*.md
.env
.env.*
dist
.cache
logs
npm-debug.log*
Dockerfile
.dockerignore

这里有个细节要注意:.env文件绝对不能进镜像。我曾经接手过一个项目,.env文件里写着生产环境的数据库密码,结果镜像被推到了公共仓库...嗯,后果你可以想象。

最佳实践:把.dockerignore当作Dockerfile的「守门员」。任何不该进镜像的东西,都在这里拦住。

4.4 安全注意事项:别给自己「埋雷」

安全这块,我吃过不少亏。总结几个关键点:

4.4.1 不要用root用户运行应用

Docker默认用root用户运行容器。这意味着如果应用被攻破,攻击者直接拥有容器内的最高权限。

正确的做法是创建一个普通用户:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

把USER指令放在CMD之前。这样应用就以普通用户身份运行了。

4.4.2 不要硬编码敏感信息

我见过有人在Dockerfile里直接写密码:

ENV DB_PASSWORD=123456

这简直是灾难。镜像会被分发、被存储、被扫描。密码等于公开了。

正确做法是用构建参数或运行时环境变量:

ARG DB_PASSWORD
ENV DB_PASSWORD=$DB_PASSWORD

或者干脆用Docker Secrets、Kubernetes Secrets来管理。

4.4.3 定期扫描镜像漏洞

基础镜像也会有安全漏洞。我习惯用docker scout或者trivy定期扫描:

docker scout quickstart
docker scout cves your-image:tag

发现高危漏洞就及时更新基础镜像版本。别等到被攻击了才想起来补。

避坑指南:我曾经用了一个node:14-slim镜像,里面有个OpenSSL的高危漏洞。当时没在意,结果安全审计没过,连夜升级到node:16才解决。从那以后,我每次构建都会加一步漏洞扫描。

4.4.4 最小化安装原则

能不用apt-get install就别用。如果非要用,装完立刻清理:

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

还有,别装那些调试工具。vim、netcat、tcpdump这些,生产环境用不到。真需要调试,用docker exec临时进容器就行。

4.5 总结一下

写Dockerfile就像装修房子。多阶段构建是把施工区和生活区分开;减少层数是别堆太多杂物;.dockerignore是别把垃圾带进门;安全注意事项是装好防盗门和烟雾报警器。

这些实践看起来都是小细节,但组合起来效果惊人。我优化过的一个项目,镜像体积从1.2GB降到150MB,构建时间从8分钟降到45秒,安全漏洞从23个降到0个。你想想看,这能省多少带宽、多少时间、多少风险?

嗯,今天就聊到这儿。下一章咱们聊聊容器编排,到时候见。