准备
先把源码放进确定的工作目录
这次部署的是一个访问计数器:访问首页返回问候语和计数,刷新就加一。数据库用 SQLite,它把数据存在一个文件里,直接嵌入应用进程,所以不用再安装数据库服务。
Git 负责拿到源码;Docker 把应用环境做成镜像;Compose 读取一份服务清单,把容器、网络和数据卷一起建好。它们分别解决“拿什么代码”“用什么环境”“这些部件怎么接起来”。回看Compose 的结构,不需要记住全部选项。
后续服务器命令都在这个目录执行。你应看到下面这些文件;若 cd 失败,先检查 clone 是否成功、目录是否已存在,以及仓库分支。PR 合并前审阅示例,在仓库中先执行 git switch --track origin/case/issue-63。
project-deploy/
├── app.py
├── Dockerfile
├── compose.yaml
├── .env.example
├── Caddyfile
└── README.md
app.py → Dockerfile → compose.yaml
程序 → 运行环境的制作方法 → 服务连接关系。
.env.example → .env
模板进入 Git,机器上的实际配置留在机器上。
app_data → /data/visits.sqlite3
Compose 创建的命名卷保存数据库,不在源码目录里。
可以先读示例说明,或查看 app.py、Dockerfile 和 完整 compose.yaml。源码目录与当前工作目录的区别,回看第 2 章。
重新部署之前,把这些东西放回它所属的位置。
- app.py
- compose.yaml
- 实际使用的 .env
- visits.sqlite3
Git 能恢复源码,不能恢复没有备份的计数。把运行配置与数据分开,才能在重建应用时保留这台机器自己的环境。
配置
.env 的值要显式传给容器里的进程
先复制配置模板,在编辑器里把 APP_MESSAGE 改成你自己的问候语。保留外层单引号,可以把空格和 $ 作为普通字符写进值里;DOMAIN=localhost 先保留,到 HTTPS 阶段再改。
config --quiet 成功时不打印展开的配置;报错就先修正文件与变量。模板中 APP_MESSAGE='Hello from Compose' 没有密码,但真实项目可能有;本例的 .gitignore 忽略 .env,.dockerignore 让构建只接收应用文件。
Compose 把 ${APP_MESSAGE:?...} 替换成实际值,再通过 environment 传给 app。只在 .env 写一个新变量,容器不会自动收到它。
services:
app:
environment:
APP_MESSAGE: ${APP_MESSAGE:?请配置问候语}这是完整配置的节选。:? 让缺少值的部署在启动前报错。
深入一点:为什么文件改了,值却没变?
Shell 中已导出的同名变量会优先用于 Compose 插值。先运行 printenv APP_MESSAGE 检查;如果它覆盖了文件值,在当前 shell 执行 unset APP_MESSAGE 后重新运行 Compose。排查凭据时不要把变量值或完整配置输出贴进公开日志。
进程启动后不会持续读取 .env。修改后重新执行本页的 up -d --build --wait,让 Compose 按配置重建;单独 restart 仍使用旧容器的环境。
假设值已正确注入,修改 .env 后还需要更新什么?
要让新值生效,需要让 Compose 重新创建 ;已有的 app_data 卷可以保留。
配置随容器创建交给进程。容器可以替换,而数据库仍挂在同一个命名卷上;配置变化和数据保留并不矛盾。
运行
先证明本机入口和数据库都能用
docker compose up -d --build --wait 构建并在后台启动应用,再等待健康检查通过。健康检查是定期访问 /healthz 的探测;本例会读取数据库,但不会增加访问计数,所以它比“进程还在”多验证了一层。
- 先解析服务清单和本机变量,缺少问候语时在这里报错。
- 用 Dockerfile 构建镜像:带上 Python 和 app.py,不把 .env 烘焙进去。
- 创建容器,将 APP_MESSAGE 交给进程。猜猜:计数要放哪里,重建容器后才还在?
- 将 app_data 挂到 /data。应用启动时打开这里的 SQLite 文件,已有计数就继续使用。
- 应用在容器的 0.0.0.0:8000 监听,发布到服务器回环地址的 8080。
- 健康检查读取数据库;通过后,再从服务器的入口访问首页,验证整条本机路径。
本页应用已在 Debian 13 托管环境用 Python 3.12.16 实测,Compose 配置已解析验证。环境没有 Docker Engine,以下 Ubuntu 容器运行、卷权限和公网签证流程未实测;建议在练习虚拟机或云服务器里试。
ps 应显示 app 已运行且健康。curl 应返回健康状态,首页返回你设定的 message 与递增的 visits;如果启动命令超时,保留现场,按下一节看状态和日志。
这是应用直接运行的真实响应体,不代表容器启动日志。反复访问、并发请求以及使用同一 SQLite 文件重启后的计数延续也已验证。
用浏览器验证时,先分清是哪台机器的 localhost
8080 只绑定服务器的 127.0.0.1,所以暂时不能用公网 IP 打开。要在自己电脑的浏览器验证,在自己电脑另开一个终端建立 SSH 端口转发;把账号和 IP 换成上一页的值。
然后打开 http://127.0.0.1:18080/,你应看到 JSON 格式的问候语和计数。用完在转发终端按 Ctrl+C;应用仍在云端后台运行。
服务器 curl 能返回计数,自己的电脑访问“服务器公网 IP:8080”却失败。最符合本例结构的解释是什么?
数据库由应用初始化,curl 已能读到计数,说明这条路径可用。
SQLite 没有独立监听端口,浏览器也不直接访问数据库。
绑定地址决定入口能从哪里访问。本例把诊断入口留在服务器本机,公网请求稍后统一从 Caddy 的 443 进入;不需要开放 8080。
排障
找出第一个不通的边界,再改配置
先用本机 curl 划分故障范围:本机都失败,先看 app;本机成功而公网失败,再看代理、域名和入口。反向代理(reverse proxy)代表服务接收请求,再把它转给内部应用。Caddy 是本页使用的代理。
服务器 Shell 中的访问
Caddy 容器中的转发
| 你观察到什么 | 先找哪条线索 | 下一步验证 |
|---|---|---|
| clone 报网络或认证错误 | 仓库地址、服务器出站网络、私有仓库的 Git 凭据 | 确认 clone 成功才 cd;本示例是公开仓库,不把 token 写入命令 |
| Compose 找不到配置或变量 | pwd、ls -a、.env 是否与 compose.yaml 同目录 | 回到示例目录,再运行 config --quiet |
| 拉镜像或构建失败 | 命令中的错误、镜像仓库连通性、Docker 守护进程状态 | 按代理设置排查实际发起下载的进程,再重试构建 |
| 提示端口被占用 | ss -ltn 中的 8080 或 80/443 | 辨认现有服务;协调入口或改本机端口,并同步 curl / SSH 转发 |
| app 退出或不健康 | docker compose ps -a 与 docker compose logs --tail=50 app | 按日志查 /data 的权限、磁盘空间;修复后重新 up |
| 本机通,域名返回 502 | Caddy 已接到请求但没得到有效上游响应;看 logs --tail=80 caddy | 核对 reverse_proxy app:8000、app 健康状态和共同网络 |
| 域名超时或证书不可信 | A/AAAA 解析、云安全组、80/443、防火墙和 Caddy 日志 | 先修通域名与端口,不用 curl -k 掩盖证书问题 |
本机健康检查成功,但 Caddy 日志指向连接 127.0.0.1:8000 失败。你先改哪一处?
Caddy 中的 127.0.0.1 是 Caddy 自己。服务名 app 才会在 Compose 网络里解析到应用;已有证据支持先修正上游地址,数据卷与这个连接错误无关。
公网
域名到达 Caddy 后,才能自动获得公网证书
Caddy 在入口接收 HTTPS,解开传输加密,再把 HTTP 请求转给同机 Compose 网络里的 app。它还会申请和续期证书,并把 HTTP 请求转向 HTTPS;前提是证书机构能验证你对这个域名的控制权。
本例用 https 这个 profile(可选服务组)启用 Caddy。没有域名也能完成前面的本机部署;要走公网这一段,先逐项检查下面的条件。
- 你拥有一个可配置的域名。将 A 记录指向服务器公网 IPv4;若有 AAAA 记录,也必须指向可达的服务器 IPv6,不留错误旧记录。
- 云安全组和主机防火墙允许入站 TCP 80 与 443;保留上一页的 SSH 入口。修改规则建议在练习云服务器或虚拟机里试。
- 80/443 未被其他服务占用;服务器能出站解析域名、访问镜像仓库和证书机构。Docker 发布端口与 UFW 的关系见官方防火墙说明,不要只凭 UFW 状态推断公网可达性。
- 在 .env 把 DOMAIN 改为你的真实域名,只写主机名,例如
counter.example.com,不要带https://或路径。示例域名必须替换;localhost不用于公网证书验证。
# Caddyfile
{$DOMAIN} {
reverse_proxy app:8000
log
}{$DOMAIN} 从 Caddy 容器环境里读取域名;Compose 已通过 environment 传入。
reverse_proxy app:8000 把外部请求转给内部应用。app 的 8080 本机入口继续保留,不需要向公网开放。
log 启用访问日志,配合 Caddy 自身的运行日志排查入口问题。
保存 .env 后,在服务器的示例目录执行:
在自己电脑的浏览器打开 https://你的域名/,确认没有证书警告、能看到同一份计数;也可以从自己电脑运行下面的命令,替换域名:
第一条应正常返回应用响应,第二条应指向 HTTPS。--wait 对 Caddy 这里只能确认容器运行,因为没有配置 Caddy 健康检查;它不证明公网证书已签好。若验证失败,回到上一节查 DNS、端口与日志。
用两句话解释:为什么 Caddy 写 app:8000,而浏览器写 https://你的域名?为什么还要保留 caddy_data?
浏览器从公网域名找到 Caddy,Caddy 再从内部网络找到 app;它们位于请求路径的两段。证书和私钥保存在 caddy_data,保留它能让容器替换后继续使用已有证书状态。
- 区分公网入口与内部上游
- 指出命名卷保存证书状态,独立于 Caddy 容器
深入一点:HTTPS 和这个最小应用的边界
本例的 HTTPS 保护浏览器到 Caddy 的传输;内部 app:8000 仍是 HTTP,不代表所有网络段都被加密。计数器没有账号和敏感数据,Python 标准库 HTTP 服务用于减少示例依赖。正式业务应采用适合的应用服务器,再补齐认证、限流、备份与监控。
交接
能重建服务,也要能保留它的状态
部署完成后,再验证一次容器替换与数据保留:记下首页计数,在服务器执行下面的重建命令,再次访问首页。健康检查不增加计数;浏览器附加访问可能影响数值,所以检查“继续增加”,不要求恰好只增加一。
日常停止用 docker compose down;启用过代理就用 docker compose --profile https down。命名卷默认保留,后续在同一项目名下重新 up 会继续使用。不要把 down -v 当日常停止:它会删除声明的命名卷,计数和证书状态也随之删除。卷不等于备份,真实数据还需要独立备份。
深入一点:两份部署怎样互不干扰?
本例显式设置项目名 linux-counter,让卷名保持稳定。同一服务器另开一份练习时,需使用不同项目名(例如 -p)、不同宿主机端口,并在每条命令中保持一致。换项目名会使用另一组卷,看起来像“计数归零”,并不说明原卷被删除。
本例使用 python:3.13-slim 与 caddy:2-alpine 标签,这些标签会更新。需要精确复现时,要同时记录 Git 提交与镜像内容摘要(digest),不能只记录分支名和标签。
- 从同一个工作目录出发,把 Git 源码、本机 .env 与数据卷分清。
- 先验证本机入口,再接代理和 HTTPS;失败时按路径找状态与日志。
- 替换容器保留卷,配置变化重新 up;公开域名验证成功才算公网交付。
你改了问候语,想更新服务并保留已有计数。哪种做法符合这次部署的结构?
restart 启动的是原容器,不会重新注入改过的配置。
-v 删除了需要保留的数据卷;更新配置不需要删除数据。
Compose 可以按新配置重建容器,并继续挂载原命名卷。更新源码时先 git pull --ff-only,再用同样的 up -d --build --wait;本页的手工验证点就是下一页 CI 自动化要接管的步骤。
交付给下一页的状态:保留仓库位置、当前 Git 提交、项目名和已验证的域名;确认 .env 与数据卷留在服务器。带着这些信息进入实战三:配上 CI,把重复检查与部署变成流水线。