正经教主头像
【FDE系列】阶段2:Day 41(up详细版):Docker 入门 — 把环境装进盒子封面图

【FDE系列】阶段2:Day 41(up详细版):Docker 入门 — 把环境装进盒子

   📚前言

📒FDE系列内容总纲:

【大纲】FDE 前沿部署工程师学习系列教程-CSDN博客

🚄前置课程列表:

见文档结尾附录。


🚀阶段2·Day 41:Docker 入门 — 把环境装进盒子

FDE 学习系列教程 · 第二阶段 · 第 5 周 · Day 1 预计时长:5.5 小时(可分两次)| 难度:★★★☆☆ | 前置知识:WSL2 环境、Linux 基础命令、FastAPI、进程与端口概念

📌 一句话目标:能说清"容器到底隔离了什么",能用 17 条命令把容器的生命周期走完且不踩坑,能把端口映射写对、把数据挂到容器外面。


⏱️ 这 5.5 小时怎么用

时段

内容

这一段结束时你手上应该有

0:00-0:30

开场 + §一 环境差异五维

一张"我到底差在哪"的清单

0:30-1:10

§二 三个内核机制

能解释"容器不是小虚拟机"

1:10-2:00

§三 镜像分层 + 实跑缓存模拟

106 倍这个数字,以及为什么

2:00-2:40

§四 安装与验收

绿鲸鱼 + hello-world

2:40-3:40

§五 生命周期 17 步(实跑)

4 类错误不再犯

3:40-4:20

§六 端口映射(实跑)

3 条判据

4:20-4:50

§七 数据卷 + 权限坑

数据库挂卷方案

4:50-5:20

§八 安全基线 + §九 故障 12 例

交付前自查表

5:20-5:30

速查 + 问答 + 小结

—


🧑‍🤝‍🧑 开场:不是"跑不起来",是"跑起来不一样"

上周你在 WSL 里把 FastAPI 跑通了。客户给你一台服务器,你上去敲 python3 --version:

  Python 3.6.8

你的代码用了 match 语句(3.10+)、list[str] 泛型(3.9+)、zoneinfo(3.9+)。三个都用不了。

于是你开始"修环境"。修到第 4 个小时,问题变成了这样:

  你的 WSL Ubuntu 22.04              客户 CentOS 7.9
  ─────────────────────              ─────────────────────
  glibc 2.35                         glibc 2.17   ← 新 wheel 装不上
  Python 3.10.12                     Python 3.6.8 ← 语法不兼容
  openssl 3.0                        openssl 1.0.2 ← requests 报 SSL 错
  /usr/lib/x86_64-linux-gnu/          /usr/lib64/  ← 库路径不同
  你的账号有 sudo                    客户只给了一个普通账号

这不是"某个包装错了",是五个维度同时不一样。

  ★ 你之前所有的部署文档,本质都是"请把你这台机器,改成和我那台一样"。
    这句话在客户现场,等于让客户动生产环境 —— 通常被拒绝。

  Docker 换了个方向:不改客户的机器,把"我那台机器"打包带过去。

📖 一、环境差异的五个维度

1.1 五个维度

维度

具体差异

典型症状

内核

3.10 vs 5.15

新系统调用不可用、cgroup v1/v2 行为不同

C 库

glibc 2.17 vs 2.35

GLIBC_2.28 not found、manylinux wheel 装不上

解释器

3.6 vs 3.12

语法错、标准库 API 变更

系统库

openssl 1.0.2 vs 3.0

SSL 握手失败、证书校验报错

路径与账号

/usr/lib64 vs /usr/lib/x86_64-linux-gnu;有无 sudo

找不到 .so、装不了系统包

1.2 一层一层往下依赖

你的 FastAPI 服务,依赖链其实有 4 层:

  ┌─────────────────────────────────────────────┐
  │ 你的代码 app/main.py                        │   ← 你天天改
  ├─────────────────────────────────────────────┤
  │ 第三方包 fastapi / uvicorn / pydantic       │   ← pip 装
  ├─────────────────────────────────────────────┤
  │ 系统库 libssl / libffi / libpq / zlib       │   ← apt/yum 装
  ├─────────────────────────────────────────────┤
  │ 基础运行时 Python 3.12 + glibc + 内核       │   ← 这层最难搬
  └─────────────────────────────────────────────┘

前两层靠 requirements.txt 能带走,后两层带不走——它们长在操作系统里。

  ★ "在我电脑上能跑"的真正含义:
    我的第 3、4 层刚好和代码匹配,而你的不匹配。

  容器把 1、2、3、4 层一起打包,所以到哪都一样。

1.3 容器 vs 虚拟机:轻量的秘密

你可能听过虚拟机(VMware、VirtualBox)。容器和它都能做"环境隔离",但路子完全不同:

打个比方:

  • 虚拟机像"租了一整套独立的房子"——自己的厨房、水电、大门,什么都独立,但又重又占地方

  • 容器像"同一栋公寓楼里的独立房间"——楼的地基和水电(内核)共享,但每个房间各自独立、互不打扰,轻得多

对比项

虚拟机

容器

启动时间

几十秒~几分钟(要启动整个系统)

秒级(就是起一个进程)

资源占用

GB 级(每个带完整系统)

MB 级(只带应用和依赖)

隔离级别

硬件级强隔离

进程级隔离(共享内核)

一台机器能跑几个

几个~十几个

几十~上百个

典型用途

跑不同操作系统、强隔离

应用部署、微服务(FDE 主战场)

📌 一句话:容器就是一种"轻量级的虚拟机",它不虚拟硬件,只把应用和它需要的环境打包隔离起来。 快、小、可移植,是它统治现代部署的原因。

1.4 三种方案对比

方案

做法

代价

什么时候用

文档化部署

写清楚每一步装什么

客户环境一变就失效

单机、环境可控

虚拟机镜像

导出一个完整 VM

几十 GB、启动慢、迁移笨重

必须换操作系统

容器

打包应用 + 依赖 + 系统库,共享内核

需要内核支持(Linux 3.10+)

FDE 主战场

📌 注意"共享内核"这四个字:容器不换内核,所以它不能把 Linux 应用搬到 Windows 内核上跑。Windows 上跑 Linux 容器,是因为 Docker Desktop 偷偷起了一个 Linux 虚拟机(WSL2 或 Hyper-V),容器还是跑在 Linux 内核里。这一点后面 §2.4 会讲清。


📖 二、容器到底隔离了什么:三个内核机制

"容器是轻量级虚拟机"是个方便的比喻,但它会误导你。准确说:容器是一组被内核限制和隔离起来的普通进程。

支撑这句话的是三个机制。

2.1 namespace:让它"看不见别人"

namespace

隔离什么

效果

PID

进程号

容器内进程是 1 号,看不到宿主的进程

NET

网络栈

自己的网卡、IP、端口表

MNT

挂载点

自己的根目录 /

UTS

主机名

自己的 hostname

IPC

进程间通信

自己的信号量/共享内存

USER

用户与 UID

容器内可以是 root,宿主上不是

  宿主机看到的                        容器内看到的
  ─────────────                       ─────────────
  PID 4812 python main.py      ──►    PID 1 python main.py
  hostname: prod-node-01       ──►    hostname: a1b2c3d4e5f6
  ip: 10.0.3.15                ──►    ip: 172.17.0.2
  /                            ──►    /(镜像里那套根目录)

  ★ 同一个进程,两套视图。namespace 就是"视图切换器"。

一个能记住的判据:

  容器里的 PID 1 不是"系统的 init",是你的应用。
  所以你的应用必须自己处理信号(SIGTERM),否则 docker stop 会等 10 秒然后强杀。

2.2 cgroups:让它"用不了太多"

namespace 管"看得见什么",cgroups 管"能用多少":

限制项

Docker 参数

不加会怎样

CPU

--cpus=1.5

一个容器跑满所有核,别的服务卡死

内存

-m 512m

内存泄漏的容器把宿主 OOM

磁盘 IO

--device-read-bps

日志狂写拖慢数据库

进程数

--pids-limit=200

fork 炸弹拖垮整机

  ★ FDE 现场铁律:给客户的容器,必须设 -m 和 --cpus。
    不是"以防万一",是"这台机器上还有别人的系统"。

2.3 unionfs:让它"看起来有一个完整系统"

镜像不是一个文件,是一堆只读层叠起来,最上面再盖一个可写层:

  ┌──────────────────────────────┐  ← 容器可写层(容器删了就没了)
  ├──────────────────────────────┤
  │ 层 5  COPY . .               │  ← 你的代码
  ├──────────────────────────────┤
  │ 层 4  RUN pip install ...    │  ← 第三方包
  ├──────────────────────────────┤
  │ 层 3  COPY requirements.txt  │
  ├──────────────────────────────┤
  │ 层 2  WORKDIR /app           │
  ├──────────────────────────────┤
  │ 层 1  FROM python:3.12-slim  │  ← 基础镜像(多容器共享,只存一份)
  └──────────────────────────────┘

  读文件:从上往下找,找到就返回(overlayfs)
  写文件:写到最上面的可写层(copy-on-write)

这一条直接推出两个重要结论,§三会用实跑数据证明:

  1. 多个容器共享基础层 → 10 个 Python 应用不会占 10 份 Python。

  2. 改一层,上面的层全部失效 → Dockerfile 的指令顺序决定构建时间。

2.4 所以它到底是不是"轻量虚拟机"

对比项

虚拟机

容器

隔离单位

硬件虚拟化 + 完整 OS

进程 + 内核机制

自带内核

有

没有(共享宿主内核)

启动

秒~分钟(要引导系统)

毫秒~秒(起一个进程)

体积

GB 级

MB 级

密度

一台机器十几个

几十到上百个

隔离强度

强(硬件边界)

中(内核共享,逃逸风险真实存在)

能否跑异构 OS

能(Linux 上跑 Windows)

不能(内核必须同族)

快照/迁移

整机快照

镜像 + 卷,天然可搬

  ★ 一句话:容器不是"更小的虚拟机",是"被关进笼子的进程"。

  这个区别在安全上很要紧:
    虚拟机逃逸 = 攻破 Hypervisor(很难)
    容器逃逸   = 攻破内核(相对容易,尤其是给了 --privileged)

🖥️ 三、镜像:分层、标签、摘要

3.1 三个概念先分清

概念

是什么

类比

层(layer)

一组文件的只读快照

一页透明胶片

镜像(image)

若干层按顺序叠起来 + 元信息

一叠胶片

标签(tag)

指向某个镜像的可变指针

便利贴

摘要(digest)

镜像内容的 SHA256

身份证号

示意图如下:

  nginx:alpine            ← tag(便利贴,会被人撕下来重贴)
  nginx@sha256:8a1b...    ← digest(身份证号,永不变)

  ★ 生产环境钉 digest,不要只写 latest。
    latest 今天指向 v1.27,明天可能指向 v1.29 —— 你的"没改任何东西"会突然出故障。

镜像 vs 容器,这是 Docker 最核心的一对概念,用你学过的 Python 一类比就通:

  镜像(Image)  ≈  类(class)        —— 只读的模板/图纸
  容器(Container)≈ 对象(instance)  —— 镜像跑起来后的活实例

  class Dog:          ──────────►   docker pull python:3.12
      def bark()                          (下载镜像=拿到图纸)

  dog1 = Dog()       ──────────►   docker run python:3.12
  dog2 = Dog()                          (从镜像创建容器=按图纸造实例)
                                       同一个镜像可以同时跑很多容器

注:docker commit 是 Docker 的一个命令,用于将一个容器的当前状态保存为一个新的镜像。

记住这个对应关系,后面所有命令都围绕这两样东西:

操作

镜像(Image)

容器(Container)

查看列表

docker images

docker ps

下载/创建

docker pull

docker run / docker create

删除

docker rmi

docker rm

生命周期

不动它就一直在

run → start → stop → rm

3.2 实跑:层缓存值多少钱

这段本该明天讲 Dockerfile,但不理解分层,就理解不了镜像,所以放在今天先跑一遍数字。

下面示例用 hashlib 模拟 Docker 的 layer cache key 计算:key = hash(父层key + 指令 + 输入文件内容指纹)。父层变了,后续层全部级联失效。然后用 BAD 和 GOOD 两组 Dockerfile 指令序列,对比“改一行 main.py”分别要重建几层、花多少秒。

跑出来的核心数字是:坏写法 42.4s vs 好写法 0.4s,相差约 106 倍。原因是坏写法里 COPY . . 在 pip install 之前,改任何源文件都会让 COPY 层的 key 变化,进而让 42 秒的 pip install 层跟着失效;好写法先单独 COPY requirements.txt,只要依赖文件没动,pip 层就一直命中缓存。

具体示例文件名 layer_cache_sim.py:

# layer_cache_sim.py —— 镜像分层与缓存失效:两种 Dockerfile 写法,改一行代码各要多久
from __future__ import annotations

import hashlib

# 指令, 输入文件, 耗时(秒)
BAD = [
    ("FROM python:3.12-slim", None, 0.0),
    ("WORKDIR /app", None, 0.1),
    ("COPY . .", ["app/main.py", "app/requirements.txt"], 0.3),
    ("RUN pip install -r requirements.txt", None, 42.0),
    ('CMD ["uvicorn","main:app"]', None, 0.1),
]
GOOD = [
    ("FROM python:3.12-slim", None, 0.0),
    ("WORKDIR /app", None, 0.1),
    ("COPY requirements.txt .", ["app/requirements.txt"], 0.2),
    ("RUN pip install -r requirements.txt", None, 42.0),
    ("COPY . .", ["app/main.py", "app/requirements.txt"], 0.3),
    ('CMD ["uvicorn","main:app"]', None, 0.1),
]

FILES = {
    "app/main.py": "print('v1')\n",
    "app/requirements.txt": "fastapi==0.115.0\nuvicorn==0.30.0\n",
}


def digest(text: str) -> str:
    return hashlib.sha256(text.encode()).hexdigest()[:8]


def build(steps, files, cache: list[str]) -> tuple[list[str], float, int]:
    """返回 (新缓存签名, 耗时, 重建层数)。父层变了,后续层全部失效。"""
    new_cache, cost, rebuilt = [], 0.0, 0
    parent = "base"
    for instr, inputs, sec in steps:
        src = "".join(sorted(f"{p}={digest(files[p])}" for p in (inputs or [])))
        key = digest(f"{parent}|{instr}|{src}")
        if key not in cache:
            cost += sec
            rebuilt += 1
        new_cache.append(key)
        parent = key
    return new_cache, cost, rebuilt


def main() -> None:
    print("镜像 = 一串只读层叠加(unionfs / overlayfs)")
    print("缓存 key = hash(父层 key + 指令 + 输入文件内容指纹)")
    print("父层变了 → 本层失效 → 后面全部失效(级联)")

    code2 = dict(FILES)
    code2["app/main.py"] = "print('v2')\n"          # 只改一行代码

    # ---------- 坏写法 ----------
    print("\n① 坏写法:COPY . . 在 pip install 之前")
    for i, (instr, _, _) in enumerate(BAD, 1):
        print(f"    L{i} {instr}")
    c1, cost1, n1 = build(BAD, FILES, [])
    print(f"  首次构建:重建 {n1} 层,{cost1:.1f}s")
    cache1, cost1b, n1b = build(BAD, code2, c1)
    print(f"  改一行 main.py 后:重建 {n1b} 层,{cost1b:.1f}s  ← 依赖跟着重装")

    # ---------- 好写法 ----------
    print("\n② 好写法:先 COPY requirements.txt → pip → 再 COPY . .")
    for i, (instr, _, _) in enumerate(GOOD, 1):
        print(f"    L{i} {instr}")
    c2, cost2, n2 = build(GOOD, FILES, [])
    print(f"  首次构建:重建 {n2} 层,{cost2:.1f}s")
    g_cache = c2                      # 首次构建产生的缓存(对应原始文件内容)
    _, cost2b, n2b = build(GOOD, code2, g_cache)
    print(f"  改一行 main.py 后:重建 {n2b} 层,{cost2b:.1f}s  ← 只重建拷贝层")

    print(f"\n同一行代码改动:{cost1b:.1f}s vs {cost2b:.1f}s,相差 {cost1b / cost2b:.0f} 倍")

    # ---------- 命中表 ----------
    print("\n③ 四种改动在好写法下的缓存命中:")
    cases = [
        ("改 main.py", {"app/main.py": "print('v3')\n"}),
        ("改 requirements.txt", {"app/requirements.txt": "fastapi==0.115.1\n"}),
        ("两个都改", {"app/main.py": "v4", "app/requirements.txt": "fastapi==0.116.0\n"}),
        ("只碰 mtime,内容不变", {}),
    ]
    for name, patch in cases:
        f2 = dict(FILES)
        f2.update(patch)
        _, c, k = build(GOOD, f2, g_cache)
        print(f"  {name:<22}重建 {k} 层 / {c:>5.1f}s")

    print("\n④ 结论")
    print("  · 改代码:好写法 0.4s,坏写法 42.4s —— 每天几十次构建,差距就是几十分钟")
    print("  · 改依赖:两种都要重装(依赖真的变了,缓存救不了)")
    print("  · 内容不变的 touch:不失效 —— key 看内容,不看修改时间")
    print("  ★ Dockerfile 的指令顺序不是风格问题,是构建成本问题。")


if __name__ == "__main__":
    main()

【实跑】输出:

镜像 = 一串只读层叠加(unionfs / overlayfs)
缓存 key = hash(父层 key + 指令 + 输入文件内容指纹)
父层变了 → 本层失效 → 后面全部失效(级联)

① 坏写法:COPY . . 在 pip install 之前
    L1 FROM python:3.12-slim
    L2 WORKDIR /app
    L3 COPY . .
    L4 RUN pip install -r requirements.txt
    L5 CMD ["uvicorn","main:app"]
  首次构建:重建 5 层,42.5s
  改一行 main.py 后:重建 3 层,42.4s  ← 依赖跟着重装

② 好写法:先 COPY requirements.txt → pip → 再 COPY . .
    L1 FROM python:3.12-slim
    L2 WORKDIR /app
    L3 COPY requirements.txt .
    L4 RUN pip install -r requirements.txt
    L5 COPY . .
    L6 CMD ["uvicorn","main:app"]
  首次构建:重建 6 层,42.7s
  改一行 main.py 后:重建 2 层,0.4s  ← 只重建拷贝层

同一行代码改动:42.4s vs 0.4s,相差 106 倍

③ 四种改动在好写法下的缓存命中:
  改 main.py             重建 2 层 /   0.4s
  改 requirements.txt    重建 4 层 /  42.6s
  两个都改                  重建 4 层 /  42.6s
  只碰 mtime,内容不变         重建 0 层 /   0.0s

④ 结论
  · 改代码:好写法 0.4s,坏写法 42.4s —— 每天几十次构建,差距就是几十分钟
  · 改依赖:两种都要重装(依赖真的变了,缓存救不了)
  · 内容不变的 touch:不失效 —— key 看内容,不看修改时间
  ★ Dockerfile 的指令顺序不是风格问题,是构建成本问题。

3.3 从这四个数字能读出什么

读到的

怎么用

106 倍

每天改 30 次代码,坏写法浪费约 21 分钟/天

改依赖两种都要重装

所以依赖变更要单独提交、单独构建,别和代码改动混在一起

mtime 不变不失效

缓存 key 看内容,不看时间;cp -p/touch 不会误伤缓存

坏写法首次构建 42.5s vs 好写法 42.7s

首次构建几乎一样——省的是第二次以后

🧠 这张表解释了为什么"能跑就行"的 Dockerfile 会在两周后变成折磨: 首次构建看不出差别,第 200 次构建差 100 倍。

3.4 一条推论:为什么基础镜像那么大还那么受欢迎

  python:3.12-slim 约 130MB,你的代码只有 20KB。

  ★ 但这 130MB 只存一份,10 个容器共享。
    真正按需增长的是最上面那几层。

🖥️ 四、安装与验收(Windows + WSL2)

4.1 装

  1. 下载:Docker Desktop: The #1 Containerization Tool for Developers | Docker

  2. 安装时保持勾选 "Use WSL 2 instead of Hyper-V"

  3. 装完重启;启动 Docker Desktop,等左下角小鲸鱼变绿并稳定

  4. Settings → Resources → WSL Integration → 打开你的 Ubuntu 发行版开关(让你在 WSL 的 Ubuntu 终端里面,直接能用 docker / docker compose 命令,共用 Docker Desktop 的同一个容器引擎daemon)

4.2 验:四道关

# 第 1 关:CLI 在不在
docker --version
docker compose version

docker compose 是 Docker 官方提供的多容器应用编排工具(Compose V2,作为 Docker CLI 插件使用,命令用空格形式 docker compose)。它通过一个 YAML 文件(通常叫 compose.yaml 或 docker-compose.yml)声明式地定义和运行多个容器,一条命令即可启动整套服务。

【实跑】本机输出(CLI 装了,引擎没起):

Docker version 28.1.1, build 4eba377

Docker Compose version v2.35.1-desktop.1
# 第 2 关:引擎在不在(这一关最容易被跳过)
docker info --format '{{.ServerVersion}} | {{.OSType}}'

【实跑】引擎没起时的真实报错:

error during connect: Get "http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.49/info":
open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.
  ★ 这句话的意思是:"CLI 找得到,但那个叫 dockerDesktopLinuxEngine 的管道不存在"。
    换成白话:Docker Desktop 没启动,或者启动失败。

    `docker --version` 能出版本号,不代表能跑容器 —— 它只是读了本地二进制。
    所以验收必须到第 2 关。
# 第 3 关:整条链路
docker run --rm hello-world

# 第 4 关:镜像能落地
docker pull nginx:alpine
docker images

第 3 关成功时你会看到(示意输出):

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
...
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

4.3 四类启动失败怎么排

现象

真因

处理

dockerDesktopLinuxEngine ... not found

Desktop 没起 / 服务没权限

启动 Desktop;服务需管理员

WSL 2 installation is incomplete

WSL 内核旧

管理员 PowerShell:wsl --update 后重启

Hardware assisted virtualization ... not enabled

BIOS 虚拟化关着

BIOS 开 Intel VT-x / AMD SVM

小鲸鱼一直转圈

WSL Integration 没开

Settings → Resources → WSL Integration

4.4 国内拉镜像慢

Settings → Docker Engine,加镜像加速:

{
  "registry-mirrors": ["https://<你的加速地址>"]
}
  ★ 客户内网通常完全不能出网。这时候不是"加速"的问题,
    是要在内网搭一个 registry(§八会讲离线交付怎么做)。

🖥️ 五、生命周期:17 步走完,4 步会被拒绝

5.1 生命周期管理

5.1.2 状态变化

容器从创建到删除,会经历以下几个状态:

(无)→ running
docker run 一步完成创建并启动。严格说中间还有个 created 状态(docker create 之后、start 之前),但 docker run 把它合并了,日常可以忽略。

running → exited
docker stop 让容器优雅退出。默认先发 SIGTERM,等 10 秒(可配置)再发 SIGKILL。容器主进程结束后进入 exited。

exited → running
docker start 重新启动。注意:重新执行的是镜像的 ENTRYPOINT/CMD,是全新进程,不是"恢复现场"。容器可写层里之前的文件改动还在,但进程状态不保留。这跟 pause/unpause 的冻结解冻是两回事。

running → exited(强制)
docker rm -f 会先 SIGKILL 强杀再删除,一步到位但跳过优雅停止。生产环境建议 stop 和 rm 分两步做。

exited → removed
docker rm 直接删除已停止的容器,不需要 -f。很多人误以为 exited 也要加 -f,其实不用。

running → removed
必须先 stop 或 rm -f,不能直接 rm。

5.1.2 三条命令的适用边界

docker exec:只对 running 有效
exited 状态报错。paused 状态也不可用(进程已冻结)。restarting 状态有时会短暂失败。要进容器排查,先确认它在 running。

docker logs:running 和 exited 都能看
日志是容器标准输出的持久记录,容器停了照样能翻。这是排查"容器为什么退出"的第一入口。paused 状态也能看。

docker rmi:删除的是镜像(image),不是容器,要求没有任何容器引用该镜像
准确说是没有任何容器的可写层依赖该镜像的某一层。具体边界:

  • 有 running 容器基于它 → rmi 失败,加 -f 也删不掉

  • 有 exited 容器基于它 → rmi 失败,但 rmi -f 可以强删(容器会变成悬空引用)

  • 容器已 rm → 镜像可正常删除

  • 多个 tag 指向同一 image ID → 删一个 tag 不影响,删最后一个才真正释放

  • 被其他镜像作为基础层引用(子镜像)→ 不能直接删,要 -f 或先删子镜像

                 docker run
   (无) ─────────────────────► running ──── docker stop ────► exited
                                   ▲  │                          │
                          start    │  │ docker rm(要 -f)        │ start
                                   │  ▼                          │
                                 exited ◄────────────────────────┘
                                   │
                            docker rm
                                   ▼
                                removed(没了)

  docker exec   :只对 running 有效
  docker logs   :running / exited 都能看
  docker rmi    :要求没有任何容器(含 exited)引用它

5.2 实跑:container_lifecycle.py

# container_lifecycle.py —— 容器状态机:跑 17 步操作,看哪些会被拒绝
from __future__ import annotations


class Engine:
    def __init__(self) -> None:
        self.images: set[str] = set()
        self.containers: dict[str, dict] = {}

    def ps(self, all_: bool = False) -> list[str]:
        return [f"{n}({c['state']})" for n, c in self.containers.items()
                if c["state"] != "removed" and (all_ or c["state"] == "running")]

    def _port_busy(self, host_port: int) -> str | None:
        for n, c in self.containers.items():
            if c["state"] == "running" and host_port in c["ports"]:
                return n
        return None

    def pull(self, image: str) -> str:
        self.images.add(image.split(":")[0] + ":latest" if ":" not in image else image)
        return f"pull {image} → 本地镜像 {len(self.images)} 个"

    def run(self, name: str, image: str, host_port: int, cport: int,
            auto_rm: bool = False) -> str:
        if name in self.containers and self.containers[name]["state"] != "removed":
            return f"ERR 名字冲突:{name} 已存在(--name 必须唯一)"
        if image not in self.images:
            return f"ERR 镜像不存在:{image}(先 docker pull)"
        busy = self._port_busy(host_port)
        if busy:
            return (f"ERR 端口占用:宿主机 {host_port} 已被 {busy} 占用"
                    f"(Bind for 0.0.0.0:{host_port} failed: port is already allocated)")
        self.containers[name] = {"image": image, "state": "running",
                                 "ports": {host_port: cport}, "auto_rm": auto_rm}
        note = "(--rm:退出即删)" if auto_rm else ""
        return f"run {name} → running,-p {host_port}:{cport}{note}"

    def finish(self, name: str) -> str:
        """模拟一次性容器跑完退出。"""
        c = self.containers.get(name)
        if not c:
            return f"ERR 无此容器:{name}"
        if c["auto_rm"]:
            c["state"] = "removed"
            return f"{name} 执行完 → 自动删除(ps -a 里找不到)"
        c["state"] = "exited"
        return f"{name} 执行完 → exited"

    def exec_(self, name: str) -> str:
        c = self.containers.get(name)
        if not c:
            return f"ERR 无此容器:{name}"
        if c["state"] != "running":
            return f"ERR exec 要求运行中:{name} 当前 {c['state']}"
        return f"exec -it {name} → 进入容器 shell"

    def logs(self, name: str) -> str:
        c = self.containers.get(name)
        if not c:
            return f"ERR 无此容器:{name}"
        return f"logs {name} → 可读({c['state']} 状态也能看历史日志)"

    def stop(self, name: str) -> str:
        c = self.containers.get(name)
        if not c:
            return f"ERR 无此容器:{name}"
        if c["state"] != "running":
            return f"ERR stop 要求运行中:{name} 当前 {c['state']}"
        c["state"] = "exited"
        return f"stop {name} → exited(端口释放)"

    def start(self, name: str) -> str:
        c = self.containers.get(name)
        if not c:
            return f"ERR 无此容器:{name}"
        if c["state"] != "exited":
            return f"ERR start 要求已停止:{name} 当前 {c['state']}"
        c["state"] = "running"
        return f"start {name} → running(配置与数据原样恢复)"

    def rm(self, name: str, force: bool = False) -> str:
        c = self.containers.get(name)
        if not c:
            return f"ERR 无此容器:{name}"
        if c["state"] == "running" and not force:
            return f"ERR 容器运行中,拒绝删除:{name}(用 -f,或先 stop)"
        c["state"] = "removed"
        return f"rm {name} → removed"

    def rmi(self, image: str) -> str:
        users = [n for n, c in self.containers.items()
                 if c["image"] == image and c["state"] != "removed"]
        if users:
            return (f"ERR 镜像被引用:{image} 仍被 {users} 依赖"
                    f"(image is being used by stopped container)")
        if image not in self.images:
            return f"ERR 本地无此镜像:{image}"
        self.images.discard(image)
        return f"rmi {image} → 已删除"


def main() -> None:
    e = Engine()
    steps = [
        ("1  拉镜像", lambda: e.pull("nginx:alpine")),
        ("2  起 web1:8080", lambda: e.run("web1", "nginx:alpine", 8080, 80)),
        ("3  起 web2:8081", lambda: e.run("web2", "nginx:alpine", 8081, 80)),
        ("4  再抢 8080", lambda: e.run("web3", "nginx:alpine", 8080, 80)),
        ("5  exec 进 web1", lambda: e.exec_("web1")),
        ("6  stop web1", lambda: e.stop("web1")),
        ("7  停着再 exec", lambda: e.exec_("web1")),
        ("8  停着看日志", lambda: e.logs("web1")),
        ("9  start web1", lambda: e.start("web1")),
        ("10 运行中裸 rm", lambda: e.rm("web1")),
        ("11 stop + rm", lambda: (e.stop("web1"), e.rm("web1"))[1]),
        ("12 web2 还在时 rmi", lambda: e.rmi("nginx:alpine")),
        ("13 强删 web2", lambda: e.rm("web2", force=True)),
        ("14 再 rmi", lambda: e.rmi("nginx:alpine")),
        ("15 拉 python 镜像", lambda: e.pull("python:3.12-slim")),
        ("16 一次性容器", lambda: e.run("calc", "python:3.12-slim", 9000, 8000, auto_rm=True)),
        ("17 它跑完即消失", lambda: e.finish("calc")),
    ]

    print(f"{'步骤':<22}结果")
    errs = 0
    for label, fn in steps:
        out = fn()
        if out.startswith("ERR"):
            errs += 1
        print(f"{label:<22}{out}")

    print(f"\nps(运行中): {e.ps() or '(空)'}")
    print(f"ps -a(全部): {e.ps(all_=True) or '(空)'}")
    print(f"\n{len(steps)} 步里被拒绝 {errs} 步")
    print("★ 被拒的 4 步,正好对应新手最常撞的 4 类错误:")
    print("  ① 端口占用(第 4 步)      ② exec 到已停止容器(第 7 步)")
    print("  ③ 裸 rm 运行中的容器(第 10 步)  ④ rmi 删被引用的镜像(第 12 步)")
    print("  还有一类没被拒:漏了 pull 就直接 run —— 现代 Docker 会自动帮你拉(第 15 步先拉是习惯)。")


if __name__ == "__main__":
    main()

5.3 【实跑】输出

步骤                    结果
1  拉镜像                pull nginx:alpine → 本地镜像 1 个
2  起 web1:8080        run web1 → running,-p 8080:80
3  起 web2:8081        run web2 → running,-p 8081:80
4  再抢 8080            ERR 端口占用:宿主机 8080 已被 web1 占用(Bind for 0.0.0.0:8080 failed: port is already allocated)
5  exec 进 web1        exec -it web1 → 进入容器 shell
6  stop web1          stop web1 → exited(端口释放)
7  停着再 exec           ERR exec 要求运行中:web1 当前 exited
8  停着看日志              logs web1 → 可读(exited 状态也能看历史日志)
9  start web1         start web1 → running(配置与数据原样恢复)
10 运行中裸 rm            ERR 容器运行中,拒绝删除:web1(用 -f,或先 stop)
11 stop + rm          rm web1 → removed
12 web2 还在时 rmi       ERR 镜像被引用:nginx:alpine 仍被 ['web2'] 依赖(image is being used by stopped container)
13 强删 web2            rm web2 → removed
14 再 rmi              rmi nginx:alpine → 已删除
15 拉 python 镜像        pull python:3.12-slim → 本地镜像 1 个
16 一次性容器              run calc → running,-p 9000:8000(--rm:退出即删)
17 它跑完即消失             calc 执行完 → 自动删除(ps -a 里找不到)

ps(运行中): (空)
ps -a(全部): (空)

17 步里被拒绝 4 步
★ 被拒的 4 步,正好对应新手最常撞的 4 类错误:
  ① 端口占用(第 4 步)      ② exec 到已停止容器(第 7 步)
  ③ 裸 rm 运行中的容器(第 10 步)  ④ rmi 删被引用的镜像(第 12 步)
  还有一类没被拒:漏了 pull 就直接 run —— 现代 Docker 会自动帮你拉(第 15 步先拉是习惯)。

5.4 逐条读这 17 步

步

学到什么

2-3

一个镜像可以起多个容器,靠 --name 和宿主机端口区分

4

端口是宿主机的独占资源;容器内 80 可以有一万个

5

exec 是排障主力:进去看文件、看进程、手敲命令

6-7

停止后不能 exec——这是"容器不运行了,里面没进程可进"

8

但 logs 还能看:日志存在宿主机的 json-file 里,不随进程消失

9

start 恢复的是原容器,不是新建:改过的文件还在(可写层没删)

10

rm 拒绝运行中的容器,是保护你——先 stop 是肌肉记忆

12

镜像被任何容器(含已停止)引用就删不掉,这是 rmi 最常见的失败

16-17

--rm 适合一次性任务;它跑完 ps -a 里就找不到

5.5 真实命令的常用组合

# 起一个后台 Nginx,映射 8080,挂了自动重启
docker run -d --name web \
  --restart unless-stopped \
  -p 8080:80 \
  nginx:alpine

# 看状态(注意 STATUS 里的 Up 3 minutes / Exited (0) 2 minutes ago)
docker ps
docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'

# 实时日志(Ctrl+C 只退出查看,不停容器)
docker logs -f --tail 50 web

# 进容器(alpine 没有 bash,用 sh)
docker exec -it web sh

# 只取容器 IP / 只看挂了哪些卷
docker inspect --format '{{.NetworkSettings.IPAddress}}' web
docker inspect --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' web

# 资源占用(相当于容器版 top)
docker stats --no-stream

5.6 退出码:137 和 143 是什么意思

退出码

含义

怎么产生

该做什么

0

正常退出

进程自己结束

一次性任务正常

1

应用报错

代码异常

看 logs

137

128+9,被 SIGKILL

OOM 被杀、docker kill、或 stop 等 10 秒超时后被强杀

查 -m 是否太小、有无内存泄漏;也查应用是否没处理 SIGTERM

143

128+15,被 SIGTERM

docker stop,且应用自己处理了信号后正常退出

正常停止,不用管

126/127

命令不可执行/找不到

ENTRYPOINT 写错

查 Dockerfile

  ★ 客户现场看到 137,先别当"程序崩了":它要么是被 OOM 杀的,要么是 stop 超时被强杀的。
    第一步总是查 OOMKilled,为 false 就去看应用有没有处理 SIGTERM。
      docker inspect --format '{{.State.OOMKilled}}' <容器>

🖥️ 六、端口映射:一个冒号决定能不能访问

6.1 方向

  docker run -p 8080:80 nginx

                8080 : 80
                 ▲      ▲
        宿主机端口     容器内端口
        (你敲浏览器用的) (程序真正监听的)

  ★ 左边是门牌号(你定),右边是屋里人站的位置(镜像定,不能瞎写)。

6.2 实跑:port_map.py

# port_map.py —— 端口映射校验:方向、监听地址、占用、容器内端口对不对
from __future__ import annotations

# 镜像默认监听端口(写错容器端口是新手第一大坑)
APP_PORT = {
    "nginx": 80,
    "httpd": 80,
    "fastapi": 8000,
    "uvicorn": 8000,
    "mysql": 3306,
    "postgres": 5432,
    "redis": 6379,
}

SPECS = [
    ("web1", "nginx", "8080:80"),
    ("web2", "nginx", "8081:80"),
    ("api-bad", "fastapi", "8000:80"),      # 容器端口写错
    ("api-ok", "fastapi", "8000:8000"),
    ("db", "mysql", "3306:3306"),
    ("lan-only", "nginx", "127.0.0.1:8082:80"),
    ("typo", "nginx", "8080:80:80"),        # 三段,非法
    ("noport", "nginx", ""),                # 不映射,只有容器网络内可达
]


def parse(spec: str) -> dict:
    if not spec:
        return {"ok": True, "host": None, "cport": None, "bind": None, "raw": spec}
    parts = spec.split(":")
    if len(parts) == 3:
        bind, host, cport = parts
        if bind.isdigit():
            return {"ok": False, "err": "三段格式的第一段必须是 IP,你这是把宿主机端口写了两遍"}
        if not host.isdigit() or not cport.isdigit():
            return {"ok": False, "err": "三段格式里主机端口/容器端口必须是数字"}
        return {"ok": True, "host": int(host), "cport": int(cport), "bind": bind, "raw": spec}
    if len(parts) == 2:
        host, cport = parts
        if not host.isdigit() or not cport.isdigit():
            return {"ok": False, "err": "端口必须是数字(-p 宿主:容器)"}
        return {"ok": True, "host": int(host), "cport": int(cport), "bind": "0.0.0.0", "raw": spec}
    return {"ok": False, "err": "段数不对:应为 宿主:容器 或 IP:宿主:容器"}


def main() -> None:
    print(f"{'容器':<9}{'镜像':<9}{'-p':<20}{'判定'}")
    used: dict[tuple[str, int], str] = {}
    rows = []
    for name, image, spec in SPECS:
        p = parse(spec)
        if not p["ok"]:
            verdict = f"❌ 语法错:{p['err']}"
        elif p["host"] is None:
            verdict = "⚠️ 未映射:只能被同网络容器访问"
        else:
            want = APP_PORT.get(image)
            key = (p["bind"], p["host"])
            if want and p["cport"] != want:
                verdict = f"❌ 方向/端口错:{image} 监听 {want},你写成 {p['cport']}"
            elif key in used:
                verdict = f"❌ 端口冲突:{p['bind']}:{p['host']} 已被 {used[key]} 占用"
            else:
                exposure = "仅本机" if p["bind"] == "127.0.0.1" else "全网可达(0.0.0.0)"
                verdict = f"✅ {p['host']} → {p['cport']}|{exposure}"
                used[key] = name
        rows.append((name, image, spec or "(无)", verdict))
        print(f"{name:<9}{image:<9}{spec or '(无)':<20}{verdict}")

    bad = [r for r in rows if r[3].startswith("❌")]
    warn = [r for r in rows if r[3].startswith("⚠️")]
    print(f"\n{len(rows)} 条映射:可用 {len(rows) - len(bad) - len(warn)}|"
          f"错误 {len(bad)}|需注意 {len(warn)}")

    print("\n判据(三条,缺一条就可能连不上或被扫):")
    print("  ① 容器端口 = 镜像里程序真正监听的端口(nginx 80 / uvicorn 8000 / mysql 3306)")
    print("  ② 宿主机端口不重复,且 >1024(1024 以下要 root/管理员)")
    print("  ③ 只给本机用的别写 0.0.0.0 —— 客户的数据库端口暴露到公网是重大事故")
    print("\n★ 记住顺序:-p 宿主机:容器。左边是你的门牌,右边是屋里的人站的位置。")


if __name__ == "__main__":
    main()

6.3 【实跑】输出

容器       镜像       -p                  判定
web1     nginx    8080:80             ✅ 8080 → 80|全网可达(0.0.0.0)
web2     nginx    8081:80             ✅ 8081 → 80|全网可达(0.0.0.0)
api-bad  fastapi  8000:80             ❌ 方向/端口错:fastapi 监听 8000,你写成 80
api-ok   fastapi  8000:8000           ✅ 8000 → 8000|全网可达(0.0.0.0)
db       mysql    3306:3306           ✅ 3306 → 3306|全网可达(0.0.0.0)
lan-only nginx    127.0.0.1:8082:80   ✅ 8082 → 80|仅本机
typo     nginx    8080:80:80          ❌ 语法错:三段格式的第一段必须是 IP,你这是把宿主机端口写了两遍
noport   nginx    (无)                 ⚠️ 未映射:只能被同网络容器访问

8 条映射:可用 5|错误 2|需注意 1

判据(三条,缺一条就可能连不上或被扫):
  ① 容器端口 = 镜像里程序真正监听的端口(nginx 80 / uvicorn 8000 / mysql 3306)
  ② 宿主机端口不重复,且 >1024(1024 以下要 root/管理员)
  ③ 只给本机用的别写 0.0.0.0 —— 客户的数据库端口暴露到公网是重大事故

★ 记住顺序:-p 宿主机:容器。左边是你的门牌,右边是屋里的人站的位置。

6.4 三个判据怎么用

判据 ①:容器端口必须对上程序监听端口

  nginx       → 80
  uvicorn     → 8000(默认;你要它监听 80 就得在代码里改 --port)
  mysql       → 3306
  postgres    → 5432
  redis       → 6379

  ★ 你写 -p 8000:80 起 fastapi,浏览器会得到"连接被重置"——
    因为容器里根本没有进程监听 80。
    这个错误排查起来很费时间:容器是 Up 的,端口也映射了,就是不响应。

判据 ②:宿主机端口不重复、不要用 1024 以下

# 查谁占了 8080(Windows)
netstat -ano | findstr :8080

# 查谁占了 8080(Linux / WSL)
ss -lntp | grep 8080

# 看是不是 Docker 自己占的
docker ps --format 'table {{.Names}}\t{{.Ports}}'

判据 ③:绑定地址决定"谁能访问"

写法

谁能访问

什么时候用

-p 8080:80

同网段任何机器(0.0.0.0)

要给同事/客户看

-p 127.0.0.1:8080:80

只有本机

自己调试、管理后台

不写 -p

只有同网络的容器

内部服务(数据库、缓存)

  ★ 客户环境最危险的一种写法:
      docker run -p 3306:3306 mysql
    这等于把数据库端口开在 0.0.0.0,公网可扫。
    正确做法:不映射,或只映射 127.0.0.1,让应用走容器网络直连。

6.5 四种网络模式

模式

参数

特点

用途

bridge

默认

每个容器一个虚拟网卡,NAT 出网

绝大多数场景

host

--network host

直接用宿主网络栈,无 NAT

性能敏感、要拿真实客户端 IP

none

--network none

只有 lo

离线计算、安全隔离

container

--network container:web

共用另一个容器的网络

边车(sidecar)模式

# 自建网络,让容器用名字互相访问(明天 Compose 会自动做这件事)
docker network create app-net
docker run -d --name db --network app-net mysql:8.0
docker run -d --name api --network app-net -p 8000:8000 myapi
# api 容器里可以直接连 db:3306 —— 名字就是域名

💡 这条是明天 Compose 的地基:容器之间不要用 IP 互连(IP 会变),要用服务名。


🖥️ 七、数据卷:容器可以死,数据不能死

7.1 为什么必须挂卷

  容器的可写层,随容器删除一起消失。

  docker exec web sh -c "echo hi > /usr/share/nginx/html/a.txt"
  docker rm -f web
  docker run -d --name web -p 8080:80 nginx:alpine
  # a.txt 没了 —— 新容器用的是镜像的初始层

对无状态服务(Nginx 静态页)无所谓,对数据库是灾难。

7.2 三种挂载

挂载就是把容器外的存储接进容器里,让数据活过容器生命周期,或者让宿主机和容器共享文件。

1)绑定挂载(bind mount)

是什么:把宿主机上的一个具体路径直接映射到容器内路径。两边看到的是同一份文件。

bash

docker run -v /host/path:/container/path nginx
docker run --mount type=bind,src=/host/path,dst=/container/path nginx

数据在哪:宿主机你指定的那个目录,位置由你决定。

谁管理:你。Docker 不插手,不创建、不备份、不清理。

生命周期:跟宿主机文件系统走。容器删了,数据还在宿主机原位置。

适用场景:开发时把源码挂进容器热更新、挂配置文件、挂宿主机上已有的数据目录(比如 /var/log、数据库数据目录)。

坑:

  • 源路径必须已存在(老版本 -v 会自动创建目录,--mount 不会,行为不一致,容易踩)。

  • 权限问题多——容器内用户 UID 和宿主机 UID 对不上,写文件会 permission denied。

  • 可移植性差,路径写死在宿主机上,换机器就得改。

  • 容器能改宿主机文件,安全边界弱。

2)命名卷(volume)

是什么:由 Docker 管理的存储,放在 Docker 自己的数据目录下(Linux 默认 /var/lib/docker/volumes/),你只给一个名字。

bash

docker volume create mydata
docker run -v mydata:/container/path nginx
docker run --mount type=volume,src=mydata,dst=/container/path nginx

数据在哪:Docker 管理的目录,位置由 Docker 决定,你不直接操作路径。

谁管理:Docker。创建、删除、备份都通过 docker volume 命令。

生命周期:独立于容器。容器删了卷还在,要 docker volume rm 才删。多个容器可以共享同一个卷。

适用场景:生产环境持久化数据(数据库、上传文件)、容器间共享数据、需要跨容器生命周期保留的数据。这是生产环境推荐的方式。

优点:

  • 跨平台一致,不依赖宿主机路径。

  • 权限问题少(Docker 管理,初始化时可以把镜像里目标目录的内容和权限带过来)。

  • 可以用 docker volume ls/inspect/prune 统一管理。

  • 支持 volume driver,能挂 NFS、云存储等。

坑:

  • 数据在 Docker 管理目录下,不直观,排查时要 docker volume inspect 找 Mountpoint。

  • 备份不如 bind 直接(要起个临时容器挂载后打包)。

  • 匿名卷(不指定名字)容易积累垃圾,docker volume prune 要定期跑。

3)tmpfs 挂载

是什么:把数据放在宿主机内存里,不落盘。容器内看到的是一个内存文件系统。

bash

docker run --tmpfs /container/path nginx
docker run --mount type=tmpfs,dst=/container/path,tmpfs-size=100m nginx

数据在哪:宿主机内存(RAM),容器删除后数据立即消失。

谁管理:内核的 tmpfs,Docker 只负责挂载。

生命周期:跟容器同生共死。容器停或删,数据就没了。

适用场景:敏感临时数据(密钥、token,不想落盘)、高频读写的临时缓存、需要极快 IO 且不需要持久化的场景。

坑:

  • 占内存,设 tmpfs-size 限制大小,否则可能吃光内存。

  • 不持久,容器重启数据就丢,别放需要保留的东西。

  • 只在 Linux 上原生支持,Windows/Mac 的 Docker Desktop 走虚拟机,行为有差异。

三者对比速查:

类型

写法

数据在哪

谁管

适合

绑定挂载 bind

-v /data/web:/usr/share/nginx/html

宿主机指定目录

你

开发挂代码、挂配置

命名卷 volume

-v dbdata:/var/lib/mysql

Docker 管理的区域

Docker

数据库、生产数据

tmpfs

--tmpfs /tmp

内存

内核

临时文件、敏感数据不落盘

📌 FDE 铁律:数据库、上传目录、日志归档 —— 一律命名卷。 绑定挂载留给"我要随时改"的配置和代码。

7.4 备份与恢复

起一个一次性容器(alpine),同时挂两个东西:volume dbdata 挂到 /data,当前目录挂到 /backup。然后在这个容器里用 tar 把 /data 打包到 /backup,或者反过来解包。

为什么要这么绕?因为 volume 的数据在 Docker 管理目录下(/var/lib/docker/volumes/...),宿主机上没有稳定的直接路径可访问。你不该、也没法可靠地直接 tar 那个目录。正确做法就是借一个容器把 volume 和宿主机目录同时挂进来,让容器内的 tar 完成桥接。这也是"不依赖宿主机路径"的含义——命令只认 volume 名字,不认数据实际存在哪。

# 备份:从容器里导出(不依赖宿主机路径)
docker run --rm -v dbdata:/data -v ${PWD}:/backup alpine \
  tar czf /backup/db-$(date +%F).tar.gz -C /data .

# 恢复
docker run --rm -v dbdata:/data -v ${PWD}:/backup alpine \
  tar xzf /backup/db-2026-10-23.tar.gz -C /data
  ★ "备份"和"能恢复"是两件事。
    交付前必须在客户面前做一次恢复演练,否则那不叫备份,叫心理安慰。

📌说明:

--rm , 临时容器用完即删,不留垃圾。

-C /data . 打包的是目录内容,不是目录本身


📖 八、镜像仓库与标签纪律

8.1 三条命令

docker pull nginx:alpine

从默认 registry(Docker Hub)拉取 nginx 镜像的 alpine tag。拉取的本质是下载层(layer),本地已有的层会跳过,所以第二次拉同一个镜像很快。nginx:alpine 是官方维护的精简版,基于 Alpine Linux,体积比 nginx:latest 小很多,适合对镜像大小敏感的场景。

docker tag myapi:dev registry.local/myapi:1.4.0

给同一个镜像 ID 再挂一个名字。关键理解:tag 不是复制镜像,是给同一个 image ID 增加一个引用标签。执行后 myapi:dev 和 myapi:1.4.0 指向完全相同的层,磁盘上只有一份数据。这就是“同一镜像多个名字”的含义,也是为什么打 tag 几乎瞬间完成、不占额外空间。tag 是可变引用,不是版本锁定。 myapi:1.4.0 这个 tag 今天指向镜像 A,明天你可以 docker tag 把它指向镜像 B,registry 上同名 tag 会被覆盖。如果要有不可变的版本保证,应该用 digest(registry.local/myapi@sha256:...)来引用,或者配 registry 的 tag 不可变策略。生产环境拉镜像建议用 digest 而不是浮动 tag。

docker login registry.local

登录私有 registry。凭据默认存在 ~/.docker/config.json(Linux 上可能是 base64 编码的 auth 字段,或者走 credential helper)。登录成功后,后续 push/pull 到这个 registry 就不用再输密码。

docker push registry.local/myapi:1.4.0

把镜像推到私有 registry。push 的规则是按 tag 推,不是按 image ID 推——你推的是 registry.local/myapi:1.4.0 这个完整名字。push 时也只传 registry 上不存在的层,所以重复推同一镜像很快。docker push 默认指向 Docker Hub,要推私有 registry,必须先 tag 成 registry.local/myapi:1.4.0。

8.2 标签怎么打

各种tag的优缺点:

  ❌ myapi:latest              不知道是哪个版本
  ⚠️ myapi:v1                  能区分,但不知对应哪次提交
  ✅ myapi:1.4.0-20261023      版本 + 日期
  ✅ myapi@sha256:...          绝对可复现(生产回滚用这个)

8.3 客户内网怎么交付(离线场景)

  客户机房不能出网,三条路:

  ① 导出/导入(最简单,适合单次交付)
     docker save myapi:1.4.0 | gzip > myapi-1.4.0.tar.gz
     # 拷到客户机器
     docker load < myapi-1.4.0.tar.gz

说明:

docker save 保存的是镜像,不是容器,也不带 tag 之外的元数据语义。 保存时 tag 会被写进 tar(manifest 里有 RepoTags),docker load 之后 tag 自动恢复。但如果你 save 的时候镜像只有 <none>(悬空),load 出来也是 <none>,所以交付前先确认 myapi:1.4.0 这个 tag 在本地是正常的。

save 支持多镜像一次打包,但体积会叠加。 如果要交付多个相关镜像(比如 app + 依赖的 sidecar),可以 docker save -o bundle.tar myapi:1.4.0 redis:7-alpine,load 时一次全进来。比一个个 tar 方便,但包更大。

docker load 不校验完整性。 传输过程中 tar 损坏,load 可能报错也可能静默出一半——这就是为什么你最后那条“必须带 sha256sum”是对的,而且是在传输前后都校验:源机器算一次,客户机器 load 前算一次,对上了再 load。

  ② 内网 registry(适合长期)
     docker run -d -p 5000:5000 --name registry registry:2
     docker tag myapi:1.4.0 10.0.0.5:5000/myapi:1.4.0
     docker push 10.0.0.5:5000/myapi:1.4.0

registry:2 默认无认证、无 TLS、数据存在容器可写层。 直接 docker run -d -p 5000:5000 registry:2 起来的 registry:

  • 任何人能 push/pull,内网也不该这么裸奔;

  • 容器删了镜像全没,必须挂 volume:-v /data/registry:/var/lib/registry;

  • 默认走 HTTP,客户端要配 insecure-registries 才能 push(10.0.0.5:5000 这种 IP:port 默认被当成 insecure,但 daemon 不配还是会拒)。

docker tag 那行的前缀必须和 push 的地址完全一致。 10.0.0.5:5000/myapi:1.4.0 这个完整名就是 registry 里的路径,tag 时就该写成这样,而不是先 tag 成 myapi:1.4.0 再指望 push 时自动加前缀——不会的,push 推的是你给的名字。

  ③ 私有云镜像服务(客户已有阿里云/华为云 ACR/SWR)
     走客户的账号,注意权限与网络策略

  ★ 交付包里必须带:镜像 tar + 版本清单 + 校验和(sha256sum)。
    没有校验和,你无法证明"客户机器上跑的就是我交付的那个"。


📖 九、安全基线:8 条,交付前必须过

#

做法

防什么

1

容器内用非 root 用户(--user 1000:1000 或 Dockerfile USER)

逃逸后拿 root

2

不加 --privileged

等于给了宿主全部权限

3

--cap-drop=ALL 再按需 --cap-add

减少内核攻击面

4

--read-only + 需要写的目录挂 tmpfs

被写入 webshell

5

--security-opt no-new-privileges

提权到 setuid 程序

6

设 -m / --cpus / --pids-limit

一个容器拖垮整机

7

日志轮转 --log-opt max-size=10m --log-opt max-file=3

日志写满磁盘

8

不挂 /var/run/docker.sock

等于把宿主机 root 交给容器

# 一个"还算像样"的生产启动命令
docker run -d --name api \
  --restart unless-stopped \
  --user 1000:1000 \
  --read-only --tmpfs /tmp \
  --cap-drop=ALL \
  --security-opt no-new-privileges \
  -m 512m --cpus 1.0 --pids-limit 200 \
  --log-opt max-size=10m --log-opt max-file=3 \
  -p 127.0.0.1:8000:8000 \
  -e APP_ENV=prod \
  myapi:1.4.0

⚠️ 第 8 条单独说:很多"图方便"的教程会让你挂 -v /var/run/docker.sock:/var/run/docker.sock,让容器里能操作 Docker。 这等于把宿主机 root 权限给了容器内任意进程。 客户安全评审看到这条,基本一票否决。


📖 十、故障 12 例:按症状查

#

症状

最可能原因

排查命令

1

docker: command not found

CLI 没装 / PATH 没生效

which docker

2

Cannot connect to the Docker daemon

引擎没起

docker info

3

dockerDesktopLinuxEngine ... not found

Desktop 未启动/无权限

启动 Desktop(管理员)

4

port is already allocated

宿主机端口被占

netstat -ano | findstr :8080

5

容器 Up 但浏览器连不上

容器端口写错 / 只绑了 127.0.0.1

docker port <名>

6

容器起来就 Exited

启动命令错 / 缺环境变量

docker logs <名>

7

退出码 137

OOM 被杀

docker inspect --format '{{.State.OOMKilled}}'

8

docker stop 要等满 10 秒,退出码是 137

应用没处理 SIGTERM,被强杀

注册信号处理;CMD 用 exec 形式

9

image is being used by stopped container

有容器引用该镜像

docker ps -a,先删容器

10

Can't create/write ... Errcode: 13

卷权限/uid 不匹配

改命名卷或 chown

11

拉镜像超时

网络/镜像源

配 registry-mirrors 或离线导入

12

磁盘莫名满了

日志/悬空镜像堆积

docker system df,docker system prune

# 一条命令看全局占用,排查磁盘问题先跑它
docker system df
# TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
# Images          12        3         2.1GB     1.4GB (66%)
# Containers      8         2         120MB     118MB (98%)
# Local Volumes   4         1         800MB     600MB
# Build Cache     35        0         1.2GB     1.2GB

📊 十一、速查表

11.1 镜像 vs 容器

操作

镜像

容器

列表

docker images

docker ps / ps -a

获取

docker pull

docker run / create

删除

docker rmi

docker rm

清理

docker image prune

docker container prune

11.2 docker run 常用参数

参数

含义

备注

-d

后台运行

不加就占着终端

--name

命名

不写会随机给个怪名字

-p 宿主:容器

端口映射

左边你定,右边镜像定

-v 源:目标

挂载

加 :ro 只读

-e KEY=VALUE

环境变量

密码别写命令行,用 --env-file

-it

交互 + 终端

调试用

--rm

退出即删

一次性任务

--restart

重启策略

常驻服务用 unless-stopped

-m / --cpus

资源限制

生产必设

--network

网络

同网络可用名字互访

--user

运行用户

安全基线第 1 条

11.3 五条命令覆盖 80% 排障

docker ps -a                        # 它活着吗、退出码多少
docker logs --tail 100 <名>         # 它说了什么
docker inspect <名>                 # 它到底怎么配的
docker exec -it <名> sh             # 进去看看
docker stats --no-stream            # 它吃了多少资源

💬 十二、客户会怎么问(8 问 8 答)

Q1:容器和虚拟机有什么区别?我们已经有 VMware 了。

虚拟机各自带一整套操作系统,容器共享宿主内核。同样一台机器,VM 能跑十几个,容器能跑几十上百个,启动从分钟降到秒。 但容器隔离弱于 VM,所以多租户强隔离场景仍用 VM。

Q2:容器安全吗?会不会影响我们现有系统?

默认桥接网络 + 不挂宿主目录 + 不加 --privileged,风险可控。我们按 8 条基线交付(非 root、只读根、cap-drop、资源限制、日志轮转、不挂 docker.sock),这 8 条可以给你们安全评审逐条核。

Q3:数据放容器里,容器删了不就没了?

对,所以数据一律放卷。数据库用命名卷,配置用只读绑定挂载。容器本身当"牲口",随时可重建;数据活在容器外面。

Q4:你们交付的镜像,我们怎么知道没被改过?

交付包含 sha256sum 清单,镜像钉 digest。你们 docker load 后跑一次校验,哈希一致就证明是同一个镜像。

Q5:能不能不用 Docker,直接装?

能,但要在你们的生产机上装 Python 3.12、改 glibc、升级 openssl —— 这等于动生产环境。容器是"不动你们机器"的方案。

Q6:为什么镜像这么大?一个多 G。

因为里面带了完整运行时。基础层只存一份,10 个服务共享。真要瘦,可以用 slim/alpine 基础镜像 + 多阶段构建,通常能压到 1/5。

Q7:容器挂了怎么办?

--restart unless-stopped 会自动拉起;再配合健康检查和告警。但自动重启只解决"进程挂了",不解决"为什么挂"——那要靠日志和指标。

Q8:我们要不要自己维护一个镜像仓库?

看规模。单次交付用 docker save 加 tar 包最省事;长期多项目建议内网 registry,配合版本清单和保留策略。


✅ 十三、今日交付物与自检

#

交付物

验收标准

1

layer_cache_sim.py

跑出 106 倍,输出与文中一致

2

container_lifecycle.py

17 步、4 步被拒,输出一致

3

port_map.py

8 条映射:5 可用 / 2 错 / 1 注意

4

docs/docker/baseline.md

8 条安全基线,每条一句为什么

5

docs/docker/troubleshoot.md

12 例,按症状能查到

6

一份镜像清单

名字 + tag + digest + sha256

  □ 1. 能说出环境差异的 5 个维度
  □ 2. 能解释 namespace / cgroups / unionfs 各管什么
  □ 3. docker --version 和 docker info 都通过(不是只过第一关)
  □ 4. hello-world 跑通
  □ 5. 能画出容器状态图(run/stop/start/rm 四态)
  □ 6. 说得出 exec 为什么不能用在已停止容器上
  □ 7. -p 左右两边各是谁定的,说不错
  □ 8. 知道 0.0.0.0 和 127.0.0.1 的安全差别
  □ 9. 数据库为什么必须用命名卷
  □ 10. 看到 137 知道先查 OOMKilled

📝 十四、本课小结

#

知识点

一句话

1

解决什么

代码 + 依赖 + 系统库一起打包,不动客户机器

2

五维差异

内核 / glibc / 解释器 / 系统库 / 路径账号

3

三个机制

namespace 看不见、cgroups 用不多、unionfs 看着全

4

不是小 VM

共享内核 → 更轻,也更需要安全基线

5

镜像分层

一层只读快照,父层变了后代全失效

6

缓存代价

同一改动 42.4s vs 0.4s,差 106 倍

7

生命周期

run/stop/start/rm 四态,exec 只对 running

8

端口

-p 宿主:容器,容器端口必须对上程序监听

9

绑定地址

0.0.0.0 全网可达,127.0.0.1 只本机

10

数据

容器可死,数据挂卷;数据库用命名卷

11

安全

8 条基线,第 8 条(不挂 docker.sock)最要紧

12

排障

ps -a / logs / inspect / exec / stats 五连

🧠 核心认知:三个分离。

  ① 镜像与容器分离 —— 一个镜像跑任意多个容器,容器是一次性的
  ② 容器与数据分离 —— 容器随便删,数据必须在卷里
  ③ 构建与运行分离 —— 构建时定死的东西,运行时改不了(所以顺序很重要)

  ★ 容器是"牲口"不是"宠物":病了不要治,杀掉重建一个。

📋 十五、课后练习

练习 1:把三个脚本跑一遍,并改一个参数(约 30 分钟)

  1. 跑 layer_cache_sim.py,确认 106 倍

  2. 把 BAD 里的 COPY . . 改成只拷 app/main.py,看倍数是变大还是变小,解释为什么

  3. 跑 container_lifecycle.py,把第 4 步的端口改成 8082,看拒绝数变成几

  4. 跑 port_map.py,加一条 ("cache","redis","6380:6379"),看判定

练习 2:Nginx 五连操作(约 25 分钟)

  1. 后台起 Nginx,映射 8088,命名 lab-nginx,加 --restart unless-stopped

  2. 浏览器确认欢迎页

  3. exec 进去,cat /usr/share/nginx/html/index.html

  4. logs -f 看访问日志,刷新三次浏览器,观察新增行

  5. stop → start → rm,每一步用 docker ps -a 确认状态

验收:能不看笔记说出第 5 步每一步的状态变化。

练习 3:数据卷持久化(约 20 分钟)

  1. 在 ~/docker-data/site 放自己的 index.html

  2. -v ...:...:ro 挂载启动,确认显示你的页面

  3. rm -f 强删,确认宿主机文件还在

  4. 用命名卷再做一个:docker volume create webdata,挂进去,改内容,删容器重建,确认数据还在

  5. 回答:为什么 Nginx 可以不挂卷,而 MySQL 必须挂?(提示:谁有状态)

练习 4:把上周的 FastAPI 用容器跑起来(约 40 分钟,选做)

不用写 Dockerfile,先用现成镜像验证思路:

docker run -d --name api \
  -v ${PWD}:/app -w /app \
  -p 8000:8000 \
  python:3.12-slim \
  sh -c "pip install -r requirements.txt && uvicorn main:app --host 0.0.0.0 --port 8000"

验收:浏览器能打开 /docs。然后回答:这个跑法有什么问题?(至少说出两条:每次启动都重装依赖、镜像里没有代码只能靠挂载)——这正是明天 Dockerfile 要解决的。


🔭 十六、下节预告

今天你跑的都是别人的镜像,相当于"住精装房"。

明天要"自己盖房子":写 Dockerfile,把 FastAPI 项目连同 Python 和依赖打成专属镜像。会用今天 §三 的层缓存结论,把构建从 42 秒压到 0.4 秒;也会讲 .dockerignore、多阶段构建、USER 非 root、以及为什么 CMD 要用 exec 形式(对应今天 §5.6 里 137 这个退出码)。


🌍附录:前置课程列表

阶段一:认知启蒙(AI 认知与 FDE 角色)
AI 认知

【FDE系列】阶段1Day 1:AI 层级关系 — 四个嵌套的圈-CSDN博客

【FDE系列】阶段1Day 2:AI 三阶段发展史 — 会认 → 会判断 → 会创造-CSDN博客

【FDE系列】阶段1Day 3:符号 AI vs 机器学习 — 两条路线的本质区别-CSDN博客

【FDE系列】阶段1Day 4:Transformer 的历史意义 — 2017 年的分水岭-CSDN博客

【FDE系列】阶段1Day 5:本周复习与自测 — 检验你的 AI 认知地基-CSDN博客

【FDE系列】阶段1Day 6:Transformer 架构 — 一张图纸盖出千千万万栋楼-CSDN博客

【FDE系列】阶段1Day 7:LLM 本质 — 文字接龙机器-CSDN博客

【FDE系列】阶段1Day 8:Token — 模型眼中的最小单位-CSDN博客

【FDE系列】阶段1Day 9:AI 幻觉 — 为什么会一本正经地胡说八道-CSDN博客

【FDE系列】阶段1Day 10:上下文窗口 — 模型的记忆力上限 + 本周复习-CSDN博客

【FDE系列】阶段1Day 11:Prompt — 给模型立规矩-CSDN博客

【FDE系列】阶段1Day 12:Memory — 让模型记住上下文

【FDE系列】阶段1Day 13:RAG — 给模型配图书管理员-CSDN博客

【FDE系列】阶段1Day 14:Tool Use — 让模型动手操作-CSDN博客

【FDE系列】阶段1Day 15:MCP — 统一的工具接口标准 + 第三周复习-CSDN博客

FDE 基础概念

【FDE系列】阶段1Day 16:什么是 FDE — 把 AI 变成客户结果的人-CSDN博客

【FDE系列】阶段1Day 17:FDE vs 传统实施 — 三大本质区别-CSDN博客

【FDE系列】阶段1Day 18:FDE 三重身份 + C6 胜任力模型-CSDN博客

【FDE系列】阶段1Day 19:七阶段行动路径 + 行业经验的价值-CSDN博客

【FDE系列】阶段1Day 20:阶段总结与产出物 — 第一阶段收官-CSDN博客


阶段二:技术地基(Python + FastAPI + SQL + Docker + API 集成)
Python基础

【FDE系列】阶段2:Day 21:Python 环境搭建 — 写出你的第一行代码-CSDN博客

【FDE系列】阶段2:Day 22:变量、数据类型、条件判断 — Python 的“记忆“和“判断“-CSDN博客

【FDE系列】阶段2:Day 23:循环与函数 — 让代码跑 100 遍、把逻辑打包复用-CSDN博客

【FDE系列】阶段2:Day 24:数据结构 — 列表、字典、集合、元组-CSDN博客

【FDE系列】阶段2:Day 25:文件读写与 JSON — 让程序连通外部数据(第一周收官)-CSDN博客

【FDE系列】阶段2:Day 26:模块化编程 — 把代码拆成“抽屉柜“-CSDN博客

【FDE系列】阶段2:Day 27:异常处理与日志 — 让程序“摔不烂、查得到“-CSDN博客

FastAPI入门到进阶

【FDE系列】阶段2:Day 28:FastAPI 入门 — 把你的函数变成 API 服务-CSDN博客

【FDE系列】阶段2:Day 29:FastAPI 进阶 — Pydantic 模型与完整 CRUD 实战-CSDN博客

【FDE系列】阶段2:Day 30:生产代码规范 — 测试、类型注解、配置管理(第二周收官)-CSDN博客

SQL基础

【FDE系列】阶段2:Day 31:SQL 基础 — 增删改查一把梭-CSDN博客

 【FDE系列】阶段2:Day 32:多表查询 — JOIN 与聚合-CSDN博客

【FDE系列】阶段2:Day 33:进阶查询 — 窗口函数与 CTE-CSDN博客

【FDE系列】阶段2:Day 34:数据清洗 — 把脏数据捋干净-CSDN博客

【FDE系列】阶段2:Day 35:Python + SQL — 工单接入 MySQL + 本周收官-CSDN博客

Linux基础

【FDE系列】阶段2:Day 36:Linux 入门与文件操作 — 扔掉鼠标的第一天-CSDN博客

【FDE系列】阶段2:Day 37:权限、进程与文本三剑客-CSDN博客

【FDE系列】阶段2:Day 38:Shell 脚本 — 把命令串起来自动跑-CSDN博客

【FDE系列】阶段2:Day 39:Linux 综合实战 — 让服务无人值守-CSDN博客

【FDE系列】阶段2:Day 40:Shell 进阶 — 生产级脚本与本周收官-CSDN博客

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2501_90561511/article/details/169361759

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--