外观
升级与数据库迁移
本文讲三件事:怎么升级 Node Agent(自更新为主、pip wheel 滚动为回退)、怎么升级控制面(主控制面 TS 面 + Python 温备面)、怎么跑数据库迁移(主面 drizzle-kit / 温备面 Alembic)。脚本参数见 ../reference/cli-and-scripts.md,迁移清单见 ../reference/database.md。
Node Agent 自更新(控制面统一分发)
fleet 升级的默认路径:wheel 只上传到控制面一处,各节点 agent 自行拉取安装,取代 scp×N 的逐节点滚动。源码:apps/node-agent/agent/self_update.py(节点侧)、apps/control-server/app/services/agent_releases.py(分发侧),API 见 ../reference/api.md。
发版流程:
bash deploy/build_wheels.sh构建整组首方 wheel(版本 =1.0.<git commit 数>)。- 上传发布:
POST /api/v1/admin/agent-releases(一次上传全部 wheel;控制面落持久卷<releases_dir>/<version>/并生成含每个 wheel sha256 的manifest.json)。 - 设全局目标版本:
POST /api/v1/admin/agent-releases/target。此后 agent 经 WS 心跳上报自身版本,低于目标即收到agent_update_available门铃。 - agent 收铃后经已鉴权通道下载 manifest + wheel,逐字节核对 sha256,暂存
/opt/dn42-wheels,然后触发独立的 systemd oneshotdn42-node-agent-update.service完成整组pip install+ 重启 + 健康门判定(/run/dn42-control/agent-healthy.json)+ 失败自动回滚。
设计要点:整组更新(5 个 wheel 一起装,绝不单更某包)、只升不降(数字版本严格比较,防降级)、下载即校验(sha256 不符即弃)、进程隔离(更新器不随被升级的 agent 一起被杀,健康门失败能滚回旧版)。
前提:节点须装有更新器 unit(新节点接入时执行一次 bash deploy/agent-self-update/install-updater.sh <ssh-target> <key-path>)。
Node Agent 手动升级(pip wheel,引导 / 回退路径)
自更新不可用(更新器未装、控制面不可达)或首次引导时使用。取代"手动 scp 覆盖 venv 单文件 + 手清 __pycache__"的旧做法(易漏文件、留字节码、版本飘移)。
构建
bash
bash deploy/build_wheels.sh- 构建 5 个一方 wheel 到
dist/:dn42-common/dn42-schemas/dn42-runtime/dn42-templates/dn42-node-agent(模板数据文件随包打入)。 - 版本 =
1.0.<git commit 数>(如1.0.107),单调递增,供pip -U识别。 - 版本注入:构建时临时改各
pyproject.toml的[project].version,构建完即还原(trap 兜底)。不用 hatch-vcs——.dockerignore排除了.git,dynamic version 会让控制面 docker 构建拿不到 git 历史而失败。 - control-server 不在此列:它走
docker build(从源码全新重建),无漏文件/残留问题。
滚动升级一个节点
bash
bash deploy/agent_pip_rollout.sh <ssh-target> <key-path> [ssh-port]脚本做三件事:
scp dist/*.whl到节点本地/opt/dn42-wheels(私有仓库 = pip--find-links目录)。pip install -U --no-index --find-links /opt/dn42-wheels/ dn42-common dn42-schemas dn42-runtime dn42-templates dn42-node-agent:--no-index全程离线,不碰 PyPI/镜像源(规避中国/香港镜像源的已知问题)。- 显式列全 5 个包:pip 默认
only-if-needed不会主动升无版本约束的依赖,必须逐个点名。
- 重启
dn42-node-agent.service并回显pip list | grep dn42。
为什么用"节点本地 find-links"而非中央 HTTP index:小 fleet 下本地 /opt/dn42-wheels + --find-links 零公网暴露、零常驻服务、离线,分发由 rollout 脚本顺带完成。
控制面 / agent 锁步升级
当一次变更同时改了 agent 行为与控制面协议/校验(如新增字段),先升 agent、后升控制面(agent 端自填默认值,控制面后跟);反之可能让旧 agent 上报被新校验拒。历史能力(import-limit、prefilter、RPKI not-found 等)都按此锁步滚动,对应的一次性 deploy/agent_*_rollout.sh 已被 wheel 机制取代(见 ../reference/cli-and-scripts.md)。
回退
节点 /opt/dn42-wheels 保留历次 wheel;回退就用旧版本号那批重跑 pip install -U --no-index --find-links ...,或 git checkout <旧commit> 后重新 build_wheels.sh + rollout。
验证
bash
# 主控制面(TS 面)存活探针
curl -s https://api.natlan.io/api/v1/healthz
# 控制面看 fleet 上报
curl -s -H "Authorization: Bearer <admin-token>" http://127.0.0.1:8000/api/v1/admin/health
# 节点看版本一致
/opt/dn42-agent/venv/bin/pip list | grep dn42last_report_status=succeeded + 各节点 dn42-* 同一版本号 = 升级成功。
控制面升级
主控制面(TS 面,Cloudflare Workers)
主控制面是 apps/control-server-ts(唯一 HTTP 入口 https://api.natlan.io)。升级 = 在该目录下部署新版本:
bash
cd apps/control-server-ts
npm run typecheck # tsc --noEmit
npm run deploy # wrangler deploy回滚用 Workers 的版本机制:wrangler versions list 查历史版本,wrangler rollback 切回指定版本(无需重新构建)。
若本次变更含 schema 变化,先应用 drizzle 迁移(见下),再 wrangler deploy;删列类迁移顺序相反(新代码不再引用旧列后再跑迁移)。
Python 温备面(docker 重建)
温备控制面是 apps/control-server(入口 https://legacy.api.natlan.io,GCP 主机上 docker compose 栈),走 docker 全新重建(或 systemd 下重装 venv + 重启)。升级步骤:
- 拉新代码。
- 先跑数据库迁移(见下):
alembic upgrade head。 - 重建/重启 control-server。
注意迁移与代码的顺序:删列类迁移(如 drop_rpki_unknown、drop_node_routing_routes)要在新代码不再引用旧列后再跑;加列类要在新代码前跑。
数据库迁移
主控制面(TS 面):drizzle-kit
主面的迁移由 drizzle-kit 管理,迁移文件在 apps/control-server-ts/drizzle/(SQL 文件 + meta/ 快照):
bash
cd apps/control-server-ts
npm run db:generate # drizzle-kit:src/schema.ts → drizzle/*.sql应用方式见 apps/control-server-ts/README.md:drizzle-kit 在 Worker 外部直连源 PostgreSQL 跑(DATABASE_URL=postgres://... npm run db:migrate,不经 Hyperdrive);生产实际手法也曾用 ssh 管道把迁移 SQL 灌进 PG 容器(cat drizzle/<file>.sql | ssh <pg-host> "docker exec -i <pg-container> psql ...",绕开本机到公网 PG 的自签 TLS 限制)。
Python 温备面:Alembic
- 后端库是 PostgreSQL(ASN 等列用
BigInteger——DN42 ASN 超 int32)。 - 启动建表:control-server 启动
Base.metadata.create_all建缺失表(不改已存在表);现已与 Alembic 等价——alembic 链补全后可从空库一路upgrade head跑通,产出与create_all逐表逐列一致。 - 规范迁移:
migrations/与 control-server 共享DN42_CONTROL_DATABASE_URL(migrations/env.py)。
bash
export DN42_CONTROL_DATABASE_URL=postgresql+asyncpg://user:pass@host/db
alembic upgrade head # 升到最新
alembic current # 看当前版本
alembic history # 看迁移链
alembic downgrade -1 # 回退一步(注意删列类多为不可逆)迁移清单与各自作用见 ../reference/database.md。
create_all 库的坑(历史)
若某控制面历史上用 create_all 起库(没走 alembic),跨 schema 升级时 create_all 不会改既有表,新迁移里的"删列/改列"也不会自动应用——需要手动 ALTER TABLE ... DROP COLUMN ...(如 rpki_unknown),或 alembic stamp 对齐版本后再 upgrade。
该坑主要存在于早期 SQLite + create_all 起库期。现在 alembic 链已修到从空库可跑通且与 create_all 完全等价,新部署用 docker 全栈方案(PostgreSQL)+
create_all/alembic upgrade head任一即可,schema 一致。SQLite→PostgreSQL 整库迁移用docker/migrate_sqlite_to_postgres.py。
FOR UPDATE + lazy-joined 关系的坑(SQLite→PostgreSQL)
Node.dns_group 是 lazy="joined" 的可空关系,所以 session.get(Node, …, with_for_update=True) 会发 SELECT … FROM nodes LEFT OUTER JOIN dns_groups … FOR UPDATE。SQLite 直接忽略 FOR UPDATE 子句,所以一直没暴露;切到 PostgreSQL 后 asyncpg 报 FeatureNotSupportedError: FOR UPDATE cannot be applied to the nullable side of an outer join,导致所有 materialize / 世代回滚 / agent WG 公钥上报全部 500(provision、改接口、改拓扑、注册都被卡死)。
修复:行级锁限定只锁 nodes 主表——with_for_update={"of": Node}(生成 FOR UPDATE OF nodes),不去锁外连接的可空侧。已统一修在 services/materializer.py、services/generations.py、services/wireguard_keys.py 三处。
通用规律:在 PostgreSQL 上对带
lazy="joined"可空关系的实体做with_for_update时,必须用of=限定到非空主表,否则报上述错误。新增此类锁查询时照此处理。