Missing Semester 计算机教育中缺失的一课 Lecture 06 Packaging and Shipping Code
前言
这个有点难懂了,之前的内容好歹能听懂,这个有点听不懂了……
基本概念
当你写好了一个项目的时候,此时打包到别人电脑上可能就跑不了了,因为环境不一样。这里,程序正常运行所需要的外部库被称为 ”依赖 Dependency“。而在自己电脑上可以运行的 python 程序,到了别人电脑上可能就会因为没有某个库而无法运行,这个就是缺失依赖。同样,一个依赖可能还要依赖别的东西,这个就是传递依赖。所以在发行时,同一个软件可能会有很多 "发行产物 artifact",需要找到和自己平台兼容的。
❯ pip show requests
Name: requests
Version: 2.34.2
Summary: Python HTTP for Humans.
Home-page:
Author:
Author-email: Kenneth Reitz <me@kennethreitz.org>
License: Apache-2.0
Location: /home/Bluuue/Desktop/deps-demo/.venv/lib/python3.14/site-packages
Requires: certifi, charset_normalizer, idna, urllib3
Required-by:
在 python 中,pip install xxx 安装某个库时,此时这个库也会有很多依赖。而由于每个软件都有好几个版本,不同版本之间会彼此不兼容,此时就会搜索是否存在可以兼容所有版本约束的软件安装。而若不存在,此时就会发生依赖冲突。
对于多个 python 项目,可能分别在自己兼容的版本上才能运行。此时就可以给这些项目开不同的环境,然后在其自己的环境中安装适配自己的版本,做到互不干扰。
❯ python -m venv .venv
❯ source .venv/bin/activate
❯ deactivate
❯ which python
/usr/bin/python
❯ source .venv/bin/activate
❯ which python
/home/Bluuue/Desktop/deps-demo/.venv/bin/python
此时就创建了一个 python 的环境,现在 install 就不会安装到系统 python 的 package 目录里,而是会安装到当前的虚拟环境里。在创建后,通常需要 activate 一下,将虚拟环境的 bin 目录加载到 $PATH 里,否则运行的还是系统 python。想退出的话,直接 deactivate 即可。
import typer
def greet(name: str) -> None:
print(f"Hello, {name}!")
def cli() -> None:
typer.run(greet)
if __name__ == "__main__":
cli()
[project]
name = "greeting"
version = "0.1.0"
description = "A simple greeting library"
dependencies = ["typer>=0.9"]
[project.scripts]
greet = "greeting:cli"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
❯ vim greeting.py
❯ vim pyproject.toml
❯ uv build
Building source distribution...
...
Successfully built dist/greeting-0.1.0.tar.gz
Successfully built dist/greeting-0.1.0-py3-none-any.whl
❯ ls dist
greeting-0.1.0-py3-none-any.whl greeting-0.1.0.tar.gz
❯ unzip -l dist/*.whl
Archive: dist/greeting-0.1.0-py3-none-any.whl
Length Date Time Name
--------- ---------- ----- ----
152 2026-09-14 13:52 greeting.py
113 2026-09-14 13:53 greeting-0.1.0.dist-info/METADATA
91 2026-09-14 13:53 greeting-0.1.0.dist-info/WHEEL
39 2026-09-14 13:53 greeting-0.1.0.dist-info/entry_points.txt
9 2026-09-14 13:53 greeting-0.1.0.dist-info/top_level.txt
463 2026-09-14 13:53 greeting-0.1.0.dist-info/RECORD
--------- -------
867 6 files
在日常工作中,就会遇到在当前工作目录下 python -c "from greeting import greet; greet('World')" 正常输出,但换个工作目录就不行了。这个就是因为当前工作目录缺失了相关文件,所以就需要在打包时,将代码按照约定安装到指定位置,并且附带上需要的数据。
在这里,python 项目通常使用 pyproject.toml 文件作为项目的说明书,写明版本、依赖和构建方法等。除此之外,.whl wheel 可以将项目整理成可以直接安装的 python package,unzip 后可以看到里面的内容。此时就可以直接 uv pip install ./greeting-0.1.0-py3-none-any.whl,安装后直接运行了。而 .tar.gz 文件就是 sdist,即 source distribution,里面包含源代码和 .toml 等。用户可以自己解压之后,在自己的电脑上编译运行。
需要注意的是,如果要直接把编译好的可执行二进制文件作为 artifact 发布,需要对方电脑的操作系统、CPU 架构等很多环境符合条件才行。
这里,uv 是一个现代 Python package manager。uv build 命令可以读取当前目录的 python 项目和 .toml 说明书,然后构建 artifact 并放到 dist 里。
发行版本
在开发中,有开发环境 development environment 和生产环境 production environment。其中开发环境就是写代码的环境,而生产环境就是给用户使用的环境了。那么由于代码是不断开发的,不可能每 commit 一次都拿到生产环境给用户更新一遍,此时就需要在某几个测试好的 commit 作为一个版本发行 release 出去。
在一般的版本命名下,通常使用 xxx.xxx.xxx 来命名。对于版本 1.2.3 来说,若原来有一个函数 query(int a),现在修复了几个内部的 bug,此时用户仍然可以正常调用函数,那么就升级为 1.2.4。而如果添加了一个函数 calc(int b),此时用户还可以正常使用 query,那么就升级为 1.3.0。如果是改成了 query(vector<int>a),此时原来的用法就完全失效了,版本整个升级为 2.0.0。除此之外,也可以用发布的日期来规定版本。
❯ uv lock
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.14`.
Resolved 9 packages in 2.98s
❯ ls
dist greeting.egg-info __pycache__ greeting.py pyproject.toml uv.lock
❯ cat uv.lock | head -10
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "annotated-doc"
version = "0.0.5"
...
当然,.toml 里可以说明接受的版本范围。但为了保险,也可以发行者确定一个没问题的版本,然后 lock file 保存这个版本,这样用户在安装时就可以使用这个没问题的版本了。
对于 Library 和 Application,版本约束也不太一样。由于 Library 是希望尽可能多的项目都能用,所以约束时更重视兼容性 Compatibility。而 Application 使用时更在乎能否像测试时一样正常跑起来,所以约束时更重视可复现性 Reproducibility。
虽然 lock file 可以固定 dependency graph,但 compiler、system library、build environment 仍然可能不同。此时,Nix/Bazel 等工具可以进一步密封构建 hermetic build,使构建输入更加确定。 而且,实际项目也不可能永远锁死旧版本,因此通常使用持续集成 CI,即 Continuous Integration 测试新的依赖版本, 并利用 Dependabot 等工具发现升级。如果新版本部署后出现问题, 需要能够 rollback 到之前验证过的没问题的 artifact。
容器
上述的隔离方法只能隔离版本依赖或 package 依赖,但现实中仍然还有很多系统依赖。此时一个很自然的想法就是虚拟机,但由于虚拟机的比较 “重”,所以就需要用到容器 Container 了。容器不会像虚拟机一样产生一个完全隔离的系统环境,多个容器之间还是共享宿主机 host 的 os 内核的。
❯ docker run --rm -it python:3.12 python
Python 3.12.14 (main, Aug 25 2026, 03:41:05) [GCC 14.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 1+2
3
>>> print("hello")
hello
>>> import sys
>>> print(sys.version)
3.12.14 (main, Aug 25 2026, 03:41:05) [GCC 14.2.0]
>>> quit()
❯ docker image ls
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
python:3.12 cd7c412d0009 1.61GB 429MB
❯ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
这里,docker run --rm -it python:3.12 python 表示从 python:3.12 这个镜像 image 创建并启动一个容器。这里镜像可以理解为一个模板,这个容器是由这个模板创造出的一个实例。参数 --rm 表示这个容器停止后自动删除,后面的 python 表示运行 python。
FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN echo "Installing dependencies..." && sleep 3
COPY app.py .
CMD ["python", "app.py"]
❯ touch requirements.txt
❯ vim app.py
❯ vim Dockerfile
❯ docker build -t cache-demo .
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
Install the buildx component to build images with BuildKit:
https://docs.docker.com/go/buildx/
Sending build context to Docker daemon 3.584kB
Step 1/6 : FROM python:3.12
---> cd7c412d0009
Step 2/6 : WORKDIR /app
---> Using cache
---> 7da4a1e2c1f5
Step 3/6 : COPY requirements.txt .
---> 966d57bd98bd
Step 4/6 : RUN echo "Installing dependencies..." && sleep 3
---> Running in 3a75631203fc
Installing dependencies...
---> Removed intermediate container 3a75631203fc
---> 9e9814fd623a
Step 5/6 : COPY app.py .
---> be23dde4e868
Step 6/6 : CMD ["python", "app.py"]
---> Running in 05ca2f27eb9c
---> Removed intermediate container 05ca2f27eb9c
---> 1c7f2f764fa6
Successfully built 1c7f2f764fa6
Successfully tagged cache-demo:latest
而对于 image,docker 使用 Dockerfile 来说明这个 image 如何构建。这里,FROM 表示从已有的这个 image 开始构建而非从零开始,RUN 就是执行这个命令,WORKDIR 就是 cd,WORKDIR /app 就是将 image 文件系统中的 /app 设置为后续的工作目录。这里 docker build . 会把当前目录下的 Dockerfile 作为说明,-t 参数是给这个 image 命名。
需要注意的是,Dockerfile 在执行时也受 cache 影响。而由于其命令可以看作一个栈,所以若当前命令需要的文件发生了改变,此时之后 cache 内的东西就不能用了,连带着后面的命令都需要重新读取。所以在写 Dockerfile 时,最好把频繁改动的命令放在后面。
FROM gcc:15 AS builder
WORKDIR /src
COPY main.cpp .
RUN g++ -O2 main.cpp -o hello
FROM debian:trixie-slim
COPY --from=builder /src/hello /usr/local/bin/hello
CMD ["hello"]
❯ vim main.cpp
❯ vim Dockerfile
❯ docker build -t cpp-multistage .
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
Install the buildx component to build images with BuildKit:
https://docs.docker.com/go/buildx/
Sending build context to Docker daemon 3.072kB
Step 1/7 : FROM gcc:15 AS builder
15: Pulling from library/gcc
2066995e8f8e: Pulling fs layer
d20b2e6b65c3: Pulling fs layer
9d2442783d46: Pulling fs layer
Digest: sha256:fb2568a8cc0609134396aeb1eae1f3a91c8ceec54aaee67c798d9602593abd66
Status: Downloaded newer image for gcc:15
---> fb2568a8cc06
Step 2/7 : WORKDIR /src
---> Running in 4deecc2c232a
---> Removed intermediate container 4deecc2c232a
---> 3e5010df5198
Step 3/7 : COPY main.cpp .
---> 6d7bd0495c02
Step 4/7 : RUN g++ -O2 main.cpp -o hello
---> Running in d4f9fdb87b68
---> Removed intermediate container d4f9fdb87b68
---> 68883acf79f3
Step 5/7 : FROM debian:trixie-slim
trixie-slim: Pulling from library/debian
6310eb16bf42: Pulling fs layer
1669196c58f8: Download complete
6310eb16bf42: Download complete
6310eb16bf42: Pull complete
Digest: sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132
Status: Downloaded newer image for debian:trixie-slim
---> d7e12182ce18
Step 6/7 : COPY --from=builder /src/hello /usr/local/bin/hello
---> 5bcabf4ad1f1
Step 7/7 : CMD ["hello"]
---> Running in e9f700408f1f
---> Removed intermediate container e9f700408f1f
---> 5ff64346074d
Successfully built 5ff64346074d
Successfully tagged cpp-multistage:latest
❯ docker run --rm cpp-multistage
Hello from C++ container!
如果想要连构建环境也写到 Dockerfile 里,让任何机器都使用同一套构建方式在生成可执行文件,此时就可能需要在 Dockerfile 里引入别的 image 来进行编译。而对于可执行文件又不需要编译时的东西,此时就可以将构建分为 build stage 和 runtime stage,不把那么多运行不需要的东西放进最终的 image 里。
配置和发布
除此之外,对于代码中需要根据环境调整的配置,也应该从代码中分离出去。此时可以通过环境变量和配置文件处理,注意不要把需要保密的信息 push 出去。
当然,对于一个大型项目,把所有东西都放到一个容器里会显得很臃肿。此时就可以配置好 compose.yaml 文件,说明每个容器如何构建以及和别的容器的联系方式,然后使用 docker compose up 就可以通过 docker 的网络连接多个容器。除此之外,还可以使用 Kubernetes 来管理多个机器中的大规模的容器。
之后对于发布,当然可以给别人一个 URL,让他 curl 进行下载。除此之外,也可以在 git push 到 github 上之后,可以创建 github release 并附加上 .tar.gz 等文件,说明好环境即可。当然,也可以像 pip 和 pacman 一样通过 package manager 直接完成下载安装,比到 github 里搜索再安装更方便。在发布时,还需要通过 checksum 来确认文件是否被改变,以及 signature 来确定这个文件是否由可信发布者签署,进一步保证安全性。
练习
Write a Dockerfile for a simple Python application. Then write a docker-compose.yml that runs your application alongside a Redis cache.
#include <cstdlib>
#include <iostream>
#include <string>
#include <thread>
#include <chrono>
#include <hiredis/hiredis.h>
int main() {
const char* env_host = std::getenv("REDIS_HOST");
std::string host = env_host ? env_host : "cache";
constexpr int port = 6379;
std::cout << "Connecting to Redis at "
<< host << ":" << port << '\n';
redisContext* context = redisConnect(host.c_str(), port);
if (context == nullptr) {
std::cerr << "Failed to create Redis connection\n";
return 1;
}
if (context->err) {
std::cerr << "Redis connection error: "
<< context->errstr << '\n';
redisFree(context);
return 1;
}
while (true) {
redisReply* reply =
static_cast<redisReply*>(
redisCommand(context, "INCR visits")
);
if (reply == nullptr) {
std::cerr << "Redis command failed\n";
break;
}
if (reply->type == REDIS_REPLY_INTEGER) {
std::cout << "visits = "
<< reply->integer << '\n';
}
freeReplyObject(reply);
std::this_thread::sleep_for(
std::chrono::seconds(2)
);
}
redisFree(context);
return 0;
}
这个实验文件感觉没啥好说的,就是调用一些 api,gpt 给出来以后零基础看也能看懂在干嘛()
# ---------- Build stage ----------
FROM debian:trixie-slim AS builder
RUN apt-get update && \
apt-get install -y --no-install-recommends \
g++ \
libhiredis-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY main.cpp .
RUN g++ -std=c++23 -O2 -pthread main.cpp -lhiredis -o app
# ---------- Runtime stage ----------
FROM debian:trixie-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libhiredis1.1.0 \
libstdc++6 && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/app /usr/local/bin/app
CMD ["app"]
❯ docker build -t cpp-redis-app .
...
Successfully built e01006abca5b
Successfully tagged cpp-redis-app:latest
此时就可以展示出 multi-stage 的用法了,在引入编译所需的 image 编译后,只需要将运行所需的文件放入最后的 image 即可。
services:
app:
build:
context: .
network: host
environment:
REDIS_HOST: cache
depends_on:
- cache
cache:
image: redis:7-alpine
❯ docker image ls
i Info → U In Use
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
cache-demo:latest 1c7f2f764fa6 1.6GB 411MB
cpp-multistage:latest 5ff64346074d 116MB 29.8MB
cpp-redis-app:latest e01006abca5b 117MB 29.9MB
debian:trixie-slim d7e12182ce18 118MB 31.8MB U
gcc:15 fb2568a8cc06 2.19GB 569MB
python:3.12 cd7c412d0009 1.61GB 429MB
❯ docker compose up --build
[+] up 10/10
✔ Image redis:7-alpine Pulled 111.3s
WARN[0111] buildx Docker CLI plugin not found: falling back to the classic buil[+] up 10/11t-only build features (multi-arch, secrets, ssh, additional context ✔ Image redis:7-alpine Pulled 111.3s
⠋ Image deps-demo-app Building 0.0s
unable to prepare context: unable to evaluate symlinks in Dockerfile path: lstat /home/Bluuue/Desktop/deps-demo/Dockerfile: no such file or directory
❯ docker compose up --build
...
app-1 | Connecting to Redis at cache:6379
app-1 | visits = 161
app-1 | visits = 162
app-1 | visits = 163
app-1 | visits = 164
app-1 | visits = 165
❯ docker compose down
[+] down 3/3
✔ Container cpp_redis_demo-app-1 Removed 10.3s
✔ Container cpp_redis_demo-cache-1 Removed 0.3s
✔ Network cpp_redis_demo_default Removed 0.4s
这里,由于说明了容器运行时的 REDIS_HOST 环境为 cache,所以之前代码中的 env_host 得到的就是 cache。之后就是声明 cache 这个容器,利用的是现成的 image。 之后就是 docker compose up --build 启动,最后 docker compose down 关闭。
这里,因为 cache 和 app 是两个独立的容器,所以若之前没有 compose down 关闭过容器,只重建 app 时 cache 的计数不会归零。
总结
这些东西有点深奥了……

Lecture 06 Packaging and Shipping Code
浙公网安备 33010602011771号