Docker 深度指南:从部署博客到理解容器

这不是一本"命令大全",而是一条从真实困境出发、逐层剥开的认知路径。
每个概念都在你问"为什么"的时候才出现,每个流程图都试图把一个抽象机制说清楚。


引子:我的博客,三个环境的三种命运

你写好了一个博客——Python + FastAPI,本地 uvicorn app.main:app 跑得飞起。
准备上线了:

开发机 (Python 3.12)       测试服 (Python 3.8)        生产服 (Python 3.10)
─────────────────────      ──────────────────        ──────────────────
uvicorn 启动               pip install 报错         启动成功但 500 
import 全正常               语法兼容问题               配置路径不同
.env 有值                   .env 没传                 .env 值不对

这还没算 Redis 版本、Nginx 配置、系统依赖(build-essentiallibssl 之类)……

核心矛盾:代码可以 git 同步,但运行环境无法 git 同步

传统部署                                        Docker 部署
──────────────────────────────────              ──────────────
代码 push → 登录服务器 → 装 Python              代码 push → docker build →
→ 创建虚拟环境 → pip install                     docker compose up -d
→ 配 Nginx → 配 systemd → 祈祷启动成功              环境即代码,到哪都一样

这就是 Docker 要回答的问题:能不能把代码和运行环境一起打包?


第一层:把环境一起打包 — 镜像

1.1 一张图说清 Docker 在做什么

┌──────────────────────────────────────────────────────────────┐
│                      Docker 工作流总览                         │
│                                                              │
│   Dockerfile ──build──▶ 镜像(Image) ──run──▶ 容器(Container)  │
│   (配方/定义)           (打包模板)            (运行实例)        │
│                                                              │
│   一次 build                  ↓ push/pull                     │
│   到处 run               Registry 仓库                        │
│                        (Docker Hub/阿里云)                     │
└──────────────────────────────────────────────────────────────┘

两张图对比传统部署和 Docker 部署,核心差异在于:Docker 交付的不只是代码,而是整个用户空间文件系统快照

1.2 往上拉一层看:Docker 系统由哪些角色构成

你敲的命令                  真正干活的进程               镜像存放处
docker build            dockerd(守护进程)           Docker Hub
docker run      ──▶    监听 /var/run/docker.sock   阿里云 ACR
docker compose          管理镜像/容器/网络/          Harbor(私有)
                                                     
  客户端                   服务端                     仓库

关键认知:你敲的每一个 docker xxx 命令,都是通过 Unix Socket 向 dockerd 发请求。容器不是"命令创建"的,是 dockerd 分配了网络命名空间、veth 对、文件系统层之后产生的。

1.3 镜像里到底有什么?没有什么?

这是理解 Docker "轻"在哪里的关键。

python:3.12-slim 镜像(约 150MB)

┌──────────────────────────────────────────┐
│  你的应用程序 + 依赖                       │
│  (Flask, numpy, torch...)                │
├──────────────────────────────────────────┤
│  Python 运行时 + pip                     │
│  (/usr/local/bin/python, .../lib/...)    │
├──────────────────────────────────────────┤
│  Debian 用户空间文件                       │
│  (/bin, /usr/lib, /etc, ... 不含内核!)    │
├──────────────────────────────────────────┤
│  ~~~ 这里没有 Linux 内核 ~~~               │
│  内核由宿主机提供,容器共享宿主机内核         │
└──────────────────────────────────────────┘
graph TB
    subgraph 虚拟机架构
        A1[App A] --> G1[Guest OS
完整内核 + 用户空间] A2[App B] --> G2[Guest OS
完整内核 + 用户空间] G1 --> H1[Hypervisor 虚拟化层] G2 --> H1 H1 --> HK1[Host OS 内核] HK1 --> HW1[物理机] end subgraph Docker架构 B1[App A] --> C1[容器
仅用户空间文件] B2[App B] --> C2[容器
仅用户空间文件] C1 --> D1[Docker Engine] C2 --> D1 D1 --> HK2[Host OS 内核
仅此一份] HK2 --> HW2[物理机] end style G1 fill:#ffcccc,stroke:#ff0000 style G2 fill:#ffcccc,stroke:#ff0000 style C1 fill:#ccffcc,stroke:#00aa00 style C2 fill:#ccffcc,stroke:#00aa00
镜像大小                 内核             启动时间         隔离强度
──────────────────────────────────────────────────────────────────
VM:   GB 级              每 VM 一个       分钟级           硬件级(强)
容器: MB 级              共享宿主机        秒级/毫秒        进程级(中)

所以 FROM python:3.12-slim 这句话不是在装一个"轻量虚拟机",而是在往镜像里塞一个精简版 Debian 的用户空间文件。

1.4 Docker 依赖的三个内核特性

这三个东西是理解"容器到底是什么"的钥匙:

Linux 内核
    │
    ├── Namespace(命名空间)
    │       "你能看到什么?"
    │       隔离进程视图(PID)、网络(NET)、文件系统(MNT)、用户(USER)……
    │       → 容器里的 ps 只能看到自己的进程
    │       → 容器里的 ifconfig 只能看到自己的网卡
    │
    ├── Cgroups(控制组)
    │       "你能用多少资源?"
    │       限制 CPU、内存、磁盘 I/O
    │       → --cpus=1.5  --memory=1g
    │
    └── UnionFS / OverlayFS(联合文件系统)
            "你看到的文件系统是怎么来的?"
            多个只读层 + 一个可写层 → 合并视图
            → 层共享 = 磁盘只存一份 = 拉取时 Already exists

一句话总结本质:容器 = 被 Namespace 限制视野 + 被 Cgroup 限制资源 + 被 UnionFS 提供文件系统视图的宿主机进程


第二层:定义"打包" — Dockerfile

2.1 场景:你写了一篇新博客,想把应用打包成镜像

你需要告诉 Docker:基于什么系统、装什么工具、安装什么依赖、把代码放哪、启动时执行什么。这就是 Dockerfile。

2.2 指令速览

指令 做的事 对镜像层的影响
FROM 指定基础镜像 第一层(底层)
WORKDIR 设置容器内工作目录 不产生新层
ENV 设置环境变量 产生新层
RUN 在构建时执行命令 产生新层(每 RUN 一层)
COPY 从宿主机复制文件到镜像 产生新层
EXPOSE 声明容器监听的端口(文档性质) 不产生新层
CMD 容器启动时的默认命令 不产生新层
VOLUME 声明挂载点 产生新层

2.3 一条指令,一层文件系统

Dockerfile                         构建过程
─────────────────────              ─────────────────────
FROM python:3.12-slim            Layer 1: Debian 用户空间 (150MB)
RUN apt-get install curl          Layer 2: curl 二进制 + lib 的增量 (5MB)
COPY requirements.txt .           Layer 3: 文件内容写入 (1KB)
RUN pip install -r ...            Layer 4: site-packages/ 下的文件 (50MB)
COPY . .                          Layer 5: 你的代码 (10MB)

最终镜像  150 + 5 + 0.001 + 50 + 10 = 215MB只读层叠加
flowchart LR
    subgraph Dockerfile
        A[FROM python:3.12-slim]
        B[RUN apt-get install curl]
        C[COPY requirements.txt .]
        D[RUN pip install -r requirements.txt]
        E[COPY . .]
    end

    subgraph 镜像层
        L1[Layer 1: base
150MB] L2[Layer 2: curl
5MB] L3[Layer 3: req.txt
1KB] L4[Layer 4: pip pkgs
50MB] L5[Layer 5: code
10MB] end A --> L1 B --> L2 C --> L3 D --> L4 E --> L5 L5 --> L4 --> L3 --> L2 --> L1

2.4 一个典型 Python 项目的 Dockerfile

FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    TZ=Asia/Shanghai

# 第一层 RUN:系统依赖(变动最少,放最前)
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' \
        /etc/apt/sources.list.d/debian.sources \
    && apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    curl \
    && rm -rf /var/lib/apt/lists/*

# 第二层:Python 依赖(先 COPY 依赖文件,不 COPY 业务代码)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
    -i https://mirrors.aliyun.com/pypi/simple/ \
    --trusted-host mirrors.aliyun.com

# 第三层:业务代码(最后才 COPY)
COPY . .

EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

这个顺序不是随便写的——它直接决定了你 docker build 能跑多快。


第三层:为什么改一行代码,构建有时秒过有时五分钟?

3.1 层缓存的工作方式:内容哈希

Docker 不"记住上次做了什么",它对每一层的内容算 SHA256 哈希

Layer: COPY requirements.txt .
    → 文件内容 SHA256 = abc123...

Layer: RUN pip install -r requirements.txt
    → 上一层的哈希(abc123) + 命令字符串 + RUN 输出的内容 → SHA256 = def456...

缓存命中的逻辑简单到只有一个判断

flowchart TD
    A[Dockerfile 第 N 条指令] --> B{该指令文本
和输入文件
和上层的 SHA256
都和上次一样?} B -->|是| C[✅ CACHED
跳过,直接用缓存层] B -->|否| D[🔄 重新执行此指令
生成新层] D --> E[第 N+1 条指令
也必须重新执行] E --> F[第 N+2 条指令
也必须重新执行] F --> G[...
之后所有层全部重建]

一层变了,后面的全得重跑。

3.2 所以层顺序就是一切

场景:你只改了 blog.py 里的一行文字

✅ 正确顺序(从不变到常变):
──────────────────────────────────────────────
[1/7] FROM python:3.12-slim          ✅ CACHED   0.0s
[2/7] WORKDIR /app                   ✅ CACHED   0.0s
[3/7] apt-get install...             ✅ CACHED   0.0s
[4/7] COPY requirements.txt .        ✅ CACHED   0.0s
[5/7] pip install -r ...             ✅ CACHED   0.0s
[6/7] COPY . .                       🔄 执行     2.1s
[7/7] CMD ...                        ✅ CACHED   0.0s
                                                ──────
总计:                                          ~2s


❌ 错误顺序(代码 COPY 在最前):
──────────────────────────────────────────────
[1/7] FROM python:3.12-slim          ✅ CACHED
[2/7] COPY . .                       🔄 执行     ← 代码变了,这层变了
[3/7] RUN pip install ...            🔄 重新装全部依赖(哪怕没加新包)
[4/7] ...                            后面全重跑
                                                ──────
总计:                                          ~5 min

核心原则:把变动频率低的放前面,变动频率高的放后面。

依赖安装    <    系统工具安装    <    基础镜像    <    业务代码 COPY
(偶尔变)        (几乎不变)          (极少变)         (每次改)
─────────────────────────────────────────────────────────────▶
        先 ◀────────── Dockerfile 中的位置 ──────────▶ 后

3.3 深入一层:OverlayFS 读写过程

前面说了"层",那容器运行时这些层是怎么组合起来的?

flowchart TB
    subgraph "容器看到的文件系统(合并视图)"
        APP[容器内进程看到的
完整文件系统] end subgraph "OverlayFS 四层叠加" direction TB CW[Container Layer
可写层
容器产生的所有变更] L3[Image Layer 3
只读: COPY . .] L2[Image Layer 2
只读: pip install 的库] L1[Image Layer 1
只读: FROM python:3.12-slim] end CW -.->|写时复制| L3 L1 --> APP L2 --> APP L3 --> APP CW --> APP subgraph "操作规则" R1[读文件: 从上往下逐层查找
找到即返回] R2[写文件: Copy-on-Write
先把文件复制到可写层
再在可写层修改] R3[删除文件: 在可写层创建
whiteout 遮蔽标记
底层文件不受影响] end
读 /usr/bin/python:
    可写层没有 → Layer 3 没有 → Layer 2 没有 → Layer 1 有 → 返回

写 /tmp/debug.log:
    文件在 Layer 1 里?→ 先 Copy-on-Write 复制到可写层 → 在可写层写
    新文件?→ 直接在可写层创建

删 /app/old_file.txt:
    在可写层创建一个 whiteout 文件(标记"这个文件不存在")
    Layer 3 里的 old_file.txt 还在,但合并视图里看不到它

两个容器共享同一个 python:3.12-slim 层 → 磁盘上这 150MB 只存一份 → 各自的可写层分开 → 互不影响。

这就是为什么 10 个容器可以共用同一个 Python 基础层,磁盘不会膨胀 10 倍。

3.4 基础镜像怎么选

python:3.12           ~1.0GB    完整版 Debian + 编译工具链,适合开发调试
python:3.12-slim      ~150MB    精简 Debian,去掉文档/非必要包 ← 推荐生产
python:3.12-alpine    ~50MB     基于 Alpine Linux(musl libc),极致小

Alpine 的坑(经验教训):

Alpine 用 musl libc 而不是 glibc。
对于纯 Python Web 服务,一般没问题。
但遇到以下场景要小心:
  - C 扩展编译(numpy、pandas、torch):可能找不到预编译 wheel
  - 神秘的 "symbol not found" / "ImportError":排查半天发现是 musl/glibc 不兼容
  - 某些 Python 包依赖的 .so 文件在 Alpine 下行为不同

经验法则:普通 Web 服务用 slim,C/科学计算依赖多用 slim,
         只有确认兼容时才用 alpine

3.5 .dockerignore:别往镜像里塞垃圾

# Python 编译缓存
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/

# 版本控制
.git/

# 敏感信息
.env
.env.*

# 本地数据和模型体积巨大不应打包进镜像
data/
models/
*.db
*.sqlite3

# IDE 和文档
.vscode/
.idea/
*.md
tests/

每一条被忽略的文件,都不会被发送到 dockerd 的构建上下文里,直接加快 COPY . . 的速度。

3.6 多阶段构建:让最终镜像只含运行时需要的东西

flowchart LR
    subgraph "阶段1: builder"
        B1[FROM python:3.12 AS builder
含编译工具 gcc/make] B2[COPY requirements.txt .] B3[RUN pip install ...
安装到 /root/.local] end subgraph "阶段2: runtime(最终镜像)" R1[FROM python:3.12-slim AS runtime
不含编译工具] R2[COPY --from=builder
/root/.local /root/.local] R3[COPY . .] R4[CMD ...] end B1 --> B2 --> B3 B3 -.->|只复制产物| R2 R1 --> R2 --> R3 --> R4 subgraph "结果对比" D1[构建阶段镜像: ~1.2GB
不推送,仅用于构建] D2[运行阶段镜像: ~300MB
推送到仓库] end
# ============ 构建阶段 ============
FROM python:3.12 AS builder

WORKDIR /build
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt \
    -i https://mirrors.aliyun.com/pypi/simple/ \
    --trusted-host mirrors.aliyun.com

# ============ 运行阶段 ============
FROM python:3.12-slim AS runtime

WORKDIR /app

# 只从 builder 复制已安装的 Python 包(不含 gcc 等编译工具)
COPY --from=builder /root/.local /root/.local

# 最后复制代码
COPY . .

EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

这个模板你现在就能用。构建阶段有 gcc,但最终镜像里没有。


第四层:博客 + Redis + Nginx — 多服务怎么办?

4.1 场景扩展

博客不只是 Python 应用,需要:

用户 → Nginx(反向代理 + 静态文件)
         │
         ▼
    博客应用(FastAPI,处理请求)
         │
         ▼
     Redis(缓存、会话)

三个服务,需要一起启动、联通网络、数据不丢。Docker Compose 就是干这个的。

4.2 docker-compose.yml 逐段拆解

version: "3.9"

# ── 可复用配置(YAML 锚点,减少重复)──
x-logging: &default-logging
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

x-restart: &default-restart
  restart: unless-stopped

services:
  # ── 主应用 ──
  app:
    build: .                          # 从当前目录 Dockerfile 构建
    container_name: blog_app
    <<: *default-restart
    ports:
      - "8000:8000"                   # 宿主机:容器
    volumes:
      - ./data:/app/data              # 数据持久化
    environment:
      - REDIS_URL=redis://redis:6379/0
    networks:
      - internal
    logging: *default-logging

  # ── Redis 缓存 ──
  redis:
    image: redis:7-alpine
    container_name: blog_redis
    <<: *default-restart
    command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis_data:/data
    networks:
      - internal
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  redis_data:

networks:
  internal:
    driver: bridge

4.3 docker compose up 背后发生了什么?

这是理解多容器协作的关键。

sequenceDiagram
    participant U as 你执行
docker compose up participant C as docker compose participant D as dockerd U->>C: docker compose up -d C->>C: 解析 docker-compose.yml C->>D: 创建网络 "internal"(bridge 类型) D-->>D: 分配子网 172.18.0.0/16 D-->>C: 网络就绪 C->>D: 拉取 redis:7-alpine 镜像 D-->>C: 镜像就绪 C->>D: 创建 redis 容器 D-->>D: 分配 IP 172.18.0.2 D-->>D: 加入 internal 网络 D-->>D: 启动内置 DNS(127.0.0.11:53) D-->>D: 注册容器名 redis → 172.18.0.2 C->>D: 构建 app 镜像(Dockerfile) D-->>C: 镜像就绪 C->>D: 创建 app 容器 D-->>D: 分配 IP 172.18.0.3 D-->>D: 加入 internal 网络 D-->>D: 注册容器名 app → 172.18.0.3 D-->>D: 添加 iptables DNAT 规则
宿主机:8000 → 172.18.0.3:8000 C-->>U: 所有服务启动完毕

注意depends_on 只控制启动顺序,不确保服务真正就绪。Redis 可能还没加载完数据,app 就启动了。生产环境需要在启动命令里加入等待逻辑(如 wait-for-it.sh)或依赖健康检查。

4.4 开发 vs 生产配置分离

项目结构:
project/
├── docker-compose.yml           # 基础配置(共用)
├── docker-compose.override.yml  # 开发覆盖(docker compose up 自动合并)
├── docker-compose.prod.yml      # 生产覆盖(手动指定 -f)
├── .env                         # 敏感变量(不提交 git)
├── .env.example                 # 变量模板(提交 git)
└── Dockerfile
# docker-compose.override.yml(开发环境,自动合并)
services:
  app:
    volumes:
      - .:/app                     # 挂载源码,修改实时生效
    environment:
      - DEBUG=true
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# 开发
docker compose up -d --build              # 自动合并 override.yml

# 生产
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build

4.5 何时需要 --build

# 需要 --build 的场景:
#   - 改了 Dockerfile
#   - 改了 requirements.txt
#   - 改了 uv.lock / pyproject.toml
#   - 第一次启动

docker compose up -d --build

# 不需要 --build 的场景:
#   - 只改了业务代码,且代码通过 volume 挂载进去的
#   - 只是重启服务

docker compose restart
docker compose up -d        # 不加 --build,用已有镜像

第五层:一个请求的完整旅程 — 网络核心

这是整个文档中最重要的一节。理解网络,你就理解了 Docker。

5.1 端口映射的本质:不是把端口"打开",是加了 NAT 规则

很多人以为 ports: - "8080:8000" 是"让容器的 8000 端口能被外面访问"。不完全是。

docker compose 里写的:
  ports:
    - "8080:8000"

dockerd 实际做的事:
  在宿主机 iptables 里加了一条 DNAT 规则:
    目的地址 = 宿主机 IP,目的端口 = 8080
    → 改写目的地址为 容器 IP (172.18.0.3)
    → 改写目的端口为 8000

用 iptables 查看:
  $ iptables -t nat -L DOCKER
  DNAT  tcp  --  anywhere  anywhere  tcp dpt:8080 to:172.18.0.3:8000
外部世界                    宿主机                        容器
────────                   ──────                       ─────
浏览器                     iptables                     app:8000
  │                         │                            │
  │──→ 宿主机:8080 ──→ DNAT 改写目标 ──→ 172.18.0.3:8000 ──│
  │                         │                            │
  │←── 返回 ←── 反向 SNAT ←── 172.18.0.3:8000 ←─────────│

容器内部完全不感知端口映射。容器里的应用只管监听 8000,至于外面用 8080 还是 9090 访问,它不知道也不关心。

5.2 容器间怎么互相找到对方?

方式一(默认 bridge):通过 IP 通信 → 但 IP 重启会变 → --link(已废弃)
方式二(自定义网络):通过容器名通信 → 内置 DNS 自动解析 → ✅ 推荐

两个 key 事实:

  1. 同一自定义网络内的容器,不需要端口映射就能互访
  2. 容器名自动注册为 DNS 的 A 记录
web 容器内部:
  $ ping redis
  PING redis (172.18.0.2) 56(84) bytes of data.
  64 bytes from 172.18.0.2: icmp_seq=1 ttl=64 time=0.123 ms

这是怎么工作的?
  web 容器的 /etc/resolv.conf 指向 127.0.0.11:53(Docker 内置 DNS)
  当代码调用 redis.get() 时:
    1. Python 解析主机名 "redis"
    2. glibc 查 /etc/resolv.conf → 向 127.0.0.11:53 发 DNS 请求
    3. Docker DNS 返回 172.18.0.2(redis 容器的 IP)
    4. 建立 TCP 连接到 172.18.0.2:6379

5.3 自定义网络 vs 默认 bridge 对比

特性               默认 bridge           自定义网络(推荐)
──────────────────────────────────────────────────────────
容器名解析          需要 --link(废弃)   内置 DNS 自动解析
跨容器通信          默认不互通             同一网络内默认互通
网络隔离            所有容器在同一桥       可建多个独立网络
重启后 IP           可能变                 可能变(但用容器名,不影响)

5.4 完整请求链路逐跳追踪

以你的博客为例:用户访问 http://宿主机:8080/article/1,博客应用查询 Redis 获取缓存数据。

sequenceDiagram
    participant B as 浏览器
    participant H as 宿主机
iptables participant B0 as bridge (br-xxx) participant W as web 容器
172.18.0.3:8000 participant DNS as Docker DNS
127.0.0.11:53 participant R as redis 容器
172.18.0.2:6379 B->>H: TCP SYN → 宿主机IP:8080 H->>H: iptables DNAT
宿主机:8080 → 172.18.0.3:8000 H->>B0: 数据包进入 bridge B0->>W: 转发到 web 容器的 eth0 Note over W: FastAPI 收到请求
执行 redis.get(key) W->>DNS: 查询 "redis" → ? DNS-->>W: 172.18.0.2 W->>B0: TCP SYN → 172.18.0.2:6379 B0->>R: 转发到 redis 容器的 eth0 R-->>W: 返回缓存数据 Note over W: FastAPI 生成 HTTP 响应 W->>B0: HTTP 响应 → bridge B0->>H: 宿主机 iptables 反向 SNAT H->>B: HTTP 200 → 浏览器
关键洞察:

容器间通信(web→redis):
  web 容器 eth0 → veth pair → 宿主机 bridge 桥 br-xxx → redis 容器 eth0
  走的是二层桥接,不经过宿主机 iptables 的 NAT 链 → 效率高、延迟低

外部访问容器(浏览器→web):
  浏览器 → 宿主机 iptables DNAT → bridge → web 容器
  经过 NAT 转换,有一点额外开销(但通常可忽略)

veth pair 是什么?你可以把它理解为"一根网线的两端":一端插在容器里(eth0),另一端插在宿主机的 bridge 桥上。数据从一头进,另一头出。


第六层:进入生产 — 依赖管理、安全、国内网络

6.1 Python 依赖管理的进化:从 pip 到 uv

2010 ── pip + requirements.txt
         问题flask>=2.0  今天装 2.3明天装 2.4行为不一致
         没有锁文件没有依赖冲突检测

2017 ── pipenv / poetry
         有了锁文件Pipfile.lock / poetry.lock
         但依赖解析慢 Python 实现),大型项目几分钟

2024 ── uvAstral 团队Rust 编写
          10-100  uv.lock 精确锁版本
         区分直接依赖和间接依赖
传统 pip:pip install -r requirements.txt → 约 60-120 秒
uv:      uv sync --frozen                    → 约 3-10 秒

uv 的项目结构:

# pyproject.toml
[project]
name = "my-blog"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.110.0",
    "uvicorn>=0.29.0",
    "chromadb>=0.4.0",
]

[tool.uv]
dev-dependencies = [
    "pytest>=8.0.0",
    "ruff>=0.4.0",
]
uv add fastapi uvicorn       # 添加依赖 → 更新 pyproject.toml + uv.lock
uv sync                      # 按 uv.lock 精确安装
uv run uvicorn app.main:app  # 在项目环境里运行

Docker + uv 的最佳实践:

FROM python:3.12-slim

# 从官方镜像直接拿 uv 二进制(不需要 pip install uv)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    UV_SYSTEM_PYTHON=1 \          # 安装到系统 Python(Docker 里不需要虚拟环境)
    UV_COMPILE_BYTECODE=1 \       # 预编译 .pyc
    UV_NO_CACHE=1                 # 构建时不缓存(减小镜像)

# 先复制依赖锁文件(利用缓存:锁文件不变 → 依赖层走缓存)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev \
    --index-url https://mirrors.aliyun.com/pypi/simple/

# 最后复制代码
COPY . .

CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

6.2 安全加固:几条关键的

# 1. 非 root 用户运行
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
RUN chown -R appuser:appuser /app
USER appuser                          # 此后所有指令以 appuser 执行

# 2. 使用 exec 格式的 CMD(PID 1 直接是应用,能收到 SIGTERM)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

# 3. 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1
# docker-compose.yml 层面的安全限制
services:
  app:
    read_only: true              # 容器文件系统只读
    tmpfs:
      - /tmp                     # 临时文件用内存盘
    security_opt:
      - no-new-privileges:true   # 禁止提权
    cap_drop:
      - ALL                      # 删除所有 Linux capabilities
    cap_add:
      - NET_BIND_SERVICE         # 只加这一个(允许绑定低端口)

敏感信息永远不要写进 Dockerfile:

# ❌ 这行会留在镜像层里,docker history 能看到
ENV SECRET_KEY=abc123

# ✅ 通过 .env 文件注入(.env 已加入 .gitignore)

6.3 国内网络加速:四层全配置

flowchart TD
    subgraph "Docker 构建中的四层网络瓶颈"
        L1[Layer 1: apt-get
访问 deb.debian.org] L2[Layer 2: pip install
访问 pypi.org] L3[Layer 3: HuggingFace
下载模型文件] L4[Layer 4: Docker Hub
拉取基础镜像] end L1 --> S1[换清华 Debian 源] L2 --> S2[换阿里云 PyPI 源] L3 --> S3[设置 HF_ENDPOINT
=https://hf-mirror.com] L4 --> S4[配置 registry-mirrors
腾讯云/中科大]
# 完整加速 Dockerfile 片段

# Layer 1: Debian apt 换源
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' \
    /etc/apt/sources.list.d/debian.sources

# Layer 2: pip 换源
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ \
    && pip config set global.trusted-host mirrors.aliyun.com

# Layer 3: HuggingFace 换源
ENV HF_ENDPOINT=https://hf-mirror.com

# Layer 4: PyTorch CPU 版(阿里云源,可选)
RUN pip install torch \
    -i https://mirrors.aliyun.com/pypi/simple/ \
    --trusted-host mirrors.aliyun.com

Docker Hub 镜像加速(Docker Desktop → Settings → Docker Engine 或 /etc/docker/daemon.json):

{
  "registry-mirrors": [
    "https://mirror.ccs.tencentyun.com",
    "https://docker.mirrors.ustc.edu.cn"
  ]
}

6.4 你对 Docker 和 Kubernetes 的定位

层次              Docker                          Kubernetes
─────────────────────────────────────────────────────────────────
职责              单机容器运行时                   跨多机容器编排
                  (创建、启动、停止)             (调度、伸缩、自愈)

核心抽象           容器(Container)               Pod(一组共享网络/存储的容器)

网络               单机 bridge/overlay              CNI 插件,跨节点通信

数据存储           卷(Volume)                    PV/PVC,支持多种后端

使用场景           开发、测试、简单生产              大规模生产集群

Pod 是什么? Pod 是 K8s 最小的部署单元,一个 Pod 包含一个或多个容器。这些容器共享同一个 Network Namespace(可以用 localhost 通信)和存储卷。Docker 不直接认识 Pod——K8s 通过 CRI(容器运行时接口)调用 Docker(或 containerd)来创建 Pod 里的容器。

一句话:Docker 管单个容器,K8s 管一群容器(通过 Pod 抽象)。


第七层:出问题了 — 排障手册

7.1 先看空间:磁盘去哪了

docker system df
# TYPE            TOTAL   ACTIVE   SIZE      RECLAIMABLE
# Images          15      3        8.2GB     6.1GB (74%)
# Containers      5       2        120MB     80MB (66%)
# Build Cache     -       -        3.4GB     3.4GB

# 空间浪费常见来源:
#   悬空镜像(dangling):重复 build 产生的无 tag 旧镜像
#   构建缓存:Build Cache 无限增长
#   停止的容器:没加 --rm 参数

docker system prune            # 清理未使用的容器/网络/悬空镜像
docker builder prune           # 只清理构建缓存
docker system prune -a         # 清理所有未使用的镜像

7.2 容器启动失败

docker compose ps                       # 看状态
docker compose logs app                 # 看日志

# 常见退出码含义:
# Exit 1   → 应用内部错误
# Exit 137 → 被 OOM Killer 杀了(内存不足)或 kill -9
# Exit 143 → 收到 SIGTERM,正常退出

# 用 shell 替换 CMD,进入容器调试
docker compose run --rm app bash

7.3 构建失败排查

# 看详细构建过程
docker compose build --progress=plain app 2>&1 | tee build.log

# 在失败步骤之前的那一层进入调试
docker history my-app:latest              # 找到最后成功的层 ID
docker run --rm -it <layer-id> bash       # 在那个层里调试

7.4 网络不通

# 查看网络和容器 IP
docker network ls
docker network inspect blog_internal

# 在容器里测连通性
docker compose exec app ping redis
docker compose exec app curl http://redis:6379

# 看端口映射是否生效
docker compose port app 8000

7.5 性能问题

docker stats                            # 实时 CPU/内存/IO
docker compose exec app top             # 容器内进程
docker inspect blog_app | grep -A5 OOMKilled  # 看是否被 OOM 杀死
dmesg | grep -i "killed process"        # 宿主机上 OOM 事件

7.6 数据卷问题

docker volume ls
docker volume inspect blog_app_data     # 看挂载路径
docker inspect blog_app | grep -A10 Mounts  # 容器视角的挂载信息

# 在宿主机直接查看卷内容
docker run --rm -v blog_app_data:/data alpine ls -la /data

附录:常见误解速查表

误解                                           真相
────────────────────────────────────────────────────────────────────────
"容器是轻量级虚拟机"                              容器是隔离的进程,共享宿主机内核
"镜像很大,运行时内存一定也很大"                    内存取决于运行的程序,与镜像大小无关
"FROM ubuntu 包含了 Linux 内核"                   只有 Ubuntu 用户空间文件,不含内核
"容器必须端口映射才能互访"                          同一自定义网络下,容器名直接访问
"depends_on 确保服务就绪"                         只控制启动顺序,不等待服务可用
"不挂卷,容器重启后数据还在"                        容器删除后可写层消失,数据丢失
"生产环境不需要限制容器资源"                        一个失控容器可能耗尽宿主机所有资源
"多个服务用同基础镜像浪费空间"                       层共享机制保证同一层只存一份

附录:命令速查

镜像

docker images                           # 列出本地镜像
docker pull python:3.12-slim            # 拉取镜像
docker build -t my-app:v1.0 .           # 构建镜像
docker build --no-cache -t my-app .     # 强制重建(不使用缓存)
docker rmi my-app:v1.0                  # 删除镜像
docker image prune                      # 清理悬空镜像
docker history my-app:v1.0              # 查看镜像层历史(含每层大小)

容器

docker ps                               # 运行中的容器
docker ps -a                            # 所有容器
docker run -d -p 8000:8000 --name blog my-app   # 后台启动
docker run --rm -it my-app bash         # 临时容器(退出即删)
docker stop blog                        # 优雅停止(SIGTERM)
docker kill blog                        # 强制停止(SIGKILL)
docker rm blog                          # 删除已停止的容器
docker logs -f --tail=100 blog          # 最后 100 行日志 + 实时跟踪
docker exec -it blog bash               # 进入容器终端
docker exec blog cat /etc/os-release    # 在容器内执行单条命令
docker stats                            # 实时资源使用

docker compose

docker compose up -d --build            # 构建并后台启动
docker compose down                     # 停止并删除容器(保留卷)
docker compose down -v                  # 停止并删除容器 + 卷(危险)
docker compose ps                       # 查看服务状态
docker compose logs -f app              # 跟踪 app 服务日志
docker compose exec app bash            # 进入 app 容器
docker compose restart app              # 重启 app 服务
docker compose config                   # 验证并查看最终合并后的配置
docker compose pull                     # 拉取最新镜像

清理

docker system df                        # 查看磁盘占用
docker system prune                     # 清理未使用资源(不含卷)
docker system prune -a                  # 清理所有未使用镜像
docker builder prune                    # 只清理构建缓存
docker volume prune                     # 清理未使用的卷

本文档基于实战运维经验与内核原理整理,目标是建立一套内在一致、可深可浅的 Docker 知识体系。
如果你对其中某个环节仍有疑问,欢迎继续深入探讨。