Docker:从容器隔离到镜像构建,理解它是如何工作的
Docker 已经成为现代应用开发和部署中非常常见的基础设施。
很多开发者第一次接触 Docker 时,通常是从几个命令开始:
docker build
docker run
docker ps
docker exec
docker logs
这些命令当然很重要,但如果只停留在“会用命令”的阶段,遇到稍微复杂一点的问题就很容易迷糊。
例如:
- 为什么 Docker 容器启动以后,修改容器里的文件,重新创建容器就没了?
- 为什么 Dockerfile 调整一下
COPY的位置,构建速度会差很多? - 为什么同一个 Node.js 项目在 Windows 可以运行,在 Alpine Linux 里却无法安装?
- 为什么 Docker 镜像可以一层一层构建?
- 为什么删除一个容器,数据库数据却可以继续存在?
- 为什么容器之间可以直接通过服务名通信?
- 为什么生产环境通常需要多阶段构建?
理解这些问题之后,Docker 才真正开始变得有意思。
一、Docker 并不是一个“轻量级虚拟机”
理解 Docker 最重要的一步,是先纠正一个常见认知:
Docker 容器不是虚拟机。
传统虚拟机大致是:
物理服务器
│
├── Hypervisor
│
├── VM 1
│ ├── Guest OS
│ └── Application
│
└── VM 2
├── Guest OS
└── Application
每个虚拟机都有自己的操作系统。
而 Docker 更接近:
物理服务器
│
└── Linux Kernel
│
├── Container A
│ └── Application
│
├── Container B
│ └── Application
│
└── Container C
└── Application
容器之间共享宿主机的 Linux Kernel。
这也是为什么容器可以启动得非常快。
Docker 并没有为每一个容器启动一个完整的操作系统,而是利用 Linux 本身提供的隔离能力,把一个进程及其运行环境隔离起来。
其中比较重要的两个 Linux 机制就是:
Namespace
Cgroups
Namespace 负责“你能看到什么”,Cgroups 负责“你能使用多少”。
二、Namespace:为什么容器感觉像拥有一台独立机器?
例如我们运行:
docker run -it ubuntu bash
进入容器后执行:
ps aux
你可能只看到容器内部的几个进程。
但实际上宿主机上可能正在运行几百个进程。
为什么?
因为 Docker 利用了 Linux Namespace。
Namespace 可以对进程看到的系统资源进行隔离,例如:
PID Namespace
Network Namespace
Mount Namespace
UTS Namespace
IPC Namespace
User Namespace
其中 PID Namespace 很容易理解。
宿主机可能:
PID 1
PID 2
PID 100
PID 2000
PID 5000
而容器内部可以看到:
PID 1
PID 2
PID 3
容器里的 PID 1 并不是宿主机真正意义上的 PID 1,它只是这个 Namespace 中看到的第一个进程。
这也是 Docker 中一个非常重要的概念:
容器本质上仍然是 Linux 进程,只不过这个进程被放进了多个 Namespace 中。
所以:
docker ps
看到的是 Docker 管理的容器,而从 Linux 内核角度来看,它最终还是一组普通进程。
三、Cgroups:为什么可以限制容器资源?
隔离还不够。
假设服务器有:
CPU:16 核
内存:32GB
运行一个 Node.js 服务:
docker run blog-api
如果这个服务出现内存泄漏,理论上它可以不断申请宿主机内存。
因此 Docker 还需要控制容器能够使用多少资源。
这就是 Cgroups。
例如:
docker run \
--memory=512m \
--cpus=1 \
blog-api
表示给这个容器设置资源限制。
从逻辑上可以理解成:
宿主机
├── Container A
│ CPU:1 Core
│ Memory:512MB
│
├── Container B
│ CPU:2 Core
│ Memory:1GB
│
└── Container C
CPU:4 Core
Memory:4GB
所以 Docker 的隔离并不是简单的“把文件放到一个目录里”。
它依赖的是操作系统本身提供的进程、网络、文件系统和资源控制机制。
四、Docker Image 到底是什么?
很多人会把镜像理解成:
一个压缩包。
这个理解并不准确。
Docker Image 更准确地说,是由多个只读 Layer 组成的文件系统快照。
例如:
FROM node:24-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
CMD ["node", "server.js"]
Docker 在构建过程中,会形成类似:
Layer 4:COPY .
Layer 3:RUN npm install
Layer 2:COPY package.json
Layer 1:node:24-alpine
最终形成一个 Image。
因此:
Docker Image
│
├── Layer 1
├── Layer 2
├── Layer 3
└── Layer 4
这些 Layer 本身是不可变的。
这就是 Docker 能够高效复用构建缓存的基础。
五、为什么 Dockerfile 的顺序非常重要?
来看一个实际的 Node.js 项目。
不推荐这样写:
FROM node:24
WORKDIR /app
COPY . .
RUN pnpm install
RUN pnpm build
假设整个项目有:
src/
pages/
components/
package.json
pnpm-lock.yaml
你只是修改:
pages/index.vue
但是:
COPY . .
发生了变化。
Docker 会认为这一层已经不是原来的内容,于是后面的:
RUN pnpm install
缓存也可能无法继续复用。
最终每次改一个 Vue 文件,都重新安装依赖。
更合理的写法是:
FROM node:24
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
这样构建过程变成:
package.json + pnpm-lock.yaml
│
▼
pnpm install
│
▼
依赖 Layer
│
▼
COPY 源代码
│
▼
pnpm build
只要依赖没有变化:
package.json
pnpm-lock.yaml
Docker 就可以直接复用安装依赖的 Layer。
这也是为什么一个好的 Dockerfile 不只是“能构建成功”,还应该考虑:
哪些内容变化的频率高,哪些内容变化的频率低?
把稳定的内容放前面,把变化频繁的内容放后面,缓存效率自然更高。
六、为什么要有 .dockerignore?
如果 Dockerfile 有:
COPY . .
Docker 并不是只复制 Git 中提交的文件。
它会读取构建上下文。
例如:
blog-web/
├── node_modules/
├── .nuxt/
├── .output/
├── .git/
├── src/
├── package.json
└── Dockerfile
如果没有 .dockerignore,这些东西都可能进入 Docker Build Context。
对于 Node.js 项目来说,最应该避免的是:
node_modules
尤其是在 Windows 上开发时。
例如:
Windows node_modules
↓
Docker Linux
↓
native module
↓
ABI / libc / binary 不兼容
正确的思路应该是:
宿主机 node_modules
X
│
│ 不复制
▼
Docker Linux
│
▼
重新安装 Linux 依赖
所以通常:
node_modules
.nuxt
.output
.git
*.log
.env
是比较基础的配置。
七、为什么 Node.js 项目特别容易遇到 Docker 构建问题?
JavaScript 本身通常不需要编译。
但是 Node.js 生态中存在大量 Native Addon。
例如:
better-sqlite3
sharp
nodejieba
bcrypt
canvas
这些包的底层可能包含:
C/C++
Python
node-gyp
make
g++
所以:
pnpm install
并不一定只是下载 JavaScript 文件。
可能发生:
pnpm install
│
▼
下载 native module
│
▼
node-gyp
│
├── Python
├── make
└── g++
│
▼
C/C++ 编译
│
▼
.node 文件
这也是为什么:
Windows
可以安装成功,并不代表:
Linux Alpine
也一定可以。
因为操作系统、CPU 架构、libc、Node ABI 等都可能不同。
八、Alpine 为什么有时候让 Node.js 项目变复杂?
Node 官方提供:
node:24
node:24-alpine
node:24-bookworm
node:24-bookworm-slim
其中 Alpine 最大的特点是体积小。
但 Alpine 使用的是:
musl libc
而 Debian / Ubuntu 通常使用:
glibc
很多 Native Module 在 glibc 环境下更加成熟。
所以对于一个简单的:
Node.js API
Alpine 通常没有太大问题。
但是如果项目依赖:
better-sqlite3
sharp
nodejieba
canvas
那么就需要认真考虑基础镜像。
这时候:
FROM node:24-bookworm-slim
有时候反而比:
FROM node:24-alpine
更省事。
镜像小几百 MB 固然很好,但如果因为 native module 花几个小时排查构建问题,实际收益就未必高。
九、多阶段构建解决的到底是什么?
很多人第一次看到:
FROM node:24 AS builder
...
FROM node:24 AS runner
COPY --from=builder ...
会觉得只是 Dockerfile 写得比较复杂。
其实它解决的是一个很现实的问题:
编译环境和运行环境并不应该是同一个环境。
例如 Nuxt:
开发 / 构建阶段
├── Node
├── pnpm
├── TypeScript
├── Vite
├── Vue Compiler
├── Python
├── make
├── g++
├── node_modules
└── 源代码
但是 Nuxt 构建完成后,真正运行服务器可能只需要:
Node
.output
所以可以:
FROM node:24-bookworm-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable \
&& corepack prepare pnpm@12.6.0 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:24-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
最终镜像:
Builder
├── 编译工具
├── node_modules
├── 源代码
└── .output
│
│ 只复制
▼
Runner
└── .output
这就是多阶段构建真正的意义:
让“构建应用”和“运行应用”成为两个不同的环境。
十、容器为什么不应该保存业务数据?
假设 PostgreSQL 在:
postgres-container
里面运行。
数据库文件位于:
/var/lib/postgresql/data
如果直接把数据放在容器的可写层里:
Container
└── PostgreSQL Data
那么容器删除以后,数据也可能一起消失。
更合理的结构是:
PostgreSQL Container
│
▼
/var/lib/postgresql/data
│
▼
Docker Volume
│
▼
postgres-data
例如:
docker volume create postgres-data
然后:
docker run -d \
--name postgres \
-v postgres-data:/var/lib/postgresql/data \
postgres:16
此时:
Container
只是运行 PostgreSQL 的地方。
真正需要长期保存的数据:
Volume
负责。
这体现了容器化非常重要的一条原则:
计算与数据分离。
十一、Docker Network 为什么可以通过容器名通信?
假设:
blog-web
blog-api
postgres
redis
都加入:
blog-network
那么:
blog-api
│
├── postgres:5432
│
└── redis:6379
这里的:
postgres
redis
不是 IP 地址,而是 Docker Network 提供的 DNS 名称。
因此 NestJS 配置:
DATABASE_HOST=postgres
REDIS_HOST=redis
而不是:
DATABASE_HOST=127.0.0.1
这是 Docker 初学者非常容易踩的坑。
因为:
127.0.0.1
表示的是当前容器自己。
如果 NestJS 在:
blog-api
容器中,那么:
127.0.0.1:5432
找的是 blog-api 自己的 5432 端口,而不是 PostgreSQL 容器。
十二、容器化之后,配置应该从镜像中剥离
假设博客 API 地址是:
https://api.example.com
不要把:
ENV API_BASE=https://api.example.com
直接写死到镜像里。
因为同一个镜像可能部署到:
开发环境
测试环境
生产环境
应该尽量做到:
同一个 Image
│
├── Development
├── Testing
└── Production
通过环境变量提供不同配置。
例如:
docker run \
-e NUXT_PUBLIC_API_BASE=https://api.example.com \
blog-web
这样镜像本身不关心部署在哪里。
这也是现代容器部署非常重要的理念:
Build once, deploy many。
即:
构建一次镜像,在不同环境中使用不同运行时配置。
十三、Docker 镜像并不等于部署
Docker 解决的是:
应用如何打包
应用如何运行
环境如何标准化
但当应用规模扩大以后,还会出现:
容器挂了怎么办?
容器怎么自动重启?
多个实例怎么部署?
怎么做滚动更新?
怎么做服务发现?
怎么做负载均衡?
怎么做健康检查?
这时候单纯的:
docker run
就开始显得不够用了。
于是会出现:
Docker Compose
↓
Kubernetes
例如 Kubernetes 可以管理:
Deployment
Pod
Service
Ingress
ConfigMap
Secret
最终形成:
Git
↓
CI/CD
↓
Docker Build
↓
Harbor
↓
Kubernetes
↓
Pod
↓
Container
Docker 在这里承担的是:
应用交付和运行环境标准化
而 Kubernetes 更偏向:
容器编排和集群管理
两者解决的问题并不完全一样。
十四、实际项目中应该如何理解 Docker?
如果只是学习 Docker,记住几十条命令当然可以。
但真正进入项目以后,更重要的是建立下面这套思维:
源代码
│
▼
Dockerfile
│
▼
Image
│
├── Immutable Layers
│
└── Runtime Environment
│
▼
Container
│
┌──────┼──────┐
▼ ▼ ▼
Network Volume ENV
然后再往外扩展:
Container
│
▼
Compose
│
▼
Registry / Harbor
│
▼
CI/CD
│
▼
Kubernetes
这样理解 Docker,就不会停留在:
docker run xxx
这种“命令记忆”层面。
十五、写在最后
Docker 最有价值的地方,并不是让服务器少安装几个软件。
它真正解决的是:
把应用以及应用运行所需要的环境变成一个可以被构建、版本化、分发和复用的交付物。
以前部署一个 Node.js 应用,可能需要:
安装 Node.js
安装 pnpm
安装 Python
安装 gcc
安装系统依赖
拉代码
安装 npm 依赖
修改环境变量
启动服务
而容器化以后,可以变成:
Dockerfile
↓
docker build
↓
Image
↓
Harbor
↓
docker pull
↓
docker run
这并不意味着 Docker 消除了所有环境问题。
恰恰相反,当你开始使用 Docker 以后,会更加明显地遇到:
Node ABI
glibc / musl
CPU Architecture
Native Module
File System
Network
Resource Limit
Image Layer
Build Cache
Runtime Configuration
但这其实是好事。
因为这些问题本来就存在,只是以前隐藏在“某台服务器的环境里”。
Docker 做的事情,是把这些环境变成可以被描述、被版本控制、被复制和被自动化处理的东西。
而这也是 Docker 真正值得学习的地方。
