容器化的边界感:用 Docker 把开发环境变成可复现的承诺
每次在新机器上拉一个项目跑起来,都是对「环境一致性」的一次考验。Node 版本差了半个大版本、本地没装 Redis、数据库用的默认端口跟另一个项目冲突——这些问题理论上都有解,但解法是「口头约定」而不是「代码约定」。用 Docker 把开发环境打包,不是为了追时髦,而是把「怎么跑起来」这件事从 README 的文字说明,变成可执行的代码承诺。
多阶段构建:分开「能跑」和「安全跑」
最初接触 Docker 时,我习惯一个 Dockerfile 搞定一切:装依赖、构建、复制产物、启动。镜像体积动辄 1GB+,里面还藏着开发工具链和构建时临时文件。
多阶段构建解决的正是这个问题——把「构建环境」和「运行环境」分成两个独立 stage:
# 依赖安装阶段
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 开发阶段(热重载、调试工具)
FROM node:22-alpine AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["npm", "run", "dev"]
# 生产阶段(最小镜像、非 root 用户)
FROM node:22-alpine AS production
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001
USER appuser
COPY --from=deps --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --chown=appuser:appgroup . .
CMD ["node", "dist/server.js"]开发时用 target: dev,生产时用 target: production。同一份 Dockerfile,两个承诺:开发阶段要快、要热重载;生产阶段要小、要安全。两者不需要妥协,也不应该合并。
卷策略:搞清楚「谁拥有这份数据」
Docker 卷(volume)是容器化里最容易踩坑的地方。我见过不少人把所有挂载都写成 bind mount——源码、数据库文件、构建缓存全挂到宿主机目录——结果数据库文件权限乱了,或者容器里的 node_modules 被宿主机的空目录覆盖掉。
卷的选择取决于「谁拥有这份数据、谁来负责它的生命周期」:
services:
app:
volumes:
- .:/app # bind mount:源码由开发者拥有,热重载靠它
- /app/node_modules # 匿名卷:容器生成的依赖,不被宿主机目录覆盖
- /app/.next # 匿名卷:构建缓存,不污染宿主机
db:
volumes:
- pgdata:/var/lib/postgresql/data # 命名卷:数据库文件由 Docker 管理三种卷各司其职:bind mount 给源码(开发者拥有)、命名卷给持久数据(Docker 管理、容器重建后依然存在)、匿名卷做隔离屏障(防止宿主机空目录覆盖容器已有文件)。
网络分层:最小暴露原则
Compose 默认给所有服务创建同一个网络,任何服务都能访问其他任何服务。对于小项目够用,但随着服务变多,「前端容器直接访问数据库」的可能性就变成了真实的安全风险。
自定义网络让你明确声明「谁能和谁通信」:
networks:
frontend-net:
backend-net:
services:
web:
networks: [frontend-net]
api:
networks: [frontend-net, backend-net] # 两个网络的桥梁
db:
networks: [backend-net] # 只有 api 能访问,web 不可达数据库只接入 backend-net,前端服务物理上根本访问不到它——这比依赖「前端不应该直连数据库」这条口头规定要可靠得多。
开发环境映射数据库端口时,"5432:5432" 和 "127.0.0.1:5432:5432" 有本质区别:前者绑定到所有网卡,同局域网的其他机器可以直接访问你的数据库;后者只绑定本地回环,外部无法触达。开发机在咖啡厅用公共 Wi-Fi 时,这个区别值得认真对待。
安全不是后置的装饰
容器的安全加固往往被推到「以后再说」,但很多措施成本极低,只是习惯问题:
不用 :latest 标签。 node:22-alpine 今天和三个月后是不同的镜像,latest 让构建失去可复现性。固定到 node:22.12-alpine3.20 这样的精确版本,至少能保证「今天能跑的镜像,六个月后拉下来还是同一个」。
不以 root 运行。 容器内 root 不等于主机 root,但也没必要放宽权限。两行命令创建非特权用户,生产镜像里基本没有不加这两行的理由。
secret 不进镜像层。 环境变量可以通过 env_file 在运行时注入,.env 文件加进 .gitignore 和 .dockerignore。一旦密钥进了镜像层,即使后来的层覆盖了它,docker history 仍然能把它翻出来。
一句话心法
Docker 的价值不在于「能跑起来」,而在于「任何人在任何机器上,用同一份代码,得到完全一致的行为」——可复现性才是容器化真正要兑现的承诺。