最后的冲刺:把开发机上的 8014 端口变成一个可以长期运行、HTTPS 加密、 宕机自愈的线上服务。三种形态按序讲:一键脚本(本机长期跑)、 Docker Compose 三角色(标准生产形态)、systemd + nginx(无 Docker 服务器), 然后给生产安全清单,最后回顾整个系列。
一、部署形态决策:先想清楚"要什么样的生产"
| 形态 | 适合 | 复杂度 | 本项目定位 |
|---|---|---|---|
runserver 8014 |
开发调试 | 零 | 仅开发——runserver 是单线程开发服务器,不抗生产 |
| start.sh(本地模式) | 个人 VPS 常驻、内网演示 | 低 | 开发形态的生产包装:venv + migrate + runserver |
| Docker Compose 三角色 | 标准生产 | 中 | 推荐:web/worker/db 分离,可横向扩 worker |
| systemd + nginx | 已有宿主 PG/nginx、没装 Docker | 中 | 仓库线上实例用的就是它 |
为什么生产不能用 runserver:单进程、无并发调优、DEBUG 页面泄源码——
生产用 gunicorn(WSGI 应用服务器)+ 反向代理(nginx)+ PostgreSQL。
二、形态 1:start.sh 一键脚本(本机/演示)
仓库根目录的 start.sh(222 行 bash,幂等可重复跑)做的事情:
检测 Python(>=3.10) → 创建 venv → 装依赖 → migrate →
全新库自动 init_data(演示数据+管理员,凭据写 admin_credentials.txt)→
端口检查 → runserver 8014
bash start.sh # 本地模式
bash start.sh --setup-only # 只准备环境不启动
bash start.sh --demo # 补充初始化演示数据
bash start.sh --docker # 直接走生产编排(下一节)
幂等是脚本的生命:每一步都先检测再执行(venv 存在就跳过、已有库不重复 init_data),重复运行永远安全。给小白的启示:把"跑起来的步骤"固化成脚本, 而不是留在你的备忘录里。
三、形态 2:Docker Compose 三角色(推荐生产形态)
3.1 角色划分:web 和 worker 为什么必须分家
回忆 05/06 篇:请求指标缓冲线程跑在 web 进程(指标是 web 产生的), 而采集/告警/拨测/巡检四条周期线程不该跟着 web 副本数翻倍——10 个 web 副本 就是 10 份重复告警。所以:
db PostgreSQL 16 —— 指标/日志/告警的持久层
web gunicorn(2 worker × 4 线程),OBS_DISABLE_WORKERS=1,可多副本
worker python manage.py obs_workers —— 四条周期线程,租约选主,可多副本
worker 可多副本的秘密就是 06 篇的租约选主:三副本抢同一把租约,
全集群只有一个进程真正干活,持有者宕机 TTL 内自动接管——
docker compose up -d --scale worker=3 随便扩。
3.2 Dockerfile
# 烽火台 Beacon Tower · 生产镜像
# 构建镜像不含密钥:SECRET_KEY / 接入令牌 / 数据库连接全部运行时注入
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
# 先装依赖再拷代码:依赖未变时命中 Docker 层缓存
COPY requirements-prod.txt .
RUN pip install --no-cache-dir -r requirements-prod.txt
COPY . .
# 非 root 运行(容器内只写 /data 卷:sqlite 兜底库与密钥文件)
RUN useradd --create-home obs \
&& mkdir -p /data \
&& chown -R obs:obs /app /data
USER obs
ENV DJANGO_DEBUG=0 \
OBS_DATA_DIR=/data
EXPOSE 8014
# gunicorn:2 worker × 4 线程;本容器仅处理请求(OBS_DISABLE_WORKERS 由 compose 注入),
# 采集/告警/拨测/巡检线程由单副本 worker 服务承担
CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py collectstatic --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:8014 --workers 2 --threads 4 --timeout 60 --access-logfile -"]
三个细节:
- 先 COPY 依赖文件再 COPY 代码:代码改动不再打碎依赖层缓存,重建快十倍;
- 非 root + 密钥运行时注入:镜像里没有任何密钥
(
.dockerignore排除全部凭据文件),容器被打穿也拿不到 SECRET_KEY; requirements-prod.txt多了 gunicorn 和 psycopg(PostgreSQL 驱动)。
3.3 docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: obs
POSTGRES_USER: obs
POSTGRES_PASSWORD: ${OBS_PG_PASSWORD:?请在 .env 设置 OBS_PG_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U obs -d obs"]
interval: 5s
timeout: 3s
retries: 12
restart: unless-stopped
web:
build: .
ports:
# 默认只绑本机回环:反代(nginx/Caddy)在本机转发即可,公网不必直连 8014
- "${OBS_PUBLISH_ADDR:-127.0.0.1}:8014:8014"
environment: &web_env
DJANGO_DEBUG: "0"
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1}
DJANGO_ALLOW_ALL_HOSTS: "0"
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?请在 .env 设置 DJANGO_SECRET_KEY}
OBS_INGEST_TOKEN: ${OBS_INGEST_TOKEN:?请在 .env 设置 OBS_INGEST_TOKEN}
OBS_DATABASE_URL: postgres://obs:${OBS_PG_PASSWORD}@db:5432/obs
OBS_DISABLE_WORKERS: "1" # web 只处理请求,后台任务全部在 worker
OBS_TRUST_XFORWARDED_FOR: "${OBS_TRUST_XFORWARDED_FOR:-0}"
DJANGO_SECURE_COOKIES: "${DJANGO_SECURE_COOKIES:-0}"
DJANGO_CSRF_TRUSTED_ORIGINS: "${DJANGO_CSRF_TRUSTED_ORIGINS:-}"
volumes:
# bind 卷:collectstatic 产物要能被宿主机 nginx 直接读到(DEBUG=0 时
# Django 不伺服静态文件,只反代应用会让页面丢样式与图表)
- ./staticfiles:/app/staticfiles
# 密钥/令牌/SQLite 兜底库都在 /data:不挂卷的话容器一重建密钥就重新生成
- obsdata:/data
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8014/api/health/', timeout=5)"]
interval: 15s
timeout: 5s
retries: 5
restart: unless-stopped
worker:
build: .
command: ["python", "manage.py", "obs_workers"]
environment:
<<: *web_env
OBS_DISABLE_WORKERS: "0"
volumes:
- obsdata:/data # 与 web 共用:读同一份 SECRET_KEY / 接入令牌
depends_on:
web:
condition: service_started # 等 web 先完成 migrate 再拉起任务线程
restart: unless-stopped
# docker compose up -d --scale worker=3:多副本抢租约,只有一个在跑
volumes:
pgdata:
obsdata:
3.4 上线操作
cp .env.example .env # 填 DJANGO_SECRET_KEY / OBS_INGEST_TOKEN / OBS_PG_PASSWORD
# 密钥生成:python -c "import secrets;print(secrets.token_urlsafe(64))"
docker compose up -d --build
docker compose exec web python manage.py createsuperuser
docker compose logs -f web # 看启动日志
三件容易踩的事:
- 忘挂 obsdata 卷:容器重建 → SECRET_KEY 重新生成 → 全部登录会话失效、 已加密的 SMTP 授权码再也解不开(数据还在但解密不了);
- worker 抢跑:worker 的
depends_on只等 web 启动——web 的 migrate 在 CMD 里,worker 启动太快可能撞上表还没建好。单机 compose 场景通常来得及 (worker 首轮 tick 有重试);严格场景把 migrate 挪到独立一次性命令; --scale worker=3之后:确认 NTP 时钟同步(租约过期判定随时钟漂移偏差)。
3.5 为什么 gunicorn 用单进程多线程(systemd 形态的坑)
限速器与登录锁定目前是进程内 cache(06 篇 rate_limit 的实现)——
开 N 个 gunicorn worker 等于把限额放大 N 倍。所以 systemd 形态推荐
--workers 1 --threads 4;要横向扩先换 Redis cache,而不是先加 worker。
这是"组件选型的隐藏耦合"——限速器的实现决定了扩容方式。
四、形态 3:systemd + nginx(无 Docker 服务器)
# 1) 代码与虚拟环境
python3 -m venv /opt/beacon-tower/.venv
/opt/beacon-tower/.venv/bin/pip install -r /opt/beacon-tower/requirements-prod.txt
# 2) /etc/beacon.env(EnvironmentFile 格式,放 DJANGO_* 与 OBS_* 变量)
# 代码树归 root、服务用户只读时,两个可写目录必须指出来:
# OBS_DATA_DIR=/var/lib/beacon (令牌/凭据/SQLite 兜底)
# OBS_STATIC_ROOT=/var/lib/beacon/static
/etc/systemd/system/beacon.service:
[Unit]
Description=Beacon Tower web (gunicorn)
After=network.target postgresql.service
[Service]
User=beacon
EnvironmentFile=/etc/beacon.env
WorkingDirectory=/opt/beacon-tower
ExecStart=/opt/beacon-tower/.venv/bin/gunicorn config.wsgi:application \
--bind 127.0.0.1:8014 --workers 1 --threads 4 --timeout 60
Restart=always
[Install]
WantedBy=multi-user.target
/etc/systemd/system/beacon-worker.service:
[Unit]
Description=Beacon Tower background workers
After=beacon.service
[Service]
User=beacon
EnvironmentFile=/etc/beacon.env
WorkingDirectory=/opt/beacon-tower
ExecStart=/opt/beacon-tower/.venv/bin/python manage.py obs_workers
Restart=always
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now beacon.service beacon-worker.service
nginx 站点(静态文件与反代各就各位):
server {
listen 443 ssl http2;
server_name obs.example.com;
ssl_certificate /etc/nginx/certs/obs.pem;
ssl_certificate_key /etc/nginx/certs/obs.key;
# 静态文件:指向 OBS_STATIC_ROOT(collectstatic 的产物)
location /static/ {
alias /var/lib/beacon/static/;
expires 7d;
}
location / {
proxy_pass http://127.0.0.1:8014;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
走 Cloudflare 橙云时:nginx 要配 set_real_ip_from +
real_ip_header X-Forwarded-For,应用侧设 OBS_TRUST_XFORWARDED_FOR=1——
两者缺一,访客就全被记成 CF 出口 IP,按 IP 的限速大面积误伤、IP 地域分析全废。
源站证书用 CF 的 Origin CA(SSL/TLS 模式 Full strict)。
五、生产安全清单(上线前逐项打勾)
这几项在前面各篇都埋了伏笔,部署时集中兑现:
| 项 | 变量/配置 | 为什么 |
|---|---|---|
| 关调试 | DJANGO_DEBUG=0 |
DEBUG 页面泄露源码与配置;同时自动启用 nosniff/X-Frame-Deny |
| Host 白名单 | DJANGO_ALLOWED_HOSTS=obs.example.com + DJANGO_ALLOW_ALL_HOSTS=0 |
防 Host 头注入 |
| Cookie/CSRF | DJANGO_SECURE_COOKIES=1 + DJANGO_CSRF_TRUSTED_ORIGINS=https://… |
Cookie 只走加密通道、CSRF 校验在反代后能过 |
| 可信反代 | OBS_TRUST_XFORWARDED_FOR=1(仅在可信反代后) |
否则客户端可伪造 XFF 污染 IP 统计与审计 |
| 出站收紧 | 多租户/对公网部署:OBS_ALLOW_LOOPBACK_URL=0、OBS_ALLOW_PRIVATE_URL=0 |
拨测/回调只许打公网(10 篇闸门) |
| 演示入口 | 要公开演示才设 OBS_SHOW_DEMO_ACCOUNT=1 |
生产不主动外泄只读账号 |
| 接入令牌 | .env 注入,接入中心/令牌文件不入库不入镜像 |
令牌只能写不能读(06 篇作用域设计) |
| HTTPS | 全链路;HSTS 随 SECURE_COOKIES 自动开启 | 监控数据与凭据全程加密 |
| 静态文件 | collectstatic + nginx 伺服 /static/ |
DEBUG=0 时 Django 不伺服静态 |
| 端口收敛 | web 只绑 127.0.0.1(默认),公网只开 443 | 公网不必直连应用端口 |
六、Agent 接入多台服务器(部署后的第一件事)
平台上线后,把每台要监控的服务器接进来(06 篇本机测过的流程,换成生产地址):
# 在目标服务器上
curl -o obs_agent.py https://obs.example.com/static/agent/obs_agent.py
pip install psutil
export OBS_SERVER_URL="https://obs.example.com"
export OBS_INGEST_TOKEN="<接入令牌,见接入中心页面>"
export OBS_HOST_NAME="web-1"
python obs_agent.py # 用 systemd 或 nohup 常驻(接入中心页面有完整示例)
主机页右上角下拉切换各台服务器;资产台账自动登记。
"接入中心"页面(/integration/)有六大观测域的接入方式与可直接复制的示例代码。
七、系列总结:你刚刚走完的路
从 00 篇的"装 Python",到今天把平台送上公网。回头看,你实际完成的是:
分析(01):从一个具体故障出发,把"监控平台"拆成 8 组需求清单、3 条用户故事、 5 条非目标——需求分析是"不做什么"的艺术。
设计(02):一张数据流图、9 个 app、五个关键决策(注册表/批量缓冲/租约选主/ 门禁+令牌/SQLite 切 PG),每个决策都有"被否掉的方案"——设计是给未来留对账单。
开发(03-10): - 采集四路自研:请求中间件(含调用链 span 与 SQL 脱敏)、psutil 线程、 日志 Handler、11KB 浏览器 SDK; - 两个枢纽:批量缓冲(p99 2190→251ms,采集永不反压业务)与指标注册表 (18+ 指标一处注册全平台生效); - 一套告警生产语义:for-duration 防抖、恢复迟滞、静默窗口、重复事件自愈; - 四种可解释算法:3σ(含零方差边界)、最小二乘(带 R²)、皮尔逊、日志模板聚类; - 一整层安全工程:整站门禁、令牌最小作用域、SSRF 三道闸门、自愈白名单、 演示账号 fail-closed、清理边界剪枝。
质量(11):125 个测试按风险分层(安全边界最密),ruff + CI 四道闸门—— 测试不是成本,是"敢改"的底气。
部署(12):三种形态、三角色编排、租约选主下的横向扩容、生产安全清单—— 上线的本质是把开发时的每一个"临时假设"换成显式配置。
把这个项目变成你的
- 删掉 forum 的演练数据,接一个你真实的业务:自定义指标
POST /api/ingest/metrics/上报"订单积压数",给它配告警——这是平台真正的用法; - 找一个你怀疑慢的页面,看调用链瀑布图,数 SQL 条数——然后优化它, 用 APM 前后对比证明;
- 扩展一个指标:注册表加一行、采集处 submit 一次——回顾"新增指标只改一处" 是不是真的;
- 给项目写 README、发到 GitHub:CI 徽章会替你说话。
面试时怎么讲这个项目(30 秒版)
"我独立做了一个全栈可观测平台:Django + SQLite/PostgreSQL,四路自研采集 (请求中间件 APM、psutil 主机线程、日志 Handler、零依赖前端 SDK),指标注册表 统一取数。两个值得讲的工程决策:一是请求指标批量缓冲,把 SQLite 单写者锁 从请求路径剥离,压测 p99 从 2.2 秒降到 251 毫秒,代价是 1 秒可见延迟和可观测的 丢弃计数;二是后台任务用数据库租约选主,CAS 条件更新实现,多副本部署不重复 告警,持有者宕机自动接管。安全上做了整站门禁、令牌最小作用域、SSRF 闸门和 自愈命令白名单。125 个测试 + GitHub Actions CI。"
——每个句子背后都有本系列的一整篇。这就是"从分析到设计到开发到部署"的完整闭环。
最终检查点(毕业清单)
- [ ] 平台在线上跑着(三种形态任一),HTTPS 可访问;
- [ ] 至少一台外部服务器的 Agent 数据出现在主机页;
- [ ] 一条真实业务指标(custom.*)正在上报且有告警策略盯着它;
- [ ] 故障演练一次,30 秒内在 APM/日志/主机/告警看到联动反应;
- [ ] CI 全绿徽章挂在你自己的仓库里;
- [ ] 你能不看资料画出整个平台的数据流图。
系列导航
README | 00 导读 | 01 分析 | 02 设计 | 03 骨架 | 04 APM | 05 缓冲与注册表 | 06 主机与 Agent | 07 日志与 RUM | 08 告警 | 09 分析与大盘 | 10 运维中心 | 11 测试与 CI | 12 部署与总结(本篇)
烽火台是古代最早的监控告警系统:瞭望采集、狼烟告警、勤王响应。 现在你有一座自己的了。祝监控常绿,告警常静。