Skip to content

升级与数据库迁移

本文讲三件事:怎么升级 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

发版流程:

  1. bash deploy/build_wheels.sh 构建整组首方 wheel(版本 = 1.0.<git commit 数>)。
  2. 上传发布:POST /api/v1/admin/agent-releases(一次上传全部 wheel;控制面落持久卷 <releases_dir>/<version>/ 并生成含每个 wheel sha256 的 manifest.json)。
  3. 设全局目标版本:POST /api/v1/admin/agent-releases/target。此后 agent 经 WS 心跳上报自身版本,低于目标即收到 agent_update_available 门铃。
  4. agent 收铃后经已鉴权通道下载 manifest + wheel,逐字节核对 sha256,暂存 /opt/dn42-wheels,然后触发独立的 systemd oneshot dn42-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]

脚本做三件事:

  1. scp dist/*.whl 到节点本地 /opt/dn42-wheels(私有仓库 = pip --find-links 目录)。
  2. 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 不会主动升无版本约束的依赖,必须逐个点名。
  3. 重启 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 dn42

last_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 + 重启)。升级步骤:

  1. 拉新代码。
  2. 先跑数据库迁移(见下):alembic upgrade head
  3. 重建/重启 control-server。

注意迁移与代码的顺序:删列类迁移(如 drop_rpki_unknowndrop_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_URLmigrations/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_grouplazy="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.pyservices/generations.pyservices/wireguard_keys.py 三处。

通用规律:在 PostgreSQL 上对带 lazy="joined" 可空关系的实体做 with_for_update 时,必须用 of= 限定到非空主表,否则报上述错误。新增此类锁查询时照此处理。