环境就是四样东西: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 秒实验
- 先跑
docker --version,再跑docker ps,把两个返回码记下来——它们不一样。 - 到 site-packages 里数一下有多少个包,猜猜你的镜像会有多大。
- 想一想:为什么 requirements.txt 要先 COPY,代码后 COPY?反过来会怎样?
常见误区
- 「在我机器上能跑」当成能交付:线上那台机器四样里没一样是你定的。
- 把容器当轻量虚拟机:以为重启以后文件还在,结果没了。
- 先 COPY 代码再 pip install:改一行代码,重装全部依赖。
- 依赖不钉版本:某天构建突然失败,代码一行没改。
- docker --version 通了就以为能用:它只验了客户端那一半。
自测三问(点开对答案)
1. 环境是哪四样东西? —— Python 版本、操作系统、包目录、装了哪些包。 2. 容器和虚拟机差的到底是什么? —— 虚拟机隔离整台机器含内核,容器只隔离进程、共享宿主内核;轻和弱是同一件事的两面。 3. 镜像分层的顺序为什么重要? —— 层是只读的,改了哪层之后全部重建;依赖先装就能一直复用缓存。打包这一层想清楚了,下一篇讲 Cloud Run 和它对代码提的那几条硬要求。
- ☐ (本篇完,下一篇 15.2 Cloud Run 与 GCP 准备)