🧪 第15章 部署物件侦测应用
📄 一句话概括

环境就是四样东西:Python 版本、操作系统、包目录、装了哪些包。 容器不是轻量虚拟机,是打包好的进程——共享内核,隔离弱,起得快。 镜像一层一层摞,改了哪层之后全重建,所以依赖要写在代码前面。

📝 本章和前面几章的关系

这一篇对应教材 15.1 与 15.1.1:Docker 的概要、虚拟化技术。 第 14 章把 API 做完了,但它只跑在自己电脑上。 这一章解决「搬走」这件事,先讲清 Docker 到底在打包什么。

一、环境是四样东西

实测这台机器:Python 3.13.14、平台 win32、第三方包目录下 32475 个文件共 1132.7 MB、装了 153 个包。

要素 实测 换一台机器会怎样
Python 版本 3.13.14 差一个次版本,语法或扩展就可能编不过
操作系统 win32 路径分隔符、系统库、换行符全不一样
包目录 site-packages 装在哪、有几个 python,决定 import 到哪个
装了哪些包 153 个 少一个就 ImportError,多一个可能改了行为

这四行就是「环境」的全部内容。「在我机器上能跑」这句话的意思,就是这四样都对得上。 换一台机器,任何一样不同,代码就可能跑不起来。而线上那台机器,这四样里没有一样是你定的。 容器干的事,说到底就是把这四样打包成一份,到哪都一样。 注意它打包的不只是「依赖」,还包括 Python 解释器本身和操作系统那一层。

二、虚拟化技术:一台机器还是一个进程

教材 15.1.1 讲虚拟化技术。 虚拟机与容器的差别,一句话说得完:虚拟机隔离的是整台机器,容器隔离的是进程。

维度 虚拟机 容器
隔离的是什么 整台机器(含内核) 进程(共享宿主内核)
启动 要引导一个操作系统 起一个进程
体积 几个 GB 起 几十 MB 起
密度 一台宿主机跑十几个 几百个
隔离强度 强 弱一些

共享内核是「轻」的全部来源,也是「弱」的全部来源。 内核只有一个,容器里的进程真跑出去了,受影响的是整台宿主机。 便宜和强隔离是一对取舍,没有哪边绝对好:多租户、跑别人的代码,用虚拟机; 自己的服务、要密度和速度,用容器。 还有一个 Windows 上特有的层次问题:Docker Desktop 先起一台 Linux 虚拟机, 所有容器共享的是那台虚拟机的内核。所以在 Windows 上,虚拟机与容器是叠着用的,不是二选一。

三、它不是轻量虚拟机

这一条分清,后面很多现象就顺了。三个最常被问到的现象:

现象 原因
容器里改了文件,重启就没了 容器只是进程加一层可写层,进程没了那层也就没了
容器里没有 systemd 它不起整个系统,只起你指定的那一个进程
容器的 1 号进程是你的启动命令 没有 init 帮忙收尸,信号直接打给你的进程

所以要留住文件得挂卷;要管多进程得自己管——gunicorn 就是干这个的。 最后一条有个实际后果:容器停的时候,平台发给 1 号进程一个 SIGTERM,等一会儿再 SIGKILL。 你的进程不处理 SIGTERM,就只能等超时被强杀,手上没写完的请求全部断掉。 gunicorn 默认处理了这件事。

四、镜像是一层一层摞的

FROM python:3.13-slim                  # 第 1 层:基础镜像
WORKDIR /app
COPY requirements.txt .                # 第 2 层
RUN pip install -r requirements.txt    # 第 3 层
COPY . .                               # 第 4 层
CMD ["gunicorn", "-b", ":8080", "wsgi:app"]

每一行产生一层。层是只读的,改了哪一层,摞在它上面的层全部要重建。

顺序 改一行代码要付什么代价
依赖先、代码后(本课) 前三层走缓存,只重建后两层
代码先、依赖后 每次都要重装全部依赖

本课那个 API 依赖很少,重装几十秒。换成要装 torch 的项目,一次十几分钟。 顺序错了,就是每天都在浪费这十几分钟。 缓存是按层的内容算的:COPY requirements.txt . 这一层的输入没变,就直接复用。 所以「先拷清单再装」不只是好看,它是让缓存命中的唯一办法。

五、把依赖钉死

flask==3.1.3
werkzeug==3.1.9
jinja2==3.1.6
opencv-python-headless==4.10.0.84
numpy==2.5.2
pillow==12.3.0
gunicorn==23.0.0

七行,每行一个等号钉死版本。 不钉版本的后果是「今天构建成功,明天构建失败」,而且你一行代码都没改。 opencv-python 换成了 opencv-python-headless。 后者不带 GUI 库、不需要显示器,体积小不少,服务器上正好用得上。 普通版在无显示器的容器里也能跑,但它会拖进一堆 X11 相关的库,白白让镜像大几十兆。 这类「我根本用不到但它是硬依赖」的包,是镜像臃肿的主因。

具体镜像多少兆这里算不出来——构建没跑成,daemon 没起。 能算的只有本机 site-packages 那 1132.7 MB,而它不等于镜像大小:基础镜像、系统库另算, 而且这里面 153 个包里这个 API 只用 7 个。

六、装了不等于能用

命令 实测 说明
docker --version 29.9.2,返回码 0 客户端在
gcloud --version 命令不存在 PATH 里找不到
docker ps 返回码 1 failed to connect to the docker API

docker --version 成功不代表 Docker 能用,它只证明客户端命令行存在。 Docker 是客户端加服务端两半,这条命令只问了客户端。 那个报错逐段拆:npipe:////./pipe/dockerDesktopLinuxEngine 是 Windows 上的命名管道, 相当于 Linux 的 socket 文件,客户端和引擎就靠它通信; dockerDesktopLinuxEngine 是 Docker Desktop 里那台 Linux 虚拟机引擎;它没起来,管道就不存在。 结论:命令行工具和服务是两样东西。 装完 Docker Desktop 要启动它,等托盘图标变绿,docker info 才有输出。 这个坑第一次装的人几乎都会踩。

下一篇正好讲装与启动, 这里先把这个信号记下——看到「连不上 daemon」,方向是「服务没起」,不是「命令写错了」。

七、动手 Demo:Docker:装了、跑不起来、以及那个报错在说什么

这个 Demo 先看清这台机器的环境,再对比虚拟机与容器,最后把三条命令跑一遍。 第二节那个报错是这一篇最值钱的一屏:它说的不是 Dockerfile 有问题,是服务没起。

flask==3.1.3
werkzeug==3.1.9
jinja2==3.1.6
opencv-python-headless==4.10.0.84
numpy==2.5.2
pillow==12.3.0
gunicorn==23.0.0

实跑输出:

--------------------------------------------------------------------
Docker:装了、跑不起来、以及那个报错在说什么
--------------------------------------------------------------------

  ① 三条命令,三种结果
  --------------------------------------------------------------------
  docker   Docker version 29.9.2, build a82e528                   返回码 0
  gcloud   命令不存在                                                  PATH 里找不到
  python   Python 3.13.14                                         返回码 0
  ! Docker 装了,版本 29.9.2。gcloud 没装。Python 有。
  ! 这一行输出值得看一眼:docker --version 成功不代表 Docker 能用。它只证明客户端命令行存在。

  ② 客户端在,服务器没起
  --------------------------------------------------------------------
  docker ps -> 返回码 1
  failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine; check
  ! 这个错误是第 15 章最有价值的一屏。逐段拆:
  ! cannot find the file specified 指的是那个 npipe——Windows 上的命名管道,
  相当于 Linux 的 socket 文件。Docker 客户端和引擎之间就是靠它通信的。
  ! dockerDesktopLinuxEngine 是 Docker Desktop 里的 Linux 虚拟机引擎。
  它没起来,管道就不存在,客户端就连不上。
  ! 结论:命令行工具和服务是两样东西。
  装完 Docker Desktop 要启动它,等托盘图标变绿,docker info 才有输出。
  这个坑第一次装的人几乎都会踩。

  ③ 容器里到底有什么
  --------------------------------------------------------------------
  Python -> 3.13.14
  平台 -> win32
  解释器路径 -> ~\.workbuddy\binaries\python\envs\default\Scripts\python.exe
  第三方包目录 -> ~\.workbuddy\binaries\python\envs\default\Lib\site-packages
  里面 -> 32475 个文件,1132.7 MB
  已装的包 -> 153 个
    alembic==1.20.1

    anyio==4.15.1

    asttokens==3.0.2

    attrs==26.1.0

    av==18.1.0

    bitarray==3.11.0

    ...(还有 147 个)
  ! 这四行就是「环境」的全部内容:Python 版本、操作系统、包目录、装了什么包。
  ! 「在我机器上能跑」这句话的意思就是这四样都对得上。
  换一台机器,任何一样不同,代码就可能跑不起来。
  ! 容器干的事,说到底就是把这四样打包成一份,到哪都一样。

  ④ 虚拟机与容器
  --------------------------------------------------------------------

| 维度 | 虚拟机 | 容器 |
|---|---|---|
| 隔离的是什么 | 整台机器(含内核) | 进程(共享宿主内核) |
| 启动 | 要引导一个操作系统 | 起一个进程 |
| 体积 | 几个 GB 起 | 几十 MB 起 |
| 密度 | 一台宿主机跑十几个 | 几百个 |
| 隔离强度 | 强 | 弱一些 |

  ! 容器不是「轻量虚拟机」,它是「打包好的进程」。这一条分清了,后面很多现象就顺了。
  ! 比如「容器里改了文件,重启就没了」——因为容器只是进程加一层可写层,进程没了那层也就没了。
  真要留住得挂卷。
  ! 比如「容器里没有 systemd」——因为它不起整个系统,只起你那一个进程。
  所以要自己管进程、自己处理信号。

  ⑤ 镜像是一层一层摞的
  --------------------------------------------------------------------

  [原样]
  | FROM python:3.13-slim          # 第 1 层:基础镜像
  | WORKDIR /app
  | COPY requirements.txt .        # 第 2 层
  | RUN pip install -r requirements.txt   # 第 3 层
  | COPY . .                       # 第 4 层
  | CMD ["gunicorn", "-b", ":8080", "wsgi:app"]
  每一行产生一层。层是只读的,改了哪层,下面的层全部要重建。

  ! 所以顺序有讲究:requirements.txt 先拷、先装,代码后拷。
  这样改代码的时候,前三层还能用缓存,只重建最后两层。
  ! 反过来(先 COPY . 再 pip install)的话,改一行代码就要重装所有依赖。
  这是 Dockerfile 里最常见的一处性能问题。
  ! 本课那个 API 的依赖很少,重装也就几十秒;换成装 torch 的项目,一次重装可能是十几分钟。
  顺序错了每天都在浪费这十几分钟。

  ⑥ 镜像会多大
  --------------------------------------------------------------------
  本机 site-packages -> 1132.7 MB
  这个 API 的 requirements.txt -> 7 行
    flask==3.1.3
    werkzeug==3.1.9
    jinja2==3.1.6
    opencv-python-headless==4.10.0.84
    numpy==2.5.2
    pillow==12.3.0
    gunicorn==23.0.0
  ! 注意 opencv-python 换成了 opencv-python-headless。
  后者不带 GUI 库(不需要显示器),体积小不少,服务器上正好用得上。
  ! 装 opencv-python 在无显示器的容器里也能跑,但它会拖进一堆 X11 相关的库,白白让镜像大几十兆。
  ! 具体镜像多少兆这里算不出来——构建没跑成,daemon 没起。
  能算的只有本机 site-packages 那 1132.7 MB,它不等于镜像大小(基础镜像、系统库另算)。

第三节 32475 个文件、1132.7 MB、153 个包,就是「环境」这两个字的分量。 第六节那七行和上面 1132.7 MB 不是一回事:前者是这一份要装的,后者是这台机器全部装的。 第二节报错里出现了三个东西:npipe 那个路径、dockerDesktopLinuxEngine、返回码 1。 看清这三个,就知道该去启动谁。

30 秒实验

  1. 先跑 docker --version,再跑 docker ps,把两个返回码记下来——它们不一样。
  2. 到 site-packages 里数一下有多少个包,猜猜你的镜像会有多大。
  3. 想一想:为什么 requirements.txt 要先 COPY,代码后 COPY?反过来会怎样?

常见误区

  1. 「在我机器上能跑」当成能交付:线上那台机器四样里没一样是你定的。
  2. 把容器当轻量虚拟机:以为重启以后文件还在,结果没了。
  3. 先 COPY 代码再 pip install:改一行代码,重装全部依赖。
  4. 依赖不钉版本:某天构建突然失败,代码一行没改。
  5. docker --version 通了就以为能用:它只验了客户端那一半。
自测三问(点开对答案) 1. 环境是哪四样东西? —— Python 版本、操作系统、包目录、装了哪些包。 2. 容器和虚拟机差的到底是什么? —— 虚拟机隔离整台机器含内核,容器只隔离进程、共享宿主内核;轻和弱是同一件事的两面。 3. 镜像分层的顺序为什么重要? —— 层是只读的,改了哪层之后全部重建;依赖先装就能一直复用缓存。

打包这一层想清楚了,下一篇讲 Cloud Run 和它对代码提的那几条硬要求。

相关笔记