制品构建与管理 - Docker 镜像的最佳实践
制品构建与管理 - Docker 镜像的最佳实践
为何使用容器?SRE 的视角
对于 SRE 来说,拥抱容器化(以 Docker 为代表)不仅仅是追赶技术潮流,更是因为它直接解决了运维中的许多核心痛点,并支撑了 SRE 的核心原则:
- 环境一致性 (Environment Consistency):容器将应用程序代码、运行时(如 Node.js)、系统工具、库和配置全部打包在一起。这个镜像在开发人员的笔记本、测试服务器和生产集群上运行时,其内部环境是完全一致的,从根本上消除了“环境不一致”导致的问题。
- 不可变基础设施 (Immutable Infrastructure):我们不应该登录到正在运行的容器中去修改它(这是可变操作)。正确的做法是:构建一个包含新代码或配置的新镜像,然后用新镜像创建的容器去替换旧的容器。这种“替换而非修改”的模式使得部署过程更可预测、更可靠,回滚也变得异常简单——只需重新部署上一个版本的镜像即可。
- 依赖项隔离 (Dependency Isolation):应用的所有依赖都封装在镜像内部,不会与宿主机或其他容器的依赖产生冲突。
- 可移植性 (Portability):一个容器镜像可以在任何安装了容器运行时(如 Docker, containerd)的机器上运行,无论是物理机、虚拟机还是云实例。
- 可扩展性 (Scalability):容器非常轻量,启动速度快,这使得在 Kubernetes 这样的编排平台上快速扩缩容应用实例变得轻而易举。
编写 Dockerfile:从基础到最佳实践
Dockerfile 是一个文本文件,它包含了一系列指令,用于告诉 Docker 如何构建一个镜像。让我们从一个简单、直观但不推荐的 Dockerfile 开始,然后逐步优化它。
一个“天真”的 Dockerfile (Dockerfile.bad
)
很多人刚开始可能会这么写:
# Dockerfile.bad - 不推荐的写法
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD [ "node", "app.js" ]
这个 Dockerfile 能工作,但存在几个严重的问题:
- 镜像体积过大:
node:18
是一个包含完整操作系统的基础镜像,体积庞大。COPY . .
会将当前目录下的所有文件(包括node_modules
、.git
文件夹、README.md
、Dockerfile 本身等)都拷贝到镜像中,造成不必要的臃肿。RUN npm install
会安装package.json
中所有的依赖,包括只在开发和测试时才需要的devDependencies
。
- 构建缓存效率低下:
- Docker 构建是分层的。
COPY . .
这一步,只要我们修改了任何一个源代码文件,这一层的缓存就会失效。 - 由于
RUN npm install
在COPY . .
之后,代码的任何微小改动都会导致npm install
这一步被重新执行,即使package.json
文件根本没有变化。这会极大地拖慢构建速度。
- Docker 构建是分层的。
- 安全风险:
- 默认情况下,容器内的进程是以
root
用户身份运行的,这带来了不必要的安全风险。 - 将
devDependencies
和完整的源代码(可能包含测试文件等)打包到最终的生产镜像中,增大了攻击面。
- 默认情况下,容器内的进程是以
最佳实践:优化的 Dockerfile
现在,让我们运用最佳实践来重写它。请在你的项目根目录下创建这个 Dockerfile
文件:
# Dockerfile# --- 阶段 1: 基础构建环境 ---
# 使用一个更小的、官方的 slim 版本作为基础镜像
FROM node:18-slim AS base# 设置工作目录
WORKDIR /app# 优化缓存:先只拷贝 package.json 和 package-lock.json
COPY package.json package-lock.json ./# 运行 npm ci,它会严格按照 package-lock.json 安装所有依赖(包括 devDependencies)
# 这一层只有在 package-lock.json 变化时才会重新构建
RUN npm ci# 拷贝所有源代码
COPY . .# --- 阶段 2: 生产环境 ---
# 从一个干净的、相同版本的 slim 镜像开始,而不是在 base 阶段的基础上继续
FROM node:18-slim AS productionWORKDIR /app# 从 'base' 阶段拷贝已经安装好的生产依赖
# `--production` 标志确保只拷贝 dependencies,不包括 devDependencies
COPY --from=base /app/node_modules ./node_modules
# 拷贝应用代码和 package.json
COPY app.js .
COPY package.json .# 安全实践:创建一个非 root 用户并切换到该用户
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 appuser
USER appuser# 暴露端口
EXPOSE 3000# 定义容器启动命令
CMD [ "node", "app.js" ]
同时,在项目根目录下创建一个 .dockerignore
文件,告诉 Docker 在构建时忽略哪些文件:
.dockerignore
node_modules
npm-debug.log
Dockerfile*
.dockerignore
.git
.gitignore
README.md
.github
这个优化后的 Dockerfile 体现了几个关键的最佳实践:
- 多阶段构建 (Multi-stage Builds):我们定义了
base
和production
两个阶段。base
阶段用于安装所有依赖并运行测试(如果需要)。production
阶段则从一个全新的干净镜像开始,只从base
阶段拷贝运行应用所必需的东西(生产依赖和应用代码),最终得到的生产镜像是最小化的。 - 使用小体积的基础镜像 (
node:18-slim
):slim
版本比默认版本小得多,减少了镜像体积和潜在的漏洞。 - 利用构建缓存: 将
COPY package*.json ./
和RUN npm ci
这两个步骤放在COPY . .
之前。这样,只要package-lock.json
文件不改变,耗时的依赖安装层就会被缓存,只有在代码变化时才需要重新执行后续的COPY
操作。 - 使用
.dockerignore
: 防止不必要的文件被发送到 Docker 守护进程,减小构建上下文,避免敏感信息泄露。 - 以非 root 用户运行: 创建一个专用的、无特权的用户来运行应用,这是重要的安全加固措施。
4. 集成到 CI/CD 流水线
现在,我们的目标是在上一篇的 CI 流水线基础上,增加一个新任务 (Job):当代码测试通过并且变更被合并到 main
分支后,自动构建这个优化后的 Docker 镜像,并将其推送到 GitHub Container Registry (GHCR)——一个与 GitHub 仓库集成的容器镜像仓库。
修改 .github/workflows/ci.yml
文件如下:
# .github/workflows/ci.yml
name: Node.js CI/CDon:push:branches: [ "main" ]pull_request:branches: [ "main" ]# 为整个工作流定义环境变量,方便复用
env:REGISTRY: ghcr.io# 镜像名称将是 ghcr.io/你的用户名/你的仓库名IMAGE_NAME: ${{ github.repository }}jobs:# 第一个任务:构建和测试,与上一篇基本相同build-and-test:runs-on: ubuntu-lateststeps:- name: Checkout repositoryuses: actions/checkout@v4- name: Use Node.js 18.xuses: actions/setup-node@v4with:node-version: '18.x'cache: 'npm'- name: Install dependenciesrun: npm ci- name: Run linterrun: npm run lint- name: Run testsrun: npm test# 新增的第二个任务:构建并推送 Docker 镜像build-and-push-image:# `needs` 关键字确保此任务必须在 `build-and-test` 成功后才运行needs: build-and-test# `if` 条件确保此任务只在 push 到 main 分支时运行,而不是在 pull request 时if: github.event_name == 'push' && github.ref == 'refs/heads/main'runs-on: ubuntu-latest# `permissions` 块用于授予 GITHUB_TOKEN 额外的权限# `packages: write` 允许工作流向 GHCR 推送镜像permissions:contents: readpackages: writesteps:- name: Checkout repositoryuses: actions/checkout@v4# 使用官方的 docker/login-action 动作登录到 GHCR- name: Log in to the GitHub Container Registryuses: docker/login-action@v3with:registry: ${{ env.REGISTRY }}username: ${{ github.actor }} # github.actor 是触发工作流的用户名password: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN 是 GitHub Actions 自动生成的临时令牌# 使用 docker/metadata-action 动作来自动提取镜像的元数据,如标签- name: Extract metadata (tags, labels) for Dockerid: metauses: docker/metadata-action@v5with:images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}# 使用 docker/build-push-action 动作来构建并推送镜像- name: Build and push Docker imageid: build-and-pushuses: docker/build-push-action@v5with:context: . # 使用当前目录作为构建上下文push: true # 明确指示要推送镜像tags: ${{ steps.meta.outputs.tags }} # 使用 metadata-action 生成的标签labels: ${{ steps.meta.outputs.labels }}# 启用 GitHub Actions 的构建缓存,加快后续构建速度cache-from: type=ghacache-to: type=gha,mode=max
5. 验证结果
- 将新的
Dockerfile
、.dockerignore
以及更新后的ci.yml
文件提交并推送到你的 GitHub 仓库的main
分支。 - 进入仓库的 “Actions” 页面,观察工作流的运行。你会看到
build-and-test
任务成功后,build-and-push-image
任务开始执行。 - 当整个工作流成功运行后,回到你的 GitHub 仓库主页。在右侧边栏,找到并点击 “Packages”。
- 在这里,你应该能看到一个与你的仓库同名的 Package,点击进入后就能看到刚刚被推送上来的 Docker 镜像及其标签(例如
latest
和一个基于 Git SHA 的标签)。
SRE 视角的思考
通过今天的实践,我们取得了巨大的进步:
- 制品管理 (Artifact Management):我们不再仅仅是验证代码,而是产出了一个版本化的、不可变的、标准化的软件制品(Docker 镜像),并将其存储在了一个中心的制品库 (GHCR) 中。这是实现可靠部署的前提。
- 可复现性 (Reproducibility):任何人或任何自动化系统,只要拉取特定标签(如 Git SHA 标签)的镜像,就能获得一个完全相同的、经过测试的运行时环境。
- 安全加固: 通过优化的 Dockerfile,我们显著减小了生产镜像的体积和攻击面。
- 效率: 多阶段构建和 CI 缓存的运用,确保了我们的自动化流程在保证质量的同时,也保持了高效。
总结
今天,我们掌握了为何容器化对 SRE 至关重要,并深入实践了如何编写一个遵循最佳实践的 Dockerfile。更重要的是,我们成功地将自动化的镜像构建与推送流程无缝地集成到了我们的 CI 流水线中。
现在,我们有了一个经过测试、版本化、并安全存储的软件制品。它已经整装待发,准备被部署到真实的运行环境中。
在下一篇中,我们将迈出从 CI 到 CD 的关键一步:我们将学习如何让流水线自动地将这个镜像部署到一个 Kubernetes 集群中,真正打通从代码提交到应用在云原生环境中运行的“最后一公里”。敬请期待!