Docker 深度解析:从分层架构原理到镜像构建最佳实践

38 阅读29分钟

Docker完整教程:

这个我感觉讲的很全面,信息密度很高。
github.com/jaywcjlove/…


Docker常用指令

但是还有一个问题,主播主播,我记不住那么多命令啊!
没关系,常用的就那么多,记住这个就好,不会的再查就行。

docker stats               # 实时查看所有容器的资源使用情况(CPU,内存,网络,磁盘IO)
docker compose ps               # 查看当前目录下的所有容器运行快照(镜像,启动命令,服务名称,当前状态,ports,created)

Attention:

  • docker compose ps 只显示当前目录下 docker-compose.yml 能够管理的容器!
  • Stats: Up 仅代表容器进程还在运行(PID 存在)。如果你的 Python 代码内部死锁了,或者端口被防火墙拦截了,状态依然是 Up。只有配置了 healthcheck 后的 (healthy) 才是真正的服务可用状态。
docker compose logs -f         # 实时查看当前目录下所有容器的日志输出
docker compose restart      # 重启所有容器
docker compose down        # 停止并删除所有容器,但不删除镜像和数据卷
docker compose down -v      # 会删除在docker-compose.yml中volumes块下的具名卷,但外部卷或compose文件之外创建引用的不会被删除。
命令运行模式镜像处理逻辑 (关键)适用场景
docker compose up前台运行 日志直接打印在屏幕上。 按下 Ctrl+C 会停止容器。**如果本地已经有镜像,就直接用。镜像不存在时,才会去构建。调试故障时 当你需要盯着日志看报错,或者想快速测试一下能不能跑通时。
docker compose up -d后台运行 启动后立即释放终端。 容器在背后默默运行。**同上。如果镜像存在,不会重新构建,哪怕你改了代码。日常启动 环境搭好了,不需要改代码,只想把服务跑起来用。
docker compose up --build -d后台运行 启动后释放终端。**不管本地有没有镜像,强制重新读取代码并构建新镜像,然后启动。开发/修改代码时 当你修改了代码,必须用这个,否则容器里跑的还是旧代码!
docker compose build --no-cache仅构建,不启动零缓存 完全忽略本地所有缓存层,强制从 0 开始执行 Dockerfile 。修玄学Bug / 强制更新依赖 1. 当 requirements.txt 没变,但想强制重新下载包里的最新子依赖时。 2. 构建出现莫名其妙的缓存错误时。 警告:非常慢,非必要不使用。
docker exec -it <容器名称或ID> <容器内的命令> [该命令的参数...]

它允许你绕过容器的启动命令,直接在正在运行的进程旁边“空降”一个新的进程,一般用来进入容器内部调试或修改文件,修改容器层文件重新构建会丢失!但是挂载卷不会丢失


Docker命令参数

首先不得不得说的一点,Docker 的命令体系非常庞大。直接列出所有参数不仅不可能,也没啥用。
并且事实上,
Docker 没有一套通用的“全局参数”。 Docker 是由 主命令 + 子命令 + 选项(Flags) 组成的。 例如 docker run -d -p 80:80 nginx 中,-d 和 -p 是专门属于 run 子命令的选项,在 docker build 中就完全不适用。
所以按照使用场景,就总结最高频、最核心的参数。

常用参数

启动容器的核心参数

参数含义与作用具体代码使用示例
-d后台运行 (Detach) 启动后在后台默默工作,不占用当前终端窗口。 适用于 Web 服务器、数据库等常驻服务。docker run -d nginx:latest*
-p端口映射 (Publish) 规则:宿主机端口:容器端口 打通内外网络,让外部浏览器能访问容器。docker run -p 8080:80 nginx (此时访问宿主机的 8080 端口 = 访问容器的 80 端口)
-v挂载数据卷 (Volume) 规则:宿主机路径:容器路径 将数据保存在宿主机硬盘上,防止容器删除导致数据丢失。docker run -v ./data:/var/lib/mysql mysql (将当前目录下的 data 文件夹映射给数据库存数据)
--restart重启策略 常用 always 或 unless-stopped。 保证服务器重启或进程崩溃后,容器能自动重新启动。docker run --restart always redis*
--rm用完即删 容器停止运行后,自动删除容器实例。 用于临时测试或一次性脚本,避免产生大量垃圾容器。docker run --rm busybox echo "hello"*

调试与交互参数

命令/参数含义与作用具体代码使用示例
-it (配合 exec)交互式伪终端 -i (交互式) + -t (伪终端) 的缩写。 作用:让你像 SSH 登录一样进入容器内部修改文件或调试。docker exec -it <容器名> /bin/bash (常用 /bin/bash 或 /bin/sh)
-f (配合 logs)实时跟踪 (Follow) 作用:类似 Linux 的 tail -f,实时刷新显示最新的日志输出。 用于监控程序正在运行时的行为。docker logs -f <容器名> (Ctrl+C退出跟踪)
--tail (配合 logs)限制行数 作用:仅查看最后 N 行日志。 防止日志文件过大瞬间刷屏,找不到重点。docker logs --tail 100 <容器名>*
-t (配合 logs)时间戳 作用:在每一行日志前添加具体的时间记录。 用于排查故障发生的具体时间点。docker logs -t <容器名> (显示如:2023-10-01T12:00:00 ...)

查看容器状态

命令/参数含义与作用具体代码使用示例
-a (配合 ps)显示所有 显示所有容器(包括已停止的)。 默认 docker ps 只看运行中的。若容器启动即报错退出,必须加此参数才能看到现场。docker ps -a (查看所有历史容器记录)
-q (配合 ps)静默模式 只显示容器 ID,不显示名称等杂项。 常用于脚本化批量操作。docker rm $(docker ps -aq) (骚操作:一键删除所有容器)

构建镜像参数

命令/参数含义与作用具体代码使用示例
-t (配合 build)打标签 (Tag) 给镜像起名字和版本号。 格式:镜像名:版本docker build -t my-app:v1 . (把当前目录代码打包并命名为 my-app:v1)
-f (配合 build)指定文件 (File) 指定 Dockerfile 的路径。 当你的构建文件不叫 "Dockerfile" 或在子目录时使用。docker build -f ./deploy/Dockerfile . (使用指定路径的配置文件构建)
--build-arg (配合 build)构建参数 在镜像构建过程中传入变量。 注意:区别于 run -e (运行时环境变量)。docker build --build-arg VERSION=2.0 . (传入版本号供构建逻辑使用)

申必错误点

避坑点核心规则/区别详细说明与记忆技巧
-p 端口映射顺序外部 : 内部 (宿主机 : 容器)严禁写反。数据是从左边(外部)流进右边(内部)的。 例:-p 8080:80 表示访问电脑的 8080 就是访问容器的 80。
-v 挂载路径写法必须是绝对路径 (或具名卷)冒号左边的宿主机路径不能写相对路径 (如 ./data),否则在某些环境会报错。 推荐写法:-v $(pwd)/data:/data 或使用完整路径 /home/user/data
rm vs rmi删容器 vs 删镜像docker rm = 删除容器 (Container) —— 删运行实例。 docker rmi = 删除镜像 (Image) —— 删安装包。 注意:如果镜像删不掉,通常是因为有容器(哪怕是停止的)还在用它,先 rm 容器,再 rmi 镜像

参考资料

嗯差不多以上就是我这次项目中用到的参数了,其他的参数可以参考官方文档:
docs.docker.com/engine/refe…


docker的层级构建

终于到我想说的重点之一啦,了解docker的层级构建原理,可以更好地写dockerfile,优化镜像体积,加快构建速度~~(要是有个啥pytorch啥大模型还稀里糊涂的你就老实了,说的就是我自己)~~


存储驱动

docker使用存储驱动来管理镜像和容器的文件系统。它的职责就是将镜像层和可写容器层管理起来,不同的驱动实现管理的方式也不一致。实现容器与镜像管理的两个关键技术就是可堆叠的镜像层和 copy-on-write。
(把后面的看看再回头看这俩图会有更清晰的理解)

图中大概就是一个镜像层(只读)、容器层(读写) 以及 写时复制(CoW) 的工作机制。

image.png Docker 存储驱动与层级架构图。

QQ_1765701705363.png

镜像与层

Docker 镜像是由一系列“层(Layers)”堆叠而成的。

镜像层 (Image Layers)

镜像是一种轻量级、可执行的独立软件包,用来打包软件运行环境和基于运行环境开发的软件,它包含运行某个软件所需的所有内容,包括代码、运行时、库、环境变量和配置文件。

  • 只读性:镜像的每一层都是不可修改的,它们是静态的只读模板。只在重新构建时修改。
  • 分层构建:镜像是通过一系列Dockerfile指令构建的,每条指令(如RUN, COPY)都会在上一层的基础上创建一个新的层。
  • 共享机制:多个容器可以共享同一个镜像的只读层,节省存储空间和带宽,加快容器启动。

容器读写层(Container Layer)

容器读写层是每个容器独有的。当你启动一个新的容器时,Docker 会为它创建一个全新的读写层。这个读写层位于所有只读镜像层之上。

  • 可写性:当容器启动时,Docker会在所有只读镜像层之上叠加一个可写层 (称为容器层)。
  • 数据隔离:所有对文件系统的操作 (创建新文件、修改、删除) 都发生在这个顶层。
  • 生命周期:容器层与容器的生命周期绑定,删除容器时,容器层及其所有更改也会被销毁。
    Union Mount:通过Union File System的技术,将镜像层 (lowerdir) 和容器层 (upperdir) 联合起来,对用户透明地展示为一个完整的、可读写的文件系统。

docker镜像构建原理

Docker镜像构建原理核心是联合挂载写时复制分层,通过Dockerfile定义一系列指令,每条指令都在前一层基础上创建一个新的只读层,形成堆叠的镜像层。这些层通过联合文件系统(UnionFS)合并,对外呈现为一个完整的文件系统,实现镜像层的共享和高效传输,同时启动容器时在顶层添加读写层,实现数据隔离和高效存储。

联合文件系统 (Union File System, UnionFS)

(看这个吧,塞到一篇文章貌似过于臃肿)
cloud.tencent.com/developer/a…
下面这个是从linux操作系统内核层面解释联合挂载的最底层原理,看着头痛。上面那个浅显一点。
www.kernel.org/doc/html/la…

写时复制 (CoW)

一个Docker镜像由一系列的层构建起来。每一个层代表Dockerfile中的一个指令。每一个层除了最后一层都是只读的。镜像并非独立的二进制块,而是由多个镜像层构成的。 不同的镜像可以共享若干个镜像层,这样确保镜像的存储和传递更加高效。所有构成基础镜像的层只会被保存一次。当拉取镜像的时候,docker会独立的下载每一层,对于已经存储在本地的层,docker不会重复下载它们。COW就与最后一层的读写操作有关。

当新进程通过 fork 被创建时,内核并不立即复制物理内存,而是仅将父进程的页表项复制给子进程,使两者共享同一组物理页框,并将这些页面的权限位均标记为只读。
当任一进程试图修改共享页面时,硬件(MMU)检测到权限冲突,触发缺页异常(Page Fault)。内核的异常处理程序捕获后,会分配一个新的物理页框,将原页面的内容复制进去,更新该进程的页表映射指向新页框,并将权限恢复为可读写,最后重新执行写入指令

有了 CoW之后:

  • 共享 (Sharing) :所有容器直接共享底层的只读镜像数据,不占用额外空间。
  • 懒加载 (Laziness) :只有当某个容器需要修改某个文件时,Docker 才会把那个文件单独复制出来给它修改。

1.新数据会直接存放在最上面的容器层。
2.修改现有数据会先从镜像层将数据复制到容器层,修改后的数据直接保存在容器层中,镜像层保持不变。
3.如果多个层中有命名相同的文件,用户只能看到最上面那层中的文件。

一言以蔽之:拷贝让容器更高效


分层 (Layering)
基础镜像层:

镜像由多个只读文件系统层堆叠而成,最底层是bootfs(引导文件系统),之上是rootfs(如Ubuntu, CentOS)。详见核心架构

构建新层:

第一阶段:准备环境

  1. 加载父级上下文。

    • 输入:上一层镜像的 ID(记为 Layer N)。
    • 动作:Docker 引擎通过联合文件系统(UnionFS) ,将 Layer N 以及它之前的所有祖先层挂载为**只读(Read-Only)**的底层。
    • 本质:这是构建新层的地基。如果这是第一层,地基就是空的(scratch)。
  2. 创建临时沙盒 (Create Ephemeral Sandbox)

    • 动作:在地基之上,Docker 盖上一层可读写层(Read-Write Layer)
    • 本质:这是一个临时容器。它是一个封闭的、隔离的执行环境。
    • 目的:确保即将发生的所有修改,不会污染底层的父镜像,而是被隔离在这个可读写层里。
      第二阶段:产生变更 (Mutation)
  3. 执行变更指令 (Execute Mutation)

    • 动作:Docker 引擎在这个临时沙盒内,执行 Dockerfile 中的指令逻辑。

    • 通用行为:

      • 写入:如果在沙盒里创建了新文件,文件实体被写入可读写层。
      • 修改:如果修改了父层的文件,则触发CoW,将父层文件复制到可读写层并修改。
      • 删除:如果删除了父层的文件,在可读写层创建一个**Whiteout(白障文件)**进行标记。
      • 结果:此时,所有的变化量(增量 ΔΔ)都保存在这个临时的可读写层中。
        第三阶段:固化与产出 (Finalization)
  4. 捕获差分 (Capture Diff)

    • 动作:指令执行结束后,Docker 引擎扫描这个可读写层。
    • 逻辑:它不关心底层有什么,它只关心这一层和底层相比的变化
    • 提取:它将可读写层里所有的新增文件、修改后的副本以及删除标记提取出来。
  5. 序列化与压缩 (Serialization)

    • 动作:将提取出来的“差分内容”打包成一个二进制归档文件(通常是 tar.gz)。
    • 实际上,这个压缩包就是物理上的“新层”。它不再是可读写的文件夹,而是一个静态的文件包
  6. 计算内容指纹 (Content Addressing)

    • 动作:对这个压缩包的内容 + 这一层的元数据(如指令本身、构建时间、父层ID)进行 SHA256 哈希计算。
    • 结果:生成一个新的唯一 ID(例如 sha256:7a8b...)。
    • 链接:将这个 ID 指向父层 ID,形成链表结构
  7. 清理 (Cleanup)

    • 动作:销毁第 2 步创建的临时沙盒(临时容器)。
    • 状态:现在的 Layer N+1 已经成为了一个只读的静态资源,准备好作为构建 Layer N+2 的父级上下文。\

image-2.png

内容寻址 (Content Addressing)
  • 什么是“内容寻址”?
    根据文件内容来索引镜像和镜像层。与之前版本对每一个镜像层随机生成一个UUID不同,传统的存储是“按位置寻址”(比如文件存在 D 盘某个文件夹)。Docker 的存储是“按指纹寻址”。

  • 计算指纹 (SHA256): 一个层被构建出来时,Docker 会对这一层的所有文件内容、目录结构、权限元数据进行一次 SHA256 哈希计算,后续再通过哈希对比实现“零冗余”存储。

    公式:Layer_ID = SHA256(Content + Metadata)

  • 共享去重的过程:
    假设有两个镜像:MyApp:v1 和 Database:v1,它们的 Dockerfile 第一行都是 FROM ubuntu:20.04。
    下载第一个镜像 (MyApp): Docker 下载 Ubuntu 的基础层。计算哈希,发现是 a1b2c3...。Docker 把这一层存在 /var/lib/docker/overlay2/a1b2c3...。

下载第二个镜像 (Database): Docker 解析镜像清单,发现它也依赖一个哈希为 a1b2c3... 的层。

  • 命中缓存 (De-duplication): Docker 检查本地数据库,发现已经有一个 a1b2c3... 的文件块了。 它就不会再去下载,也不会再占用一份硬盘空间。直接让 Database 镜像的一个指针指向本地已有的那个文件块。
  • 安全性与不可变性:
    因为 ID 是由内容算出来的,如果你修改了层里的一个字节,算出来的哈希值就会彻底改变。
    Docker 会认为这是一个全新的层,找不到旧的 ID,从而报错或将其视为新数据。
    这保证了镜像数据的完整性——只要 ID 对得上,内容就没有被篡改。

PS:

这里在初次了解的时候,我误以为:“哈希一样 -> 复用” “哈希不一>样 -> 再用配置文件Manifest堆叠”,但其实是错的,
但实际上应该是:配置文件决定了我要哪些层、按什么顺序堆叠。哈希决定了我>是不下载直接用(复用),还是下载新的(不复用)。

逻辑执行顺序

  • 第一步:读取 Manifest(跟说明书一样)

    Docker 拿到镜像的manifest,上面写着:“我是 my-app:v1,我三层组成,顺序是:底层用 A (Hash: 123),中间用 B (Hash: 456),顶层用 C (Hash: 789)。”

  • 第二步:逐个检查零件(内容寻址) Docker 拿着这个清单,去本地仓库检查:

    检查 A (123):本地有吗?

    情况 1 (Hash 一样):直接复用本地的 /var/lib/docker/overlay2/123。

    情况 2 (Hash 不一样/不存在):去 Registry 下载,存到本地。

    检查 B ,C:本地有吗?(同上逻辑)

  • 第三步:按图纸堆叠(OverlayFS 挂载) 所有零件都备齐(无论是复用的还是新下载的)之后,OverlayFS 严格按照 Manifest 里写好的顺序 (LowerDir=C:B:A) 把它们堆叠起来。


核心架构:
Bootfs (Boot File System):

引导文件系统

它主要包含两个核心组件:

  • Bootloader (引导加载程序) :比如常见的 GRUB。它的任务是加载内核
  • Kernel (内核) :操作系统的核心大脑(在 Linux 中通常是 vmlinuz 文件)。

虚拟机的做法:
当你启动一个虚拟机(VM)时,它会模拟整个开机过程,加载它自己的 bootfs,启动它自己的内核。所以虚拟机启动很慢(需要几十秒)。

Docker 的做法:

  • Docker 容器里面其实没有 bootfs,容器直接复用宿主机的内核。
  • 当你启动一个容器时,它不需要从头加载内核,而是直接使用宿主机(比如你的 Ubuntu 服务器或 Windows WSL)已经加载好的内核。这个过程结束后,内核驻留在内存中。
    这就是容器比虚拟机快的原因(不需要经过 BIOS 自检、加载内核等过程)。

PS:Linux 系统在启动完成后,通常**卸载(umount)**bootfs,释放内存

Rootfs (Root File System):

根文件系统

一旦内核(Kernel)启动并初始化完成,它需要一个“工作环境”。rootfs 就是用户能看到、能操作的工作环境。它包含了操作系统运行所需要的文件、配置和工具。

包含操作系统的典型目录结构(如 /dev, /proc, /bin, /etc 等),这是 Docker 里的 FROM ubuntu 或 FROM alpine 指令引入的那一层.

Docker 镜像本质上就是一个 rootfs。

  • Docker 引擎利用 chroot (更多的是** pivot_root**) 技术,把容器的“视野”锁定在这个 rootfs 里,让它觉得这也就是整个世界。涉及docker的隔离camelgemonion.gitbook.io/docker/dock… 这个比较简单的讲了一下。
  • 当你执行 docker run ubuntu 时,Docker 实际上是加载了一个 Ubuntu 的 rootfs。 当你执行 docker run centos 时,Docker 加载的是一个 CentOS 的 rootfs。
  • 这样一来就很精简: Docker复用宿主机的内核,Docker 镜像里的 rootfs 可以非常小。

总而言之,大体上,Docker 容器 = 宿主机的 kernel (内存中运行的内核) + 容器自己的 Rootfs (镜像)
这也就解释了为什么能在ubuntu的宿主机里跑centos的容器,因为内核是共享的,容器内容的是centos的rootfs。


overlay2存储驱动机制

Docker主要用的是overlay2存储驱动。

加载过程通过 联合挂载将多个目录“合并”成一个虚拟的文件系统。

Overlay2 的层级视图
启动一个容器时,文件系统在内核层面被分为三类目录:

LowerDir (底层 - 只读):

  • 这里存放的是镜像层
  • 一个镜像可能由多个层组成(例如:基础系统层 + Python 环境层 + 代码层)。
  • 在 OverlayFS 中,这些层通过冒号分隔,全部作为 lowerdir 挂载。

UpperDir (上层 - 读写):

  • 这就是容器层。
  • 容器启动时,Docker 会在宿主机上创建一个空目录作为 UpperDir。
  • 所有对容器的修改、新增文件,实际都存储在这里。

MergedDir (合并层 - 用户视图):

  • 这是容器内部看到的目录(挂载点)。
  • 用户看到的完整文件系统,是 LowerDir 和 UpperDir 上下重叠后的结果。(很像图层合并对么?我也觉得),这个叠加是遵循的有规律的,尤其是The Law of Opacity之类的。详见www.kernel.org/doc/html/la…

image-1.png

整个过程大概就是下面这张图的过程: QQ_1765701639042.png


构建更高效的容器

为什么要优化镜像?

这是我在实际开发里遇到的大问题,也是促使我了解这些的原因,忽然发现啰啰嗦嗦半天都没说。\

QQ_1765610482950.png 喜欢我14GB大镜像缺乏合适的dockerfile构建一直重装pytorch吗(甚至是支持cuda驱动的)

谈谈我的反思

1.版本冲突:

  • openai-whisper 在 requirements.txt 里默认会依赖最新版 Torch,然而我的cosyvoice并不支持,引发了版本锁定(Pinning) 策略的冲突。
  • 解决方法:修改Dockerfile,强制全链路统一使用这个版本

2.Dockerfile的编写顺序:

  • COPY requirements.txt . 放在了 CosyVoice 那些巨型依赖安装之前。然后在 requirements.txt 里只要有任何一点改动,第一层 RUN 缓存失效 -> 重装 Whisper 和 Torch。
  • 解决方法:运用依赖分层(Dependency Layering)思想,可以用拆分法,把requirements.txt 拆成requirements-heavy.txt和requirements.txt俩。(但是更现代的python容器推荐用多阶段构建+virtualenv,或者用uv/poetry等工具导出明确的lock file)

3.没有使用多阶段构建:

  • 不仅导致镜像体积巨大,残留有大量编译器和缓存文件比如g++ 和 git等,会有安全隐患。生产环境不应该有编译器
  • 解决方法:多阶段构建,builder阶段造环境,runner阶段用环境。

因此,这些问题就是优化的出发点。


最佳实践

Best practices for writing Dockerfiles
docs.docker.com/build/build…
上面个文档讲的很全面,我主要做一个补充和细化。
下面的很详细的分析了一下到底是什么东西在拖慢docker构建速度,并且大概讲了一下优化手段,感兴趣可以看看。
zhuanlan.zhihu.com/p/666985389…

多阶段构建multi-stage builds

实现起来并不复杂。在同一个 Dockerfile 中使用多个 FROM 指令,每个 FROM 指令代表一个独立的构建阶段。因此,每个 FROM 指令都会清除之前的状态,开始一个新的构建阶段。在构建最终镜像的时候,之前所有阶段产生的中间镜像,源代码,编译器,缓存文件都会被丢弃,保证镜像的最小化。
实现多阶段构建只需要掌握两个关键点:

  • 命名阶段:在 FROM 指令后面加上 AS <别名>,给这一层起个名字(比如 builder)。
  • 跨阶段复制:在后面的阶段中,使用COPY --from=<别名>将前一个阶段生成的产物(比如编译好的二进制文件)复制过来。
    具体实现可以看docker docs,或者看yeasy.gitbook.io/docker_prac… 这个是中文版的,很简单的讲了代码实现。

Create reusable stages

适用于同一个项目需要发布不同版本,有多个镜像有共同的组件。符合don't repeat yourself原则。

  • 实现:创建一个有共同组建的基础镜像。例如as base ,后面就用from base即可。

选一个合理的基础镜像

Choose the right base image
基础镜像一旦构建,具体的镜像文件内容就不可变了,一个可能存在的误区是,镜像标签是可变的,发布者可以更新标签以指向新的镜像版本。

基础镜像的选择对最终镜像的体积、安全性和性能都有重大影响。
下面这个文章很全面地讲了基础镜像。
pythonspeed.com/articles/ba…

使用 .dockerignore文件!

类似于.gitignore文件,防止不必要的文件被COPY进镜像,减小镜像体积,加快构建速度。
那么哪些文件不需要copy进镜像呢,大概就是以下的内容

# --- Version Control ---
.git
.gitignore
.github

# --- Python & Venv ---
__pycache__
*.pyc
*.pyo
*.pyd
venv/
.venv/
env/
.pytest_cache/
.coverage
htmlcov/

# --- IDE & OS ---
.idea/
.vscode/
.DS_Store
Thumbs.db

# --- Secrets (CRITICAL) ---
.env*
*.pem
*.key
id_rsa*

# --- Project Specific (AI/Big Files) ---
# 忽略本地下载的大模型,防止构建上下文过大
# 除非您确实想把本地模型打进镜像(通常不建议)
*.pt
*.pth
*.safetensors
*.bin
models/
checkpoints/

# --- Logs & Temp ---
logs/
*.log
tmp/

# --- Docker ---
Dockerfile
docker-compose.yml
.dockerignore

我不会告诉你我的镜像里有多大模型文件的。。。

创建临时容器

它不是指这个容器寿命短,而是指一种架构,随时可被替换的容器,
遵循“十二要素应用 (The Twelve-factor App) ”方法论中的进程管理原则,以无状态方式运行容器。

如何实现?

A. 无状态设计 (Stateless)

如果容器被删除了,不能有任何数据丢失。

正确做法:
数据:存入数据库或对象存储。
文件:挂载 Docker Volume(卷),或者直接传到云存储。
Session:存入 Redis,而不是应用内存。
测试标准:随机杀掉一个正在运行的容器,用户会发现自己掉线了吗?数据会丢吗?如果答案是 NO,你就成功了。

这里又有一个新问题,怎么会容器被kill了用户没有掉线呢???

  • 答案是:无状态(Stateless)设计”的前提通常是基于**集群(Cluster)和负载均衡(Load Balancer)**的场景。涉及多副本 + 负载均衡 + 数据外置。我是猪我也不懂,跑题了不管。
B. 配置外置 (Config in Environment)

既然容器是临时的,就不能把配置写死在镜像里。

做法:使用 ENV 环境变量。
场景:同一个镜像,跑在测试环境连接测试库,跑在生产环境连接生产库。不用重构镜像,只需要在启动新容器时给它不同的环境变量。

C. 快速启动 (Fast Startup)

因为容器随时可能被杀掉并重启(比如自动扩缩容时),它必须能在几秒钟内进入工作状态。

反面教材:启动时需要花费 5 分钟去编译缓存、解压文件或预热数据。
做法:把初始化工作放在 Docker 构建阶段,而不是运行阶段。

D. 优雅停机

当 Docker 想要关闭容器时,会发送 SIGTERM 信号

临时容器的自我修养:接收到信号后,不应该立即嘎巴一下就shutdown,要
停止接收新的请求。
处理完当前手里正在处理的请求。
断开数据库连接。
自己退出。
目的:确保在容器替换的过程中,没有用户的请求被中断。

那么如何实现优雅停机呢?
labex.io/zh/tutorial…

PS.

  • 这个可以注意一下和临时沙盒的区别,因为我第一次看到这个以为和临时沙盒是一个东西。
  • 这里有一个陷阱:僵尸进程/信号屏蔽。如果应用没有显示注册信号处理函数,linux内核会默认应用进程忽略被默认的sigterm信号,导致发送停止命令后容器无动于衷,直到超时被强制kill掉。
  • 因此,要在docker run 时加上--init参数,或者在代码里注册信号处理函数。

解耦应用decouple applications

将应用拆分成多个小服务,每个服务运行在独立的容器中,通过网络通信docker容器网络协同工作。

  • 架构拆分,比如前后端分离,数据库独立容器,缓存独立容器等。
  • 编写docker-compose.yml文件,定义多个服务,并配置它们之间的依赖关系和网络连接。

容器内通信原理
zhuanlan.zhihu.com/p/364886965

对多行参数进行排序

其实就是遵循一套特定的dockerfile书写规范。
排序+多行:

换行,每个包占一行,用\结尾。
按字母顺序排序,从A-Z。
空格,\前面留一个空格。
(还有什么补充可以给我提)

小喵招:

vscode
选中那几行包名。
按下 Ctrl + Shift + P (Mac 是 Cmd + Shift + P)。
输入 Sort Lines Ascending (按升序排列行)。


利用构建缓存

Docker 构建缓存原理

Cache management with GitHub Actions
docs.docker.com/build/ci/gi…
重点是"Optimize the build cache"这部分。
snyk.io/blog/10-doc…

层级依赖性:如果第N层命中缓存,他会去尝试第N+1层,如果N+1层未命中,则从此层开始重新构建。

最小缓存原则,其实是指: “把变动最频繁的操作放到 Dockerfile 的最下面,把最稳定的操作放到最上面。”

Docker 构建镜像时,是从上往下一层层执行的。只要上面某一层的hash变了,它下面所有的层缓存都会失效,必须重新执行。

1.变动频率排序

为了最大化利用缓存,应该按照变动频率从低到高的顺序编排 Dockerfile 的各个指令。

变动频率:  操作系统 < 系统依赖(apt) < 项目依赖(requirements/lock) < 源代码

2.匹配机制:docker怎么判断变没变的?
  1. 普通指令 (RUN, ENV, EXPOSE 等)

    判断标准:字符串字面量 (String Literal)。

    逻辑:Docker 仅仅对比 Dockerfile 里的命令字符串是否和缓存里的一模一样。

可能存在的问题!

如果用 RUN apt-get update,即使软件源里有了新软件,只要Dockerfile 这行字没变,Docker 就认为没变,直接用缓存(导致你装不到最新的包)。

解决:通常使用 --no-cache 参数或者修改命令字符串来强制更新。

  1. 文件复制指令 (COPY, ADD)
    判断标准:文件内容校验和Checksum。

    逻辑:

    • Docker 不看命令字符串(因为 COPY . . 这个字符串永远不变)。
    • 它会扫描你源目录下的每一个文件,计算它们的 SHA256 哈希值。
    • 只要其中任意一个文件的内容变了一个字节,校验和就会变,缓存就会失效。
3 对比演示

❌ 错误写法(只要改一行代码,所有依赖都要重装):

FROM python:3.11-slim

WORKDIR /app

# 😱 致命错误:先把所有代码都拷进去了
COPY . .
# 只要你改了 main.py 的哪怕一个空格,这一层的 hash 就变了

# 😭 导致这一层缓存失效,每次都要重新下载安装巨包
RUN uv pip install -r requirements.txt

CMD ["python", "main.py"]

✅ 正确写法(改代码不影响依赖缓存):

FROM python:3.11-slim

WORKDIR /app

# 😎 聪明做法:先只拷“依赖描述文件”
# 因为 requirements.txt 几个星期才变一次
COPY requirements.txt .

# 😊 这层通常会命中缓存,瞬间完成
RUN uv pip install -r requirements.txt

# 🚀 最后再拷代码
# 只有这一层需要重建,速度极快
COPY . .

CMD ["python", "main.py"]

原理见前面的Docker 构建缓存原则部分,这里主要讲一下怎样充分利用构建缓存。

如何利用构建缓存呢

优化指令顺序

按照变动频率从低到高的顺序编排 Dockerfile 的各个指令。

合并指令

对于 apt-get 这样的包管理器,必须将 update 和 install 写在同一行。
为什么要这样做呢?

因为每一条 RUN 指令都会创建一个新的镜像层。如果把 update 和 install 分开写成两条 RUN 指令,那么 >update 会创建一个新层,install 又会创建另一个新层。这个时候你要修改加个新软件install层的话,>docker会服用update层(旧的软件源索引),install就找不到最新版。

buildkit

重构构建过程的引擎,救世主来的。不得不品味的工具。
docs.docker.com/build/build…

补充问题:

有些东西会默认使用latest标签,导致缓存失效,这个时候怎么办呢?
(有点类似于前面选择合理基础镜像最后的问题)
1.不是所有时候都需要这么严格的稳定性。

  • 构建成本高

  • 生产环境极其敏感

  • 团队协作规模大

  • 安全合规要求高
    基础镜像,python包,源码,服务都尽量锁定版本号。
    2.弃用latest标签,改用具体版本号标签(不过有时没有具体版本号,而且这个标签不一定可靠)
    3.使用Digest(摘要/哈希值)锁定它。

    • 使用@sha256:...语法,直接锁定镜像的唯一身份证 ID

4.自动化工具

  • 使用 Dependabot 或 Renovate 这类工具,定期检查基础镜像的更新,并自动生成 PR 来更新 Dockerfile 中的标签。

5.构建并托管自己的一个base镜像,包稳定的。

PS.对于 Python 依赖,除了死锁版本 (==) ,还有一种更温和的锁定方式:语义化版本控制 (Semantic Versioning)

  • 死锁 (Pinning): fastapi==0.95.0 (最稳,但比较僵化)
  • 兼容性锁定 (Compatible): fastapi~=0.95.0
  • 含义:允许安装 0.95.1, 0.95.2 (补丁版本),但拒绝 0.96.0 (次版本更新)。
  • 优点:重新构建镜像时,pip 会自动拉取最新的“安全补丁版”,但不会拉取可能破坏 API 的新功能版。

pay attention:
这要求库的作者严格遵守语义化版本规范。对于 PyTorch (torch) 这种底层库,建议还是死锁,因为小版本更新也可能导致 CUDA 不兼容。

锁定基础镜像版本

其实这个和上面默认latest标签问题解决方式很相似。
问题:镜像标签(Tag,如 3.21)是可变的,发布者可能会更新它指向新版本。这虽然方便更新,但可能导致破坏性变更,且缺乏审计记录。
解决方案:和上面消灭latest标签的四种方式一样.

但是依旧有一个问题,你锁定版本了之后,如果又有安全漏洞修复怎么办呢?
所以这里涉及一个稳定性和时效性的权衡,锁定哈希+漏洞扫描+自动机器人。

锁定哈希
CI/CD 流水线或镜像仓库中配置扫描工具,这个原理好复杂。。
自动机器人,配置 Renovate 或 Dependabot。机器人扫描到变化会给你提pr,然后CI 系统自动跑了一遍测试,确认没问题就合并发布新镜像。

其余参考资料:

国内的酷客博客容器系列
coolshell.cn/?s=docker基础…
docs.docker.com/engine/stor…
github.com/opencontain…