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-essential、libssl 之类)……

核心矛盾:代码可以 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 内核 ~~~               │
│  内核由宿主机提供,容器共享宿主机内核         │
└──────────────────────────────────────────┘

Docker架构

App A

容器
仅用户空间文件

App B

容器
仅用户空间文件

Docker Engine

Host OS 内核
仅此一份

物理机

虚拟机架构

App A

Guest OS
完整内核 + 用户空间

App B

Guest OS
完整内核 + 用户空间

Hypervisor 虚拟化层

Host OS 内核

物理机

镜像大小                 内核             启动时间         隔离强度
──────────────────────────────────────────────────────────────────
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(只读层叠加)

镜像层

Dockerfile

FROM python:3.12-slim

RUN apt-get install curl

COPY requirements.txt .

RUN pip install -r requirements.txt

COPY . .

Layer 1: base
150MB

Layer 2: curl
5MB

Layer 3: req.txt
1KB

Layer 4: pip pkgs
50MB

Layer 5: code
10MB

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...

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

是

否

Dockerfile 第 N 条指令

该指令文本
和输入文件
和上层的 SHA256
都和上次一样?

✅ CACHED
跳过,直接用缓存层

🔄 重新执行此指令
生成新层

第 N+1 条指令
也必须重新执行

第 N+2 条指令
也必须重新执行

...
之后所有层全部重建

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

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 读写过程

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

OverlayFS 四层叠加

容器看到的文件系统(合并视图)

写时复制

操作规则

读文件: 从上往下逐层查找
找到即返回

写文件: Copy-on-Write
先把文件复制到可写层
再在可写层修改

删除文件: 在可写层创建
whiteout 遮蔽标记
底层文件不受影响

容器内进程看到的
完整文件系统

Container Layer
可写层
容器产生的所有变更

Image Layer 3
只读: COPY . .

Image Layer 2
只读: pip install 的库

Image Layer 1
只读: FROM python:3.12-slim

读 /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 多阶段构建:让最终镜像只含运行时需要的东西

阶段2: runtime(最终镜像)

阶段1: builder

只复制产物

结果对比

构建阶段镜像: ~1.2GB
不推送,仅用于构建

运行阶段镜像: ~300MB
推送到仓库

FROM python:3.12 AS builder
含编译工具 gcc/make

COPY requirements.txt .

RUN pip install ...
安装到 /root/.local

FROM python:3.12-slim AS runtime
不含编译工具

COPY --from=builder
/root/.local /root/.local

COPY . .

CMD ...

# ============ 构建阶段 ============
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 背后发生了什么?

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

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

注意: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 获取缓存数据。

redis 容器172.18.0.2:6379Docker DNS127.0.0.11:53web 容器172.18.0.3:8000bridge (br-xxx)宿主机iptables浏览器redis 容器172.18.0.2:6379Docker DNS127.0.0.11:53web 容器172.18.0.3:8000bridge (br-xxx)宿主机iptables浏览器FastAPI 收到请求执行 redis.get(key)FastAPI 生成 HTTP 响应TCP SYN → 宿主机IP:8080iptables DNAT宿主机:8080 → 172.18.0.3:8000数据包进入 bridge转发到 web 容器的 eth0查询 "redis" → ?172.18.0.2TCP SYN → 172.18.0.2:6379转发到 redis 容器的 eth0返回缓存数据HTTP 响应 → bridge宿主机 iptables 反向 SNATHTTP 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 ── uv(Astral 团队,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 国内网络加速:四层全配置

Docker 构建中的四层网络瓶颈

Layer 1: apt-get
访问 deb.debian.org

Layer 2: pip install
访问 pypi.org

Layer 3: HuggingFace
下载模型文件

Layer 4: Docker Hub
拉取基础镜像

换清华 Debian 源

换阿里云 PyPI 源

设置 HF_ENDPOINT
=https://hf-mirror.com

配置 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 知识体系。
如果你对其中某个环节仍有疑问,欢迎继续深入探讨。

更多推荐