技术干货 # docker已发布

Docker是如何工作的

Docker 已经成为现代应用开发和部署中非常常见的基础设施。很多开发者第一次接触 Docker 时,通常是从几个命令开始:docker build docker run docker ps 这些命令当然很重要,但如果只停留在“会用命令”的阶段,遇到稍微复杂一点的问题就很容易迷糊。

2026.04.1215 分钟阅读
Docker是如何工作的
Docker是如何工作的 · Cover

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 真正值得学习的地方。

#docker
ZZC

ZZC

写代码,也写一些生活。