跳到主要内容

镜像与 Dockerfile

上一章使用了别人制作的 Nginx 镜像。本篇将制作一个属于自己的静态网站镜像。完成后,你将能够:

  • 理解镜像名、标签、层和构建上下文;
  • 编写基础 Dockerfile;
  • 使用 .dockerignore 排除无关文件;
  • 构建、运行、检查和删除镜像;
  • 理解缓存和多阶段构建的用途。

1. 镜像从哪里来

镜像通常来自两种途径:

  1. 使用 docker pull 从 Docker Hub 或私有仓库下载;
  2. 使用 docker build 根据 Dockerfile 自己构建。

镜像引用的一般格式是:

# 完整格式:仓库地址/命名空间/镜像名:标签
registry.example.com/team/myapp:1.0.0

# Docker Hub 官方镜像可以省略仓库地址和命名空间
nginx:1.27-alpine

nginx 是镜像名,1.27-alpine 是标签。省略标签时 Docker 默认使用 latest,但 latest 只是普通标签,并不保证内容固定或一定最新。教程和生产部署都建议显式写版本。

2. 管理已有镜像

# 从 Docker Hub 拉取固定标签的 Nginx 镜像
docker pull nginx:1.27-alpine

# 列出本地镜像;注意 REPOSITORY、TAG、IMAGE ID 和 SIZE
docker image ls

# 查看镜像的启动命令、环境变量、架构和层等元数据
docker image inspect nginx:1.27-alpine

# 查看镜像每一层对应的构建命令和大致大小
docker history nginx:1.27-alpine

同一个镜像可以有多个标签。标签类似指向镜像内容的易读名称,不会复制一份镜像数据。

3. 动手制作第一个镜像

3.1 创建实验目录

# 在用户主目录创建独立实验目录,避免混入其他项目文件
mkdir -p ~/docker-beginner/site
cd ~/docker-beginner/site

# 确认后续命令所在目录
pwd

在目录中创建 index.html

<!-- 这是要放进镜像的静态首页 -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>我的第一个 Docker 镜像</title>
</head>
<body>
<h1>Docker 构建成功</h1>
<p>这个页面来自自定义镜像。</p>
</body>
</html>

3.2 编写 Dockerfile

在同一目录创建一个文件名严格为 Dockerfile 的文件:

# 使用体积较小的官方 Nginx 镜像作为基础镜像
FROM nginx:1.27-alpine

# 删除基础镜像自带的欢迎页
RUN rm -f /usr/share/nginx/html/index.html

# 把当前构建目录中的首页复制进镜像
COPY index.html /usr/share/nginx/html/index.html

# 记录应用预期监听 80 端口;此指令不会自动发布端口
EXPOSE 80

Dockerfile 指令的作用:

指令作用
FROM指定基础镜像;通常是第一条有效指令
RUN在构建过程中执行命令,并把结果保存到镜像层
COPY从构建上下文复制文件到镜像
WORKDIR设置后续指令的工作目录
ENV设置镜像中的环境变量
EXPOSE说明容器预计监听的端口,不负责端口映射
CMD设置容器默认启动命令,可被运行参数覆盖
ENTRYPOINT设置容器入口程序,适合固定可执行文件

3.3 添加 .dockerignore

docker build 会把指定目录作为构建上下文发送给构建器。不要把 Git 历史、密钥和本地依赖一并发送。在同一目录创建 .dockerignore

# 版本控制元数据不参与镜像构建
.git

# 环境变量和私钥绝不能复制进镜像
.env
*.pem

# 常见的本地依赖和构建输出
node_modules
dist

# 编辑器与系统临时文件
.DS_Store
.idea
.vscode

3.4 构建镜像

# -t 设置镜像名和标签;最后的点表示当前目录是构建上下文
docker build --tag beginner-site:1.0 .

# 确认新镜像出现在本地列表中
docker image ls beginner-site

构建日志中的 FROMRUNCOPY 基本对应 Dockerfile 中的步骤。最后应该看到镜像被命名为 beginner-site:1.0

最后的点不能省略

docker build -t beginner-site:1.0 . 最后的 . 表示“把当前目录作为构建上下文”。COPY index.html ... 只能复制上下文内且未被 .dockerignore 排除的文件。

3.5 运行并验证

# 基于自制镜像启动容器,并映射到本机 8080 端口
docker run --detach \
--name beginner-site \
--publish 8080:80 \
beginner-site:1.0

# 请求页面;响应中应出现“Docker 构建成功”
curl http://127.0.0.1:8080

# 查看容器使用的镜像和端口映射
docker ps --filter name=beginner-site

也可以在浏览器打开 http://localhost:8080。完成后清理容器:

# -f 会先停止再删除这个实验容器;不会删除镜像
docker rm --force beginner-site

4. 修改页面并理解缓存

修改 index.html 后构建新版本:

# 使用新标签构建第二版;未变化的步骤可能显示 CACHED
docker build --tag beginner-site:1.1 .

# 同时列出两个标签,比较 IMAGE ID
docker image ls beginner-site

Dockerfile 中每条会改变文件系统的指令通常形成一层。某一步及其输入没有变化时,Docker 可以复用缓存。某一步发生变化后,它以及后续依赖层通常需要重新构建。

缓存优化的基本原则是:先复制变化少的文件,后复制变化频繁的文件。例如 Node.js 项目应先复制依赖清单并安装依赖,再复制源码:

# 使用固定主版本的 Node.js 基础镜像
FROM node:22-alpine

# 后续 COPY 和 RUN 都在 /app 下执行
WORKDIR /app

# 依赖清单通常比业务源码变化少,因此先单独复制
COPY package.json package-lock.json ./

# npm ci 根据锁文件进行可复现安装
RUN npm ci

# 最后复制经常变化的业务源码
COPY . .

# 使用 JSON 数组形式,让 Node 正确接收停止信号
CMD ["node", "server.js"]

如果先执行 COPY . .,任何源码变化都会使后面的依赖安装缓存失效。

5. 多阶段构建

编译型项目需要构建工具,但运行阶段通常不需要。多阶段构建可以在第一阶段编译,在第二阶段只复制运行产物。下面是 Node.js 项目的结构示例:

# syntax=docker/dockerfile:1

# 第一阶段负责安装完整依赖并构建应用
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# 第二阶段只包含生产运行所需文件
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

# 只安装生产依赖,减少最终镜像内容
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

# 从 build 阶段复制编译结果,不复制源码和构建工具
COPY --from=build /app/dist ./dist

# 官方 Node 镜像内置低权限 node 用户
USER node
EXPOSE 3000

# exec 形式可以让应用正确接收 SIGTERM
CMD ["node", "dist/server.js"]

这段示例需要项目本身存在 package.json、锁文件、构建脚本和 dist/server.js,不能直接用于前面的纯 HTML 实验。重点是理解“构建环境”和“运行环境”可以分开。

6. 标签、仓库与 digest

为镜像增加仓库标签后才能推送:

# 为已有镜像增加完整仓库标签;请替换仓库和命名空间
docker tag beginner-site:1.0 registry.example.com/team/beginner-site:1.0

# 登录私有仓库;推荐通过标准输入传递密码,避免出现在命令历史
printf '%s' "$REGISTRY_PASSWORD" | \
docker login registry.example.com --username "$REGISTRY_USER" --password-stdin

# 推送带完整仓库地址的标签
docker push registry.example.com/team/beginner-site:1.0

标签可以被重新指向其他内容;digest 是镜像内容的加密摘要,更适合要求严格复现的部署:

# 查看拉取后记录的内容摘要
docker image inspect nginx:1.27-alpine \
--format '{{json .RepoDigests}}'

# 生产环境可按真实 digest 拉取,尖括号内容需要替换
docker pull nginx@sha256:<真实的镜像摘要>

7. 不要把密钥写进镜像

ARGENVCOPY 和构建命令历史都可能泄露凭据。需要私有依赖时,使用 BuildKit secret:

# secret 只在这一条 RUN 指令期间挂载,不写入最终镜像层
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
# 将本机 .npmrc 作为临时 secret 提供给上面的构建步骤
docker build \
--secret id=npmrc,src="$HOME/.npmrc" \
--tag private-app:1.0 .

8. 清理实验镜像

# 只删除明确指定的两个实验标签;仍被容器使用时会拒绝删除
docker image rm beginner-site:1.0 beginner-site:1.1

# 查看删除后的镜像列表和空间占用
docker image ls
docker system df

不要为了省事直接执行 docker system prune -a。新手阶段应逐个确认对象,理解容器和镜像之间的引用关系。

9. 本篇练习

  1. 在首页增加一段文字,构建 beginner-site:1.2 并运行验证;
  2. 把 Dockerfile 中的 EXPOSE 80 删除,再构建运行,验证 -p 8080:80 是否仍然有效;
  3. 在目录中创建 .env,确认 .dockerignore 已排除它;
  4. docker history beginner-site:1.2 对照 Dockerfile 的每一步。

下一篇将深入容器根文件系统,解释镜像层、Docker init 层、容器可写层和 OverlayFS 联合视图。