外观
项目 4:Python 自动化部署 Web 服务
约 6922 字大约 23 分钟
Pythonargparseconfigparserpathlib
2026-05-27
项目目标
你所在的运维团队接到一个任务:为公司的多个内部项目快速搭建 Web 展示页面。手工操作不仅效率低下,还容易因为端口写错、配置文件遗漏而引发线上事故。
本任务要求你将前期 Shell 部署思路迁移到 Python 脚本中,编写一个具备 命令行参数解析、配置文件读取、分级日志输出、HTML 页面动态生成、Nginx 站点配置 和 服务健康拨测 能力的专业级部署工具 deploy_web.py。
通过 V1 → V2 → V3 → V4 四次代码迭代,你将深刻理解 Python 为什么是"自动化运维的瑞士军刀"。
一、核心底层理论支撑
在动手写代码之前,我们必须理解三个关键理论。它们不是"背完就忘"的概念,而是贯穿整个脚本设计的指导思想。
1.1 Bash vs Python:解释器内存模型的降维打击
前两节课我们用 Bash 脚本高效地解决了文件分发问题。但在面对真实的后端业务时,Bash 的弱点暴露无遗,其根本原因在于解释器的内存模型差异。
| 维度 | Bash | Python |
|---|---|---|
| 核心抽象 | 进程与管道(Process & Pipe) | 对象与引用(Object & Reference) |
| 数据结构 | 扁平的字符串,数组靠空格分隔 | 字典、列表、集合、类——丰富的嵌套结构 |
| 子进程开销 | 每个 grep、awk 都 fork 一个子进程 | 内存中操作,只在必要时调用 subprocess |
| 配置解析 | 需要 sed/awk 拼凑正则,脆弱易错 | configparser 一行代码搞定 INI 解析 |
| 错误处理 | set -e 全局退出,难以细粒度控制 | try/except 精准捕获,分级处理 |
核心结论:Bash 适合"串联已有工具"的胶水任务(如第 2、3 课的文件部署),而 Python 适合"从零构建复杂逻辑"的工程任务(如本课的配置校验、模板渲染、健康检查)。
1.2 配置外置化:代码与配置分离的设计思想
在云原生架构中,Twelve-Factor App(十二要素应用) 的第三要素提出了一个核心理念:将配置从代码中分离。官方推荐的实现方式是环境变量,但思想本身可以推广到任何配置外置方案。
代码 = 不变的逻辑 | 配置 = 可变的环境参数
本课使用 INI 配置文件(而非环境变量)来实现这一思想,原因有二:一是 INI 文件直观可读,适合教学场景中"改一行配置,页面立刻变化"的即时反馈;二是 configparser 是 Python 标准库,无需额外安装。在实际生产环境中,你完全可以将这个思想迁移到环境变量、YAML 配置或配置中心(如 Consul、Nacos)上。
为什么这是"架构级"的要求? 看一个真实的翻车现场:
# ❌ 硬编码——架构之耻
port = 8080 # 测试环境用了 8080
db_host = "10.0.1.5" # 生产环境的数据库 IP这个脚本写完、测试通过、Git 提交之后,它的 SHA-1 哈希值就固定了。但现在需要部署到预发布环境——端口要改成 8081,数据库 IP 要换成 10.0.2.5。
你有两个选择:
- 修改 .py 源码 → 哈希值改变 → 测试环境通过的版本 ≠ 预发布运行的版本 → 安全审计失效
- 不改代码,用配置文件 → 同一个脚本,三份不同的
web_config.ini→ 代码不变,配置可变
这就是本课用 configparser + web_config.ini 的根本原因。
1.3 CLI 工具设计哲学:一个好的命令行界面长什么样
你每天都在用命令行工具——git、docker、npm。它们都有一个共同的设计模式:
command --flag value # 长选项,自文档化
command -f value # 短选项,老手速记
command --dry-run # 布尔标志,只看不做
command --help # 内置帮助,不问人一个"专业"的命令行工具和"玩具脚本"的区别就在于:
- 玩具脚本:参数写死在代码里,改参数 = 改代码
- 专业工具:通过
--config指定配置文件,通过--dry-run预览执行计划,通过--help自述用法
Python 的 argparse 标准库就是专门用来打造这种专业体验的。
二、项目全景图
WSL Ubuntu 环境说明
本课程所有操作均在 WSL 2 Ubuntu 中完成(第 1 课已搭建好环境)。机房环境已统一使用 WSL 2,systemd 默认可用,localhost 端口会自动映射到 Windows 主机。
需要留意的是:sudo 操作 /var/www/ 和 /etc/nginx/ 时可能需要输入当前用户的密码。
2.1 我们要构建什么
最终交付的 deploy_web.py 是一个 CLI 工具,它的执行流程如下:
2.2 项目目录结构
lesson-04/
├── deploy_web.py # 主部署脚本(你要写的核心文件)
├── web_config.ini # 配置文件(INI 格式)
├── build/ # 脚本自动生成的构建产物
│ ├── index.html # 动态生成的网页
│ └── site.conf # Nginx 站点配置文件
└── deploy.log # 运行日志(脚本自动生成)2.3 配置文件 web_config.ini 设计解析
点击展开:web_config.ini 完整内容
[site]
name = atsb-python-web
title = 自动化工具与脚本编写
message = Python 已完成 Web 服务自动化部署
port = 8088
[paths]
web_root = /var/www/atsb-python-web
nginx_conf = /etc/nginx/sites-available/atsb-python-web.conf
nginx_link = /etc/nginx/sites-enabled/atsb-python-web.conf将以上内容保存为 lesson-04/web_config.ini,这是本课所有脚本运行的配置来源。
配置文件分为两个段(Section),每个字段都有明确的设计目的:
| 段 | 字段 | 含义 | 为什么需要它 |
|---|---|---|---|
[site] | name | 站点标识名 | 用于命名文件、目录,保证多个项目不冲突 |
[site] | title | 页面标题 | 注入到 HTML <title> 标签 |
[site] | message | 页面正文 | 注入到 HTML <body>,展示部署结果 |
[site] | port | 监听端口 | Nginx 监听的 TCP 端口 |
[paths] | web_root | Web 根目录 | Nginx 从哪个目录读取 HTML 文件 |
[paths] | nginx_conf | Nginx 配置文件路径 | 站点配置文件存放位置 |
[paths] | nginx_link | Nginx 启用软链接 | sites-enabled 下的符号链接 |
关键设计原则:[paths] 段中的路径使用绝对路径。这是因为 Nginx 必须以 root 权限运行,而 root 的工作目录通常是 /root——依赖相对路径会导致"脚本在开发目录能跑,但 sudo 后找不到文件"的诡异问题。
2.4 环境准备与自检
在动手写代码之前,先用一段环境检测脚本确认你的系统是否就绪。将以下代码保存为项目根目录下的 env_check.py,运行即可一键排查潜在问题。
点击展开:env_check.py 完整代码
#!/usr/bin/env python3
"""lesson-04 环境检测脚本 —— 运行前检查系统、工具和项目目录是否就绪。"""
import shutil
import subprocess
import sys
from pathlib import Path
def check(label, ok, detail=""):
status = "✅" if ok else "❌"
print(f" {status} {label}{' — ' + detail if detail else ''}")
return ok
def main():
print("=" * 56)
print(" Lesson 04 环境检测")
print("=" * 56)
all_ok = True
# ---- 1. 项目目录 ----
print("\n📁 项目目录")
project_dir = Path.cwd()
all_ok &= check("当前目录", True, str(project_dir))
lesson_dir = project_dir / "lesson-04"
all_ok &= check(
"lesson-04/ 子目录",
lesson_dir.is_dir(),
"请确认你在项目根目录下运行此脚本" if not lesson_dir.is_dir() else "",
)
# ---- 2. 系统环境 ----
print("\n🖥️ 系统环境")
uname = subprocess.run(["uname", "-a"], capture_output=True, text=True)
is_linux = "Linux" in (uname.stdout or "")
all_ok &= check("操作系统", is_linux, uname.stdout.strip().split(" ")[0] if is_linux else "非 Linux 环境")
# 检测是否在 WSL 中
wsl_check = subprocess.run(
["grep", "-qi", "microsoft", "/proc/version"],
capture_output=True,
)
is_wsl = wsl_check.returncode == 0
all_ok &= check("WSL 环境", is_wsl, "WSL Ubuntu" if is_wsl else "非 WSL(可能是原生 Linux)")
# ---- 3. 关键工具 ----
print("\n🔧 关键工具")
python_ver = sys.version.split()[0]
py_ok = sys.version_info >= (3, 8)
all_ok &= check(f"Python {python_ver}", py_ok, "建议 Python 3.8+")
nginx_path = shutil.which("nginx")
all_ok &= check("nginx", nginx_path is not None, nginx_path or "未安装 — 脚本会自动安装")
curl_path = shutil.which("curl")
all_ok &= check("curl", curl_path is not None, curl_path or "未安装")
git_path = shutil.which("git")
all_ok &= check("git", git_path is not None, git_path or "未安装")
# ---- 4. Python 标准库导入 ----
print("\n🐍 Python 标准库")
modules = [
("argparse", "命令行参数解析"),
("configparser", "INI 配置文件解析"),
("logging", "日志记录"),
("subprocess", "系统命令调用"),
("pathlib", "面向对象路径操作"),
]
for mod_name, desc in modules:
try:
__import__(mod_name)
all_ok &= check(f"{mod_name} ({desc})", True)
except ImportError:
all_ok &= check(f"{mod_name} ({desc})", False, "标准库缺失,请检查 Python 安装")
# ---- 5. 网络连通性 ----
print("\n🌐 网络连通性")
try:
result = subprocess.run(
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--connect-timeout", "5",
"http://archive.ubuntu.com"],
capture_output=True, text=True,
)
net_ok = result.returncode == 0
all_ok &= check("apt 软件源可达", net_ok, result.stdout.strip() if net_ok else "网络不通或超时")
except Exception:
all_ok &= check("apt 软件源可达", False, "curl 执行异常")
# ---- 6. sudo 权限 ----
print("\n🔑 sudo 权限")
try:
sudo_test = subprocess.run(
["sudo", "-n", "true"],
capture_output=True,
timeout=5,
)
has_sudo = sudo_test.returncode == 0
if has_sudo:
all_ok &= check("sudo (免密码)", True)
else:
all_ok &= check("sudo (需密码)", True, "运行脚本时会提示输入密码,属正常现象")
except Exception:
all_ok &= check("sudo", False, "无法检测 sudo 状态")
# ---- 汇总 ----
print("\n" + "=" * 56)
if all_ok:
print(" ✅ 环境检测全部通过,可以开始第 4 课的学习!")
else:
print(" ⚠️ 部分检测未通过,请根据上方 ❌ 提示排查后再开始。")
print("=" * 56)
if __name__ == "__main__":
main()# 在项目根目录下运行(与 lesson-04/ 同级)
python3 env_check.py脚本会依次检测以下 6 个维度:
| 检测维度 | 检测内容 | 不通过时的含义 |
|---|---|---|
| 📁 项目目录 | lesson-04/ 子目录是否存在 | 确认你的工作目录是否正确 |
| 🖥️ 系统环境 | 是否为 Linux / WSL | 本课依赖 Ubuntu 环境(WSL 或原生) |
| 🔧 关键工具 | Python、nginx、curl、git 是否已安装 | nginx 缺失脚本会自动安装,其他需手动补全 |
| 🐍 Python 标准库 | argparse、configparser、logging、subprocess、pathlib | 标准库几乎不会缺失,确认 Python 安装完整 |
| 🌐 网络连通性 | apt 软件源是否可达 | 脚本需要 apt install nginx,网络不通会失败 |
| 🔑 sudo 权限 | 当前用户是否有 sudo 权限 | 无 sudo 无法操作 /var/www/ 和 /etc/nginx/ |
为什么要先跑环境检测?
不同电脑的环境配置难免有差异。常见问题包括:nginx 从未安装过、/var/www/ 目录权限不对、apt 软件源连不上。花 30 秒跑一次检测,能省下后续半小时的 debug 时间。
三、V1:硬编码版——从最简陋的起点出发
3.1 直白的 subprocess 调用
我们先从一个"能跑就行"的版本开始。创建 deploy_web.py:
#!/usr/bin/env python3
import subprocess
def deploy():
print("开始部署 Web 服务...")
# 把端口、IP、配置全写死在代码里
port = 8080
host = "0.0.0.0"
env = "production"
print(f"正在 {env} 环境启动服务,监听 {host}:{port}")
subprocess.run(["python3", "-m", "http.server", str(port)])
print("服务已启动!")
if __name__ == "__main__":
deploy()3.2 硬编码的三宗罪
这个 V1 版本看起来干净利落,但它犯了三个致命的架构错误:
| 罪名 | 表现 | 后果 |
|---|---|---|
| 不可配置 | port = 8080 写死在代码里 | 换端口 = 改代码 → 重新测试 → Git 提交 → 代码哈希改变 |
| 不可预览 | 没有 --dry-run | 无法在执行前知道脚本会做什么,只能"闭眼回车" |
| 不可观测 | 用 print() 而不是 logging | 日志消失在终端,没有时间戳,没有级别,出问题无法回溯 |
接下来,我们将逐一解决这三个问题。每次迭代只解决一个问题,让你清楚地看到每一层改进带来的价值。
四、V2:CLI + 配置解耦
V2 的目标:用一个 web_config.ini 文件接管所有可变参数,并且让脚本支持 --dry-run 和 --config 命令行参数。
4.1 argparse:打造专业 CLI 的第一步
argparse 是 Python 标准库中用于解析命令行参数的模块。它的核心概念很简单:
import argparse
parser = argparse.ArgumentParser(description="工具描述")
parser.add_argument("--config", default="web_config.ini", help="配置文件路径")
parser.add_argument("--dry-run", action="store_true", help="只打印命令,不执行")
args = parser.parse_args()
print(args.config) # 用户传的 --config 值,或默认值
print(args.dry_run) # True 如果用户传了 --dry-run,否则 False三个关键点:
default:用户不传参数时的默认值help:--help时显示的文字action="store_true":这是一个布尔开关,传了就是True,不传就是False
4.2 configparser:将配置从代码中剥离
configparser 将 INI 格式的配置文件解析为内存中的嵌套字典结构:
命令行参数
--config web.ini--dry-run配置文件 (web.ini)
[site]port = 8088→
argparse
→
configparser
主逻辑代码
↓
等待执行
了解 Python 是如何优雅地处理配置和参数的。
使用起来非常简单:
import configparser
config = configparser.ConfigParser()
config.read("web_config.ini", encoding="utf-8")
# 读取 [site] 段下的 port 字段,注意返回的是字符串!
port = config["site"]["port"] # 返回 "8088"(字符串)
port = config["site"].getint("port") # 返回 8088(整数)
# 检查字段是否存在
if not config.has_option("site", "title"):
print("配置文件缺少 [site] title 字段!")字段校验的重要性
任务卡中明确指出:配置文件字段缺失、段名错误或编码异常时脚本应给出清晰错误提示。不要假设配置文件一定完整——你的脚本必须在启动时主动校验所有必需字段,缺失任何一个都要立刻报错并退出,而不是跑到一半抛出晦涩的 KeyError。
4.3 pathlib:告别字符串拼接的路径地狱
在 Bash 中我们习惯用字符串拼接路径:
WEB_ROOT="/var/www/mysite"
INDEX="$WEB_ROOT/index.html" # 字符串拼接,容易漏斜杠Python 的 pathlib 提供了面向对象的路径操作:
from pathlib import Path
web_root = Path("/var/www/mysite")
index_file = web_root / "index.html" # / 运算符自动处理分隔符!
build_dir = Path("build")
build_dir.mkdir(exist_ok=True) # 创建目录,已存在也不报错
index_file.write_text("<h1>Hello</h1>", encoding="utf-8") # 一行代码写入文件为什么不用 os.path? 虽然 os.path.join() 也能拼接路径,但它返回的是字符串,没有 .mkdir()、.write_text() 这些便捷方法。pathlib 是 Python 3.4+ 推荐的现代路径操作方式。
4.4 V2 完整代码
在下方输入 Python 用于解析命令行参数的标准库模块名称,解锁 V2 版完整代码:
问题:Python 用于解析命令行参数的标准库模块叫什么?(提示:本节的标题里有)
五、V3:日志监控体系——告别 print,拥抱 logging
5.1 logging 漏斗模型深入理解
print() 最大的问题不是"不够好看",而是无法分级过滤。在生产环境中,一个服务可能产生海量的调试信息,但运维人员只想看 WARNING 以上的关键事件。
Python 的 logging 模块就像一个多层漏斗,将日志分为 5 个级别:
Python 代码
logger.debug("加载详细配置信息...")
logger.info("开始部署 Nginx 服务...")
logger.warning("磁盘空间不足 20%,请注意清理")
logger.error("致命错误:端口 80 已被占用")
过滤器 (Level >= INFO)
控制台输出
等待输出...
Python logging 模块通过**层级过滤**来控制输出。你可以随时调整全局级别,代码不用改,输出的冗余度就会改变。
DEBUG (10): 开发调试用,最繁琐,如变量状态。INFO (20): 正常运行时的提示,如部署开始、完成。WARNING (30): 遇到问题但不致命,如空间告警。ERROR (40): 严重问题,导致某个功能失败。
原理:只有日志的方法级别(比如 logger.info() 的 20)大于或等于全局设置的级别时,这条消息才会被打印到终端!
DEBUG (10) < INFO (20) < WARNING (30) < ERROR (40) < CRITICAL (50)当你设置 level=logging.INFO 时,所有 DEBUG 消息都会被漏斗拦截,只有 INFO 及以上级别才能穿透。这个机制的价值在于:开发时设 DEBUG 看全部细节,上线后设 WARNING 只看关键问题——同一份代码,不需要改一行。
5.2 双通道输出设计
专业的日志系统通常采用双通道(Dual Handler)设计:
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - [%(levelname)s] - %(message)s',
handlers=[
logging.FileHandler("deploy.log"), # 通道1:写入磁盘,永久留存
logging.StreamHandler() # 通道2:输出终端,实时查看
]
)
logging.info("部署开始") # 同时出现在终端和 deploy.log
logging.warning("端口可能冲突") # 同上
logging.error("Nginx 启动失败") # 同上| 对比维度 | print() | logging |
|---|---|---|
| 时间戳 | ❌ 需要手动拼 | ✅ 自动附带 %(asctime)s |
| 日志级别 | ❌ 无法区分 | ✅ DEBUG/INFO/WARNING/ERROR/CRITICAL |
| 输出目标 | ❌ 只能终端 | ✅ 同时写文件和终端 |
| 上线后屏蔽调试信息 | ❌ 需要删代码 | ✅ 改一行 level=logging.WARNING |
5.3 V3 完整代码
在下方输入 logging 模块中代表"信息"级别的常量名(提示:logging.____),解锁 V3 版完整代码:
问题:logging 模块中表示'日常信息'级别的常量叫什么?
六、V4:完整部署流水线
V4 是最终目标版本。除了前面 V2 + V3 已经具备的 CLI 解析、配置读取、日志输出能力外,V4 还需要新增三个关键能力:
6.1 HTML 模板动态生成
我们不使用现成的 HTML 文件,而是让脚本根据 web_config.ini 中的 title 和 message 动态生成页面。这意味着:
- 每个项目的页面内容可以完全不同,只需修改配置文件
- 页面自动附带部署时间戳,方便确认是否是"最新版本"
核心实现——Python f-string 模板:
def render_html(config):
title = config["site"]["title"]
message = config["site"]["message"]
now = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return f"""<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>{title}</title></head>
<body>
<h1>{title}</h1>
<p>{message}</p>
<p>部署时间:{now}</p>
</body>
</html>"""6.2 Nginx 站点配置生成
Nginx 是业界最广泛使用的 Web 服务器之一。要让 Nginx 托管我们生成的页面,需要为每个站点创建一个 server block 配置文件。
Nginx 的配置管理有一个经典的"双目录"设计:
/etc/nginx/sites-available/
📄 atsb-python-web.conf
📄 default
/etc/nginx/sites-enabled/
🔗 atsb-python-web.conf
🔗 default
$ sudo nginx -t
$ sudo nginx -s reload
⚙️
Nginx 守护进程 (Master)
点击“自动部署演示”,观察 Nginx 经典的“双目录软链”工作流。
sites-available/:存放所有站点配置文件(包括暂时不用的)sites-enabled/:只放需要启用的站点的软链接(Symlink)- 启用一个站点 = 创建软链接;停用一个站点 = 删除软链接,配置文件本身不丢
脚本需要自动生成的 Nginx server block:
server {
listen 8088;
server_name _;
root /var/www/atsb-python-web;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}6.3 subprocess 命令执行封装
在 V2/V3 中我们只是简单调用 subprocess.run()。但在 V4 中,脚本需要执行多条系统命令(apt install、mkdir、cp、ln、nginx -t、nginx reload),每一条都可能失败。
我们将命令执行封装成一个统一的 run() 函数:
def run(command, dry_run=False):
"""执行系统命令,支持 --dry-run 预览"""
logging.info(f"$ {' '.join(command)}") # 打印将要执行的命令
if dry_run:
return # dry-run 模式,不真正执行
result = subprocess.run(command, text=True, capture_output=True)
if result.returncode != 0:
logging.error(f"命令失败:{result.stderr}")
raise SystemExit(1)设计要点
capture_output=True 意味着捕获命令的标准输出和标准错误,而不是让它们直接喷到终端。这样我们可以用 logging 统一管理所有输出,确保日志格式一致。
6.4 服务拨测与指数退避自愈
进程活着 ≠ 服务活着。这是 SRE(Site Reliability Engineering)领域最基础也最容易被忽视的原则。
📝
1. 配置语法测试
sudo nginx -t⚙️
2. 服务状态检查
systemctl is-active🌐
3. HTTP 连通性探测
curl -s -I HTTP🎉 部署摘要 (Deploy Summary)
状态检测已全部通过,服务器已成功部署 2048 小游戏并可以正常访问!
点击“启动检测”,看看自动化脚本发布后如何证明自己做对了。
TCP 层面的根因分析:
当 Python 脚本用 subprocess 执行 reload nginx 的命令(无论是 systemctl reload nginx 还是 WSL 下的 nginx -s reload)时,这个命令只是向 Nginx 进程发送了一个"重新加载配置"的信号。Nginx 的 worker 进程收到信号后,需要:
- 重新读取配置文件
- 关闭旧的 listen socket
- 创建新的 listen socket
- 向内核注册(bind)端口
这个过程需要数百毫秒到数秒。如果脚本在 reload 返回后立刻发起 HTTP 请求,大概率会撞上"端口还没准备好"的窗口,收到 ConnectionRefusedError。
指数退避(Exponential Backoff)算法:
不能疯狂用 while True 死循环去重试——那会造成 CPU 飙升。正确的做法是指数退避:
import time
for attempt in range(1, 6): # 最多重试 5 次
try:
response = urllib.request.urlopen(f"http://localhost:{port}", timeout=5)
logging.info(f"HTTP {response.status} — 服务正常!")
break # 成功,退出循环
except Exception as e:
wait = 2 ** (attempt - 1) # 指数退避:1s, 2s, 4s, 8s, 16s
logging.warning(f"第 {attempt} 次拨测失败,{wait}s 后重试...")
time.sleep(wait)
else:
logging.error("服务拨测失败,已达最大重试次数")
raise SystemExit(1)| 重试次数 | 等待时间 | 累计等待 | 原理 |
|---|---|---|---|
| 第 1 次 | 1s | 1s | 给服务最基本的启动时间 |
| 第 2 次 | 2s | 3s | 如果还没好,可能是配置有问题 |
| 第 3 次 | 4s | 7s | 给一次充分的机会 |
| 第 4 次 | 8s | 15s | 接近极限 |
| 第 5 次 | 16s | 31s | 30 秒还没起来,基本可以确认失败了 |
为什么叫"指数退避"? 因为等待时间的增长速度是 指数级(2⁰, 2¹, 2², 2³, 2⁴)。这种算法对服务器极其温柔——它假设"如果服务很快就起来了,说明只是启动延迟;如果很久都没起来,说明真的出了问题,重试也没用,不如拉大间隔减少压力"。
6.5 V4 最终版完整代码
现在你已经掌握了所有模块。请在下方输入 Python 中用于面向对象操作文件系统路径的标准库模块名称,解锁最终版的完整部署调度架构代码:
问题:Python 标准库中用于面向对象操作文件系统路径的模块名称是什么?(提示:本课第四节介绍过)
七、运行与验证
7.1 --dry-run 试运行
拿到最终版代码后,不要直接跑。先进入工作目录,再用 --dry-run 预览脚本会执行哪些命令:
cd lesson-04
python3 deploy_web.py --dry-run你会看到类似这样的输出:
INFO: $ sudo apt update
INFO: $ sudo apt install -y nginx
INFO: $ sudo mkdir -p /var/www/atsb-python-web
INFO: $ sudo cp build/index.html /var/www/atsb-python-web/index.html
INFO: $ sudo cp build/site.conf /etc/nginx/sites-available/atsb-python-web.conf
INFO: $ sudo ln -sfn ... /etc/nginx/sites-enabled/atsb-python-web.conf
INFO: $ sudo nginx -t
INFO: $ sudo systemctl reload nginx逐条检查这些命令是否符合预期。确认无误后,再执行正式部署。
7.2 正式部署
# 确保当前目录下有 web_config.ini
ls web_config.ini
# 正式部署(不加 --dry-run)
python3 deploy_web.py
# 验证部署结果
curl http://localhost:8088你应该看到自己配置文件中定义的页面标题和消息。恭喜——你已经完成了一次具备工业级质量的自动化部署!
7.3 浏览器验证与截图
提交要求
任务卡明确要求提交访问截图。仅用 curl 验证是不够的。
- 在 Windows 浏览器中访问
http://localhost:8088(WSL 的localhost端口会自动映射到 Windows) - 确认页面显示了你配置的标题和消息
- 截取完整的浏览器窗口(包含地址栏),保存为
screenshot.png
7.4 提交物清单
将以下文件打包压缩后提交:
| 文件 | 说明 | 验证方法 |
|---|---|---|
deploy_web.py | 最终版部署脚本(V4) | 能通过 --help、--dry-run 和正式部署 |
web_config.ini | 配置文件 | 包含 [site] 和 [paths] 两个段 |
build/index.html | 脚本生成的网页 | 用浏览器打开,显示正确内容 |
deploy.log | 运行日志 | 包含时间戳和级别的完整部署记录 |
screenshot.png | 浏览器访问截图 | 含地址栏,显示 localhost:8088 |
7.5 验收清单
| 验收项 | 检查方法 | 通过标准 |
|---|---|---|
脚本支持 --help | python3 deploy_web.py --help | 显示完整的帮助信息 |
脚本支持 --dry-run | python3 deploy_web.py --dry-run | 打印命令但不执行 |
脚本支持 --config | python3 deploy_web.py --config web_config.ini | 读取指定配置文件 |
| 配置文件校验 | 故意删除 web_config.ini 中的一行 | 脚本报错并明确指出缺失的字段 |
| 日志输出到文件 | 执行后检查 deploy.log | 包含时间戳和级别的完整日志 |
| HTML 页面生成 | cat build/index.html | 包含配置文件中的 title 和 message |
| Nginx 配置生成 | cat build/site.conf | 端口和路径与配置文件一致 |
| 服务可访问 | curl http://localhost:8088 | 返回 HTTP 200 和生成的 HTML 内容 |
八、故障排查工具箱
| 现象 | 根因 | 排查命令 |
|---|---|---|
KeyError: 'site' | 配置文件段名拼写错误或文件路径不正确 | ls -la web_config.ini && cat web_config.ini |
configparser.NoOptionError | 配置文件中某个字段缺失 | 对照 2.3 节的字段表逐项检查 |
Permission denied | 操作 /var/www/ 或 /etc/nginx/ 需要 sudo 权限 | 先 sudo -v 验证 sudo 权限,再用 python3 deploy_web.py 运行(脚本内部已逐条使用 sudo) |
nginx: [emerg] bind() to 0.0.0.0:8088 failed | 端口已被占用 | sudo lsof -i :8088 检查占用端口的进程 |
nginx -t 语法检查失败 | 生成的配置文件有语法错误 | 检查 build/site.conf 的大括号是否配对 |
| HTTP 拨测返回 404 | Nginx 在运行但找不到 HTML 文件 | 检查 web_root 路径是否正确,文件是否真的被复制过去 |
| 日志文件没有生成 | 当前用户对当前目录没有写入权限 | ls -ld . 检查目录权限 |
--dry-run 也报权限错误 | run() 函数在 dry-run 模式下仍然调用了命令 | 确认 run() 函数中 if dry_run: return 在所有命令之前 |
nginx: unrecognized service | nginx 安装后可能未自动启动 | 手动执行 sudo nginx 启动 nginx 进程,再运行脚本 |
Windows 浏览器访问 localhost:8088 无响应 | WSL 网络转发异常 | 先在 WSL 终端内 curl http://localhost:8088 确认服务正常;若 WSL 内正常但 Windows 无法访问,尝试重启 WSL:wsl --shutdown |
九、进阶验收标准
| 验收维度 | 达标要求 |
|---|---|
| 理论基石 | 能口述 Bash 与 Python 内存模型的三个核心差异;能用自己的话解释 Twelve-Factor App 第三要素;能从 TCP 三次握手层面解释 ConnectionRefusedError 的根因 |
| CLI 设计 | 能解释 argparse 中 action="store_true" 的含义;能说出 --help、--dry-run、--config 各自的设计目的 |
| 配置解耦 | 能解释为什么硬编码是"架构之耻";能演示修改 web_config.ini 中一个字段后脚本行为随之改变,而无需修改 .py 文件 |
| 日志体系 | 能画出 logging 的五级漏斗模型;能将脚本的日志级别从 INFO 改为 DEBUG 并观察输出变化;能解释为什么生产环境通常设置 WARNING 级别 |
| 自愈算法 | 能解释"指数退避"为什么不会造成 CPU 飙升;能修改退避算法的重试次数和基数并观察行为变化 |
| 路径安全 | 能解释为什么配置文件中使用绝对路径而非相对路径;能用 pathlib 替代 os.path 重写一段路径操作代码 |
十、深度探索任务
以下任务不强制提交,但完成它们会让你真正理解本课的设计思想。它们同时也是防止"复制粘贴就能交作业"的有效训练:
探索任务 1:增加一个新的配置项
在 web_config.ini 的 [site] 段中新增一个 theme_color 字段,让部署脚本将它的值注入到 HTML 的 <body style="background-color: ..."> 中。你需要:
- 修改
web_config.ini增加字段 - 修改
load_config()的required字典 - 修改
render_html()函数 - 运行验证
提示:这正是"配置解耦"的核心价值——你只需要改配置和渲染逻辑,不用改部署流程。
探索任务 2:改写退避算法
将指数退避改为 线性退避(每次等固定 3 秒),并将最大重试次数改为 3 次。比较两种策略在"服务需要 8 秒才能启动"这种场景下的表现:
| 策略 | 重试 1 | 重试 2 | 重试 3 | 能否成功? |
|---|---|---|---|---|
| 指数退避 | 1s | 2s | 4s | ✅ 累计 7s < 8s... 等等,需要第 4 次 ✅ |
| 线性退避 | 3s | 3s | 3s | ✅ 累计 9s > 8s ✅ |
思考:如果最大重试次数只有 3 次,哪种策略更可能成功?如果最大重试次数是 10 次呢?
探索任务 3:为另一个端口部署第二个站点
修改 web_config.ini(或复制一份新的),将端口改为 8089、站点名称改为 atsb-python-web-2、根目录改为 /var/www/atsb-python-web-2,重新运行 deploy_web.py。
通过浏览器分别访问 http://localhost:8088 和 http://localhost:8089,确认两个站点互不影响。
这个任务证明:你的脚本已经具备了"一份代码,多套环境"的工业级部署能力。
