LOADING
6513 words
33 minutes
Docker 工程化实践(一):Docker 基本原理与基础语法

Docker 工程化实践系列 · 第 1 篇

引言:从”我电脑上能跑”说起

在后端开发中,有一个场景你一定不陌生:代码在本地跑得一切正常,部署到服务器之后却各种报错。你排查了半天,发现不是代码逻辑的问题——是服务器上的 Node.js 版本比本地低了两个大版本,是服务器上没装 pnpm,是数据库版本不一致,是某个系统依赖在 Linux 上的行为跟你的 Windows 开发机不一样(CI/CD流水线特别要求版本号)

于是我不得不跑到服务器上重新执行一遍环境配置:装 Node.js、装 pnpm、装数据库、装 Redis、配环境变量……如果只有一台服务器,咬咬牙也就过去了。但如果项目需要部署到测试环境、预发布环境和多台生产服务器,每次都要重复这一套手动配置流程,效率极低,而且每次配出来的环境都可能不一样,并且多个程序需要运行时导致服务器下载这么多依赖会让服务器压力过大

我第一次做的时候在服务器上安装依赖直接让服务器死机了,以下是我学习docker过程中整理出我的博客文档,从传统部署的痛点出发,逐步建立 Docker 的核心概念模型,然后介绍 Dockerfile 的基础指令,最后通过一个 hello-world 实验帮你建立对容器生命周期的直观理解。

一、为什么需要 Docker

1. 环境差异是部署的最大敌人

一个典型的 Node.js 后端项目,在开发者本地可能使用这样的环境:

Node.js 22 + pnpm 10 + PostgreSQL 17 + Redis 8

而服务器上可能是:

Node.js 18 + 没有 pnpm + PostgreSQL 版本不同 + Redis 尚未安装

即使代码完全一样,项目也会因为环境不同而无法运行。常见的问题包括 Node.js 版本不兼容、包管理器版本不同、操作系统依赖缺失、数据库版本不一致、环境变量没有正确配置,甚至本地用 Windows 而服务器用 Linux 导致的路径和行为差异。

这些差异叠加在一起,最终导致同一份代码在不同机器上表现出完全不同的行为。开发人员不得不把每台服务器都手动配置一遍,而这正是传统部署方式最大的痛点。

2. 传统部署的五个痛点

在传统部署方式中,我们通常先准备一台服务器,然后逐步安装应用需要的软件。以 Node.js 项目为例:

服务器
├── 安装 Node.js
├── 安装 pnpm
├── 安装 PostgreSQL
├── 安装 Redis
├── 上传项目代码
├── 安装项目依赖
├── 配置环境变量
└── 启动应用

这种方式存在五个明显的问题。

第一,环境配置复杂。 每台服务器都需要单独安装运行环境。如果部署步骤没有被完整记录下来,很容易遗漏某些依赖或配置——比如项目用到了某个系统库但服务器没有安装,最终在应用启动时才发现问题。

第二,环境容易不一致。 开发环境用 Node.js 22,测试环境是 Node.js 20,生产环境是 Node.js 18。这种差异让问题难以复现——开发人员可能根本无法在本地重现生产环境中的错误,因为两边的运行环境并不相同。

第三,服务器容易被污染。 项目 A 需要 Node.js 18,项目 B 需要 Node.js 20,项目 C 需要 Node.js 22。如果这些项目全部运行在同一台服务器上,就需要额外处理版本切换和依赖冲突。随着项目越来越多,服务器环境会变得难以维护。

第四,迁移和恢复困难。 如果服务器出现故障需要重新部署,开发人员必须重新执行所有安装步骤。没有完整的自动化脚本,新服务器很难完全还原旧服务器的运行环境。

第五,部署过程难以复用。 手动执行的部署步骤很难保证每次结果一致——同一个人两次部署,甚至都可能产生不同的服务器状态。

Docker 的核心思路,就是把应用代码、运行时、系统依赖和启动方式一起封装成一个标准化的单元,让它在任何安装了 Docker 的机器上都能一致地运行。

二、Docker 的核心模型

在动手写命令之前,需要先厘清 Dockerfile、镜像、容器、仓库与 Docker Engine 之间的关系。这几个概念是理解 Docker 一切操作的基石。

下面这张图片是总览docker的核心概念和基本组成

1. Dockerfile:构建说明书

Dockerfile 是一个文本文件,用来描述如何构建 Docker 镜像。它类似一份构建说明书,Docker 会从上到下读取其中的指令并逐步执行。

一个最基础的 Node.js 项目 Dockerfile 长这样:

FROM node:22
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable
RUN pnpm install --frozen-lockfile
COPY . .
CMD ["pnpm", "start"]

这份 Dockerfile 表达的意思很直白:使用 Node.js 22 作为基础环境,将工作目录设置为 /app,复制依赖描述文件,启用 Corepack,安装项目依赖,复制项目代码,最后在容器启动时运行 pnpm start

需要注意:Dockerfile 不是 Shell 脚本。虽然部分指令可以执行 Shell 命令,但 Dockerfile 本身使用的是 Docker 定义的一套构建语法,有它自己的指令体系和执行逻辑。

2. 镜像(Image):应用模板

Docker 镜像是根据 Dockerfile 构建出来的、只读的应用模板。镜像中包含基础操作系统文件、应用运行时、系统依赖、项目代码、项目依赖以及默认启动命令。

一个 Fastify 项目的镜像可能包含:Linux 文件系统、Node.js、pnpm、Fastify 项目代码、node_modules 和启动命令。镜像本身不是一个正在运行的应用——它更像是一份已经准备好的”应用安装包”,随时可以基于它启动容器。

使用 docker image ls 可以查看本地已有的镜像。镜像名称通常搭配标签使用,比如 node:22postgres:17redis:8,冒号前面是镜像名称,冒号后面是标签。如果不写标签,Docker 会使用 latest——但 latest 只是一个普通标签,并不一定永远代表最新的稳定版本,生产环境中应该明确指定版本。

镜像的来源有两种:基于官方基础镜像自己制作,或者直接从 Docker Registry 下载现成的镜像。Docker Hub(hub.docker.com)是目前最大的公共镜像仓库,上面有官方提供的基础镜像和各软件公司制作的软件镜像。

3. 容器(Container):运行实例

Docker 容器是镜像运行后产生的实例。可以类比为:镜像是类,容器是对象;镜像是安装包,容器是运行中的进程。

同一个 fastify-api 镜像可以启动多个容器,每个容器都是独立运行的实例,拥有独立的进程、独立的文件系统视图、独立的网络配置、独立的环境变量和独立的容器名称。

使用 docker run mysql 会根据 mysql 镜像创建并启动一个容器。如果本地不存在该镜像,Docker 会先从镜像仓库下载。docker run 背后包含三个步骤:检查本地镜像 → 如果不存在则拉取 → 根据镜像创建并启动容器。

4. 镜像仓库(Registry):存储与分发

Docker Registry 是存储和分发镜像的服务。常见流程是:开发者构建镜像 → 上传到镜像仓库 → 服务器下载镜像 → 服务器启动容器。Docker Hub 是最常用的公共镜像仓库,除此之外企业也可以搭建私有镜像仓库用于存储内部项目的镜像。常见的操作有 docker pull(拉取镜像)、docker push(上传镜像)和 docker login(登录仓库)。

5. Docker Engine:核心引擎

Docker Engine 是实际负责构建和运行容器的核心程序。当我们执行 docker run nginx 时,真正负责创建容器的不是终端命令本身,而是 Docker Engine。可以简单理解为:Docker CLI 是用户输入命令的工具,Docker Engine 负责执行命令并管理镜像、容器、网络和数据卷。docker ps 这条命令,就是 CLI 将请求发送给 Engine,Engine 再返回当前运行中的容器信息。

6. 整体关系

把上述概念串起来,Docker 的核心工作流可以归纳为:

Dockerfile(构建说明)
↓ docker build
Image(应用模板)
↓ docker run
Container(运行实例)
↑ docker pull / push
Registry(镜像仓库)

记住这条链路:你写 Dockerfile 描述环境,Docker 把它构建成镜像,镜像可以在任何安装了 Docker 的机器上启动为容器,镜像本身则存储在 Registry 中供拉取和分发。

7. Dockerfile 在哪里执行?两种部署方式

理解了上面的核心概念后,一个很自然的问题是:Dockerfile 到底是在开发机上执行,还是在服务器上执行?

答案是:都可以。 区别在于工程实践的选择不同。这也是很多初学者学完 Docker 之后仍然感到困惑的地方——他们知道怎么写 Dockerfile,但不知道整个构建和部署的流程应该怎么组织。下面介绍两种最常见的模式。

方式一:服务器直接构建(Server-side Build)

这是最直接的部署方式:把代码推送到服务器,然后在服务器上执行 docker compose build 构建镜像,再启动容器。

开发机 服务器
│ │
│ git push │
└──────────────────────────→
git pull 代码
docker compose build
docker compose up

流程很简单:开发者在本地写完代码 → 推送到 Git 仓库 → SSH 到服务器 → 拉取最新代码 → 执行 docker compose build 构建镜像 → 执行 docker compose up 启动服务。

这种方式的优点是简单直接,不需要额外的中间服务,个人项目和中小团队非常常用。缺点是构建过程会消耗服务器的 CPU 和内存资源——如果你曾经在低配服务器上跑 pnpm install 导致服务器卡死,就会深刻理解这个痛点。此外,每台服务器都要自己构建,没法保证不同服务器上的构建结果完全一致,也没有统一的镜像版本管理。

方式二:CI/CD 构建 + Registry 分发(CI/CD Pipeline)

这是更标准化、更适合团队协作的部署方式:由 CI/CD 系统(如 GitHub Actions)负责构建镜像,推送到镜像仓库(Registry),服务器只需要拉取镜像并启动容器。

开发机 CI/CD Registry 服务器
│ │ │ │
│ git push │ │ │
└──────────────────────────→ │ │
│ │ │
docker build │ │
│ │ │
│ docker push │ │
└──────────────────────────→ │
│ │
│ docker pull │
└─────────────────────────→
docker compose up

流程变为:开发者推送代码 → GitHub Actions 自动触发构建 → 在 CI 环境中执行 docker build → 构建完成后推送镜像到 Docker Hub 或私有 Registry → 服务器从 Registry 拉取镜像 → 启动容器。

这种方式的优势很明显:构建过程不占用服务器资源,不会出现”服务器安装依赖直接卡死”的情况;镜像统一存储在 Registry 中,版本清晰可追溯;多台服务器拉取的是同一个镜像,环境完全一致;出问题可以快速回滚到上一个镜像版本。缺点是需要额外的 CI/CD 配置和 Registry 服务,初期搭建成本较高。

两种方式的选择没有绝对的对错。 个人项目、小团队内部工具,用方式一完全够用——一台服务器上 docker compose up --build 简单省事。但对于正式产品、多人协作、需要多台服务器部署的项目,方式二是更标准、更稳定的选择。而且方式二正好衔接后续的 GitHub Actions 自动构建、镜像仓库和 CI/CD 部署系列。

现在只需要有个概念,知道这两种模式的存在和适用场景即可。后续文章会逐步展开具体的工程实践。

三、Docker 如何实现隔离

Docker 容器并不是一台完整的虚拟机,容器本质上仍然是宿主机中的进程,只是这个进程被放在了一个相对隔离的运行环境中。Docker 主要利用 Linux 内核提供的三种机制来实现隔离和资源控制:Namespace、cgroups 和联合文件系统。

1. Namespace:资源隔离

Namespace 用于隔离不同容器看到的系统资源。不同容器可以拥有不同的进程视图、网络环境、主机名称、文件系统挂载和用户信息。容器中的进程会”感觉”自己运行在一个独立的环境中,但实际上它仍然是宿主机中的一个普通进程——只是被 Namespace 限制了视野。

2. cgroups:资源限制

cgroups 用于限制和统计容器可以使用的资源,包括 CPU 使用量、内存使用量、磁盘 I/O 和进程数量。这可以避免某个容器无限占用宿主机资源,导致其他容器或宿主机本身出问题。

3. 联合文件系统:镜像分层

Docker 镜像通常由多个只读层组成。比如 FROM node:22COPY package.jsonRUN npm installCOPY . . 这些指令每一行都可能产生不同的镜像层:Node.js 基础镜像层、工作目录层、依赖文件层、依赖安装层、项目代码层。Docker 可以复用没有变化的层,从而提高构建速度——这也是后续 Build Cache 和镜像优化的基础。

现在只需要先记住一个关键认知:Docker 镜像并不是一个不可拆分的大文件,而是由多个层组合而成的。

4. Docker 和虚拟机的区别

Docker 经常被称为”轻量级虚拟机”,但这种说法并不准确。虚拟机通过虚拟化技术运行一套完整的操作系统,每个虚拟机有自己的内核;而 Docker 容器共享宿主机内核,容器本身主要运行应用进程及其依赖。

两者的架构对比如下:

虚拟机: Docker 容器:
硬件 硬件
└── 宿主操作系统 └── 宿主操作系统
└── 虚拟化程序 └── Docker Engine
├── 虚拟机 OS A ├── 容器 A
│ └── 应用 A │ └── 应用 A
└── 虚拟机 OS B └── 容器 B
└── 应用 B └── 应用 B
对比项Docker 容器虚拟机
操作系统共享宿主机内核每个虚拟机包含完整系统
启动速度通常较快通常较慢
资源占用相对较少相对较多
隔离方式进程级隔离硬件或系统级隔离
镜像大小通常较小通常较大
常见用途应用打包和部署完整操作系统环境

Docker 并不是虚拟机的完全替代品,它们适用于不同场景。只需要建立一个基础认识:Docker 容器更接近”被隔离和限制的进程”,而不是一台完整的虚拟计算机。

四、Dockerfile 基础指令

理解了核心概念之后,就可以开始学写 Dockerfile 了。下面按使用频率和重要程度介绍最常用的指令。

1. FROM:指定基础镜像

FROM 是 Dockerfile 的第一条指令(注释除外),用来指定以什么镜像为基础进行构建:

FROM node:22

这意味着在 Node.js 22 的官方镜像之上,叠加我们自己的文件和配置。选择什么基础镜像直接决定了最终镜像的大小、安全性和兼容性,后续文章会深入讨论如何为不同场景选择合适的基础镜像。

2. WORKDIR:设置工作目录

WORKDIR 用于设置容器内的工作目录。如果目录不存在,Docker 会自动创建:

WORKDIR /app

后续的 COPYRUNCMD 等指令都会基于这个目录执行。好处是避免在每条指令里写绝对路径,让 Dockerfile 更清晰。

3. COPY:复制文件

COPY 用于将构建上下文中的文件复制到镜像中:

COPY package.json pnpm-lock.yaml ./
COPY . .

第一条只复制依赖描述文件,第二条复制项目其他文件。为什么要分两步?这涉及到 Build Cache 的优化——后面文章会详细展开。

4. RUN:构建时执行命令

RUN 在镜像构建阶段执行命令,通常用于安装依赖、编译代码等操作:

RUN corepack enable
RUN pnpm install --frozen-lockfile
RUN pnpm build

这些命令只在 docker build 时执行一次,结果会被固化到镜像中。

5. CMD:容器启动命令

CMD 定义容器启动时默认执行什么命令:

CMD ["pnpm", "start"]

注意区分 RUNCMDRUN 在构建镜像时执行,CMD 在启动容器时执行。如果你写成 RUN pnpm start,Docker 会在构建阶段就把应用启动起来——这通常会导致构建过程一直阻塞,或者因为服务无法正常退出而失败。

6. EXPOSE:声明端口

EXPOSE 用于声明应用会监听哪个端口:

EXPOSE 3000

但需要特别注意:EXPOSE 不会自动将容器端口映射到宿主机。 即使 Dockerfile 中写了 EXPOSE 3000,运行容器时仍然需要 docker run -p 3000:3000 来完成端口映射。EXPOSE 更像是一种文档性质的声明,真正控制端口映射的是 docker run-p 参数。

7. ENV 与 ARG:环境变量与构建参数

ENV 用于设置镜像或容器中的环境变量:

ENV NODE_ENV=production
ENV PORT=3000

应用可以通过 process.env.NODE_ENV 读取到这些值。不过,数据库密码、API 密钥等敏感信息不应该直接写进 Dockerfile——因为 Dockerfile 和镜像可能会被提交或分发。敏感配置应该在运行容器时通过 docker run -e--env-file 传入。

ARG 则用于定义构建阶段可以使用的参数:

ARG APP_VERSION=1.0.0

构建时可以通过 docker build --build-arg APP_VERSION=2.0.0 来覆盖。ARGENV 的核心区别在于:ARG 只在镜像构建阶段可用,ENV 在镜像和容器运行阶段都可用。

8. USER:指定运行用户

USER 用于指定容器中进程以哪个用户身份运行:

USER node

默认情况下,很多 Docker 容器以 root 用户运行,这存在安全隐患——如果应用存在漏洞,攻击者可能获得 root 权限。生产环境中应该创建并使用普通用户运行应用,这一点后续文章会详细展开。

9. 一个基础 Node.js Dockerfile 示例

把上面的指令组合起来,就是一份可以工作的 Node.js 项目 Dockerfile:

FROM node:22
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
EXPOSE 3000
CMD ["pnpm", "start"]

这份 Dockerfile 可以正常运行,但还不是最优的生产配置——它可能存在镜像体积较大、开发依赖被保留、使用 root 用户运行、构建环境和运行环境没有分离等问题。这些问题会在后续多阶段构建和镜像优化文章中逐步解决。

五、构建上下文与 .dockerignore

1. 什么是构建上下文

执行 docker build -t my-api:1.0 . 时,命令末尾的 . 表示构建上下文——Docker 会读取该目录中的文件,并将它们作为构建时可以访问的内容。Dockerfile 中的 COPY . . 复制的就是构建上下文中的文件。

需要注意两点:第一,Dockerfile 不能随意复制构建上下文外部的文件(比如 ../secret.txt),这是出于安全考虑;第二,构建上下文过大会降低构建速度,因此不应该在一个包含大量无关文件的目录中执行 docker build

2. .dockerignore 的作用

.dockerignore 用于排除不需要进入构建上下文的文件,作用类似 .gitignore。一个 Node.js 项目通常可以排除:

node_modules
dist
.git
.gitignore
.env
*.log
README.md
coverage

这些内容不应该被复制进镜像:本地的 node_modules 可能是在 Windows 或 macOS 中安装的,而容器内部通常运行 Linux,直接复制可能导致依赖不兼容;.env 可能包含敏感信息,进入构建上下文有泄露风险。但即使配置了 .dockerignore,也不能完全依赖它来保护敏感信息——更稳妥的做法仍然是不把密钥写入代码或 Dockerfile,在运行阶段注入环境变量。

六、第一个 Docker 实验

学习 Docker 最好的方式就是动手。打开终端,执行你人生的第一条 Docker 命令:

Terminal window
docker run --rm hello-world

这条命令虽然简单,但背后发生了六个步骤,每步都涉及前面介绍的核心概念。

第一步,Docker 查找本地镜像。 Docker 首先检查本地是否存在 hello-world:latest 镜像。

第二步,Docker 下载镜像。 如果本地没有该镜像,Docker 会从镜像仓库拉取——相当于自动执行了 docker pull hello-world

第三步,Docker 创建容器。 Docker 根据 hello-world 镜像创建一个新的容器。镜像是模板,容器是运行实例。

第四步,容器执行程序。 容器启动后运行镜像中定义的默认程序,输出一段欢迎信息。

第五步,容器退出。 程序执行完成后容器中的主进程结束,容器也随之停止。这说明一个关键事实:容器的生命周期通常与它的主进程绑定。

第六步,自动删除容器。 因为命令中使用了 --rm 参数,容器退出后会被自动删除。

理解容器为什么会退出

初学 Docker 时一个常见的困惑是:执行 docker run node:22 之后,容器很快就退出了。这是因为容器需要一个持续运行的主进程——如果主进程结束,容器就会退出。比如 docker run node:22 node -e "console.log('hello')",Node.js 输出 hello 后进程结束,容器随之退出。而 Nginx 会持续运行,所以 docker run nginx 不会立即退出。

可以通过 docker ps -a 查看已经退出的容器。因此,不要把容器理解为一台”需要一直开机的服务器”——更准确的理解是:容器是围绕一个或多个应用进程运行的隔离环境,主进程结束后,容器通常也会停止。

七、常用命令与易混淆概念

1. 基础命令速查

命令作用
docker pull nginx拉取镜像
docker image ls查看本地镜像
docker build -t my-api:1.0 .构建镜像
docker run nginx创建并启动容器
docker run -d nginx后台运行容器
docker run --name app nginx设置容器名称
docker run -p 8080:80 nginx映射端口
docker run --rm hello-world退出后自动删除
docker ps查看运行中的容器
docker ps -a查看全部容器
docker logs app查看容器日志
docker logs -f app持续查看日志
docker stop app停止容器
docker start app启动已有容器
docker restart app重启容器
docker rm app删除容器
docker rm -f app强制删除容器
docker exec -it app sh进入运行中的容器
docker image rm nginx删除镜像
docker inspect app查看详细配置

命令之间的关系可以用这张图片来表示

2. 五个容易混淆的知识点

镜像不等于容器。 镜像是模板,容器是运行实例。删除容器不会自动删除镜像,删除镜像也不等于停止容器。

EXPOSE 不等于端口映射。 EXPOSE 3000 只是声明,真正映射端口需要 docker run -p 3000:3000

RUN 不等于 docker run。 Dockerfile 中的 RUN npm install 在构建镜像时执行;终端中的 docker run my-api 是创建并启动容器。两者名字相似但作用完全不同。

停止容器不等于删除容器。 docker stop app 后容器仍然存在,可以 docker start app 重新启动;只有 docker rm app 才会真正移除容器。

容器删除后,内部修改可能丢失。 如果通过 docker exec -it app sh 进入容器手动修改文件,这些修改只存在于当前容器中。容器被删除并重新创建后,修改消失。因此正式配置应该通过 Dockerfile、数据卷或外部配置管理。

总结

本文从”我的电脑能跑”这个每个开发者都遇到过的场景出发,介绍了 Docker 的核心概念和基础语法。Docker 的核心思路是将应用代码、运行时、系统依赖和启动方式一起封装成镜像,再通过镜像创建和运行容器。

最重要的三个关系和它们的流向是:

Dockerfile → Image → Container
(构建说明) (应用模板) (运行实例)

Docker 容器并不是完整的虚拟机——容器本质上仍然是宿主机中的进程,只是通过 Namespace、cgroups 和联合文件系统等机制获得了相对独立的运行环境和资源限制。

掌握了这些基础之后,还有一个重要问题没有深入讨论:Dockerfile 中的每一条指令,为什么会影响镜像大小和构建速度?下一篇将介绍 Docker 镜像的分层结构,这是理解 Build Cache、多阶段构建和镜像优化的基础。

下一篇:Docker 工程化实践(二):理解镜像分层与容器文件系统

Docker 工程化实践(一):Docker 基本原理与基础语法
/posts/2026-7-15/1-docker-工程化实践一docker-基本原理与基础语法/
Author
Atopos
Published at
2026-07-15
License
CC BY-NC-SA 4.0

Some information may be outdated