跳到正文
Xiaomai个人博客
教学

把旧项目搬进 Dokploy:数据迁移、自动部署和 Zero Downtime

把已经在跑的项目搬进 Dokploy,旧数据怎么带走,私密 Gitea 仓库怎么自动部署,Tunnel 又怎样交给 Traefik 分流。这次从备份演练、正式切换到滚动发布实测,一步步记录下来。文中项目身份和配图均已匿名化。

把旧项目搬进 Dokploy:数据迁移、自动部署和 Zero Downtime的封面

这篇文章想解决一个很实际的问题:家里的服务器上已经有一套在用的项目,现在想搬进 Dokploy,怎样把旧数据一起带过去,再让以后的更新省一点事。

如果你也正好卡在这里,希望读完之后,你可以自己完成这几件事:搬走项目和数据库;把域名接进 Traefik;让 Gitea 的 main 分支推送触发部署;最后,验证新环境真的接手了,才关掉旧的。

我之前写过一篇 Cloudflare Tunnel 的配置笔记,这次算是接着往下折腾。

之前我的做法很直接:项目跑在旧 Server,Tunnel 对着它的端口。能用,没什么问题。但项目一多,每次改完都要上服务器处理,还是有一点烦。

所以这次我想把部署交给 Dokploy。理想情况就是:代码写好,push 上去,后面的构建和发布让它自己做。

(当然,旧的数据也要在。总不能搬完家,把家具全部留在原地。)

隐私说明:本文的项目名、仓库、域名、服务器地址、端口与路径均为匿名示例。配图也重新绘制,不含真实项目界面、业务内容或部署凭据。请按自己的环境替换示例值。

1. 先看清楚旧服务器到底在跑什么

我没有一上来就在 Dokploy 按 Deploy。先检查旧环境,把运行中的版本和数据位置记下来。

这里尤其要分清楚:Repo 里的代码,和服务器正在运行的代码,不一定是同一个版本。

可以先看这些:

# 在旧项目目录中查看
git status --short
git branch --show-current
git rev-parse HEAD

# 查看正在运行的容器,以及实际使用的镜像
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}'

再确认数据库版本、挂载的数据卷、上传文件位置,以及有哪些后台任务会写数据。环境变量也要核对,但不要把包含密码的完整输出贴进 Blog 或聊天。

这次数据库是 PostgreSQL 15,新环境也继续用 PostgreSQL 15,先把迁移范围控制住。换机器和升级数据库大版本,没有必要挤在同一次操作里。

另外,资料不一定都在项目目录下。有些附件存在数据库里,有些项目把文件放在 volume,还有一些会把状态放在独立目录。只 clone Repo,拿到的通常只是程序。

2. 我为什么拆成两个 service

最开始看 Dokploy 的时候,很容易觉得:一个项目,放一个 Compose 不就好了?

如果只是要跑起来,确实可以。但我还希望以后更新网站时,数据库和后台任务继续工作,所以最后分成这样:

Dokploy 服务 放什么 什么时候更新
demo-web,Application 网站和 API main 推送后自动构建、滚动发布
demo-infrastructure,Compose PostgreSQL 和后台 Worker 需要升级这些组件时单独处理

匿名服务配置示意:网站与数据库、后台任务分开管理

上图是重新绘制的示意界面。两个 service 各有职责;Web 的两个副本,属于同一个 Application。

这里的重点是:网站发布不需要顺便重启数据库。数据库用独立的持久卷保存数据,Worker 则先保留单实例,避免一个任务被重复执行。

这次后台组件先迁移了经过验证的运行镜像。它没有跟着 Web 的 push 自动升级,以后改到 Worker 时,再单独构建、测试和发布。

3. 搬数据库,先演练,再做最终切换

我先做了一份在线备份,在新服务器恢复到临时数据库里。确认数据库能起来、应用能读取原有资料之后,才安排最后的切换。

PostgreSQL 这里使用逻辑备份:

# 旧服务器;容器名、数据库用户和库名都要换成自己的
umask 077
docker exec demo-db-old \
  pg_dump -U demo_user -d demo_db -Fc > final.dump

# 记录备份校验值
shasum -a 256 final.dump

-Fc 生成的是给 pg_restore 使用的备份格式。把文件传到新服务器后,再比一次 SHA-256,先确认传输前后是同一份文件。

最终切换时,我做了一个短暂的停写窗口:停止旧应用和会写库的 Worker,再重新导出最终备份。否则旧站一边接收新资料,新站一边恢复,恢复完成的那一刻就可能已经落后了。

新数据库准备好以后,再导入。下面这段只适用于首次导入的空数据库,已经上线的数据库不要重复执行:

docker exec -i demo-db-new \
  pg_restore -U demo_user -d demo_db \
  --no-owner --no-privileges \
  --single-transaction --exit-on-error < final.dump

恢复时还没有启动新网站和 Worker。这样就有一个比较干净的时间点,能拿旧库和新库逐项对照。

我这次检查了每张业务表的记录数、内容摘要,以及序列值。附件的二进制内容也参与核对。不能只看首页打开了,就当成数据搬完了。

还有一个容易漏掉的地方:数据库之外的密钥,也可能是数据的一部分。 如果旧资料需要某个加密 Key 才能读取,就要把原 Key 安全带过去。随手生成一个新的,数据库虽然在,内容却可能解不开。

数据库密码可以重新配置,但应用和 Worker 的连接信息要同步更新。真正的值放在 Dokploy 的运行环境或权限受控的配置文件中,不放进 Git,也不带进 Docker 构建。

4. 先把新站跑通,再接域名

新服务器上,我先让基础 Compose 启动 PostgreSQL 和 Worker,再部署 Web Application。

示例网络叫 demo-network,数据库在里面有一个固定别名 demo-db,应用用这个名字连接数据库:

DATABASE_URL=postgresql://demo_user:REPLACE_WITH_PASSWORD@demo-db:5432/demo_db
HOST=0.0.0.0
PORT=3000
PUBLIC_BASE_URL=https://app.example.com

实际密码只填在运行环境中。上面这段是格式示例,密码里有特殊字符时,也要按连接 URL 的规则处理。

数据库端口没有发布到宿主机。Web 和 Worker 从内部网络连接它就够了。

为了方便在家里检查,我另外保留一个 LAN 入口。示例配置如下:

配置 示例值
Published port 8080
Target port 3000
Publish mode Ingress
Replicas 2

这样内网访问 http://新服务器地址:8080,就能到网站。

如果你还需要在这个 HTTP 地址登录,记得检查 Cookie 的 Secure 设置:带 Secure 标记的 Cookie 需要 HTTPS。我的环境因为要兼顾 LAN HTTP,作了对应设置;如果你的所有访问都走 HTTPS,就应该保持 Secure 开启。

5. Tunnel 接 80,Traefik 再按域名分流

之前的做法,是每个项目对应一个 Tunnel 上游端口。这次我想把入口统一到 Traefik。

匿名架构图:Cloudflare Tunnel 经 Traefik 分流到网站,数据库和 Worker 独立运行

请求经过的路径就是:

app.example.com
    → Cloudflare Tunnel
    → 新服务器的 localhost:80
    → Traefik 按域名匹配
    → demo-web 的容器端口 3000

在 Dokploy 的 Web Application 里添加 Domain:

配置 示例值
Host app.example.com
Path / Internal Path /
Container port 3000
Traefik 入口 HTTP / web

然后让这个域名进入新服务器对应的 Tunnel,上游填 http://localhost:80。公网访问仍然用 HTTPS,这里的 HTTP 是 cloudflared 到本机 Traefik 的连接。

有两个小细节,我这次也踩到了。

第一,localhost 是相对于 cloudflared 所在的环境。 这里 cloudflared 跑在宿主机,所以 localhost:80 是宿主机的 80。如果它跑在另外一个容器里,就要确认那个容器实际能连接到 Traefik,不能原样照抄。

第二,Web 要同时加入两个网络。

  • demo-network:让 Web 连接数据库。
  • dokploy-network:让 Dokploy 的 Traefik 连接 Web。

我一开始只接了项目网络,结果内网端口可以打开,域名却是 502。补上 dokploy-network,再把设置保存进 Dokploy,域名入口才正常。

另外,Traefik 是靠请求里的 Host 来分流的。Tunnel 不要把所有项目的 Host 强行改成同一个值。域名也要有对应的 Tunnel/DNS 路由,单单填一个 localhost:80,不会自动接管所有子域名。

本地管理的 Tunnel 可以参考 Cloudflare 的配置文件说明;在 Dashboard 管理的 Tunnel,就在对应的已发布应用中设置域名和 Service URL。

6. 私密 Gitea 仓库,也可以自动部署

仓库改成 private,不影响这个流程。关键是 Dokploy 要有读取代码的权限。

我给这个仓库单独配了一把只读 Deploy Key:公钥放进 Gitea 仓库的 Deploy Keys,私钥交给 Dokploy 的 SSH Keys,再让 Web Application 选择这把 Key。

示例 Git 地址:

ssh://[email protected]:22/example/demo-app.git

SSH 地址和端口要填 Dokploy 服务器实际能到达的地址。浏览器能打开 Gitea 的 HTTPS 页面,不代表 Git SSH 也一定通。主机指纹也要先核对,再加入 known_hosts。

Application 里选择 main,按项目的 Dockerfile 设置构建路径。运行密钥留在运行环境,不要为了省事把整个 .env 复制进构建上下文。

接着设置自动部署:

  1. 在 Dokploy 的 Web Application 开启 Auto Deploy。
  2. 到 Deployments 复制 Webhook URL。
  3. 在 Gitea 的仓库 Settings → Webhooks 添加它。
  4. 选择 Push Events,分支过滤为 refs/heads/main。
  5. 发送 Test Push Event,检查投递响应,再到 Dokploy 看是否真的开始拉取和构建。

Webhook URL 本身带着触发部署的能力,所以这里不放截图,也不要把完整地址贴出来。

这部分的操作位置可以对照 Dokploy 的自动部署说明。我这次连起来的是自动构建和发布;还没有额外接一条 Gitea Actions 流水线去跑完整测试,提交前的检查仍然要做。

7. 自动部署之后,再处理 Zero Downtime

自动部署只是替我按了 Deploy。如果新容器还没有准备好,旧容器就退出,用户还是会遇到短暂的 Bad Gateway。

所以我给 Web 配了两个副本,再到 Advanced → Swarm Settings 设置更新策略和健康检查。

更新策略的关键配置:

{
  "Parallelism": 1,
  "Delay": 10000000000,
  "FailureAction": "rollback",
  "Monitor": 30000000000,
  "MaxFailureRatio": 0,
  "Order": "start-first"
}

意思是一次更新一个副本,先启动新版本;出现被检测到的更新故障时,按策略回滚。上面的时间单位是纳秒,例如 10000000000 就是 10 秒。

健康检查也要检查到真正的依赖。这个例子要求 /api/health 返回 JSON,并且 database_connected 为 true:

{
  "Test": [
    "CMD", "python", "-c",
    "import json,urllib.request; d=json.load(urllib.request.urlopen('http://127.0.0.1:3000/api/health',timeout=3)); assert d.get('database_connected') is True"
  ],
  "Interval": 5000000000,
  "Timeout": 4000000000,
  "StartPeriod": 30000000000,
  "Retries": 3
}

这是带 Python 的镜像可以用的写法。换成其他镜像,要用镜像里确实存在的工具,也要按自己的接口改判断条件。

此外,我给旧容器留了 180 秒的停止宽限时间,让应用有机会处理退出时的请求。这个设置也需要应用本身正确处理停止信号,不能单靠一个数字包办。

匿名发布流程图:main 推送、构建、健康检查与逐个替换,以及本次探测结果

这里留一个边界:这次说的 Zero Downtime,是兼容版本的 Web 滚动更新。

第一次搬数据库时,我安排了停写;以后如果要删除旧代码还在用的字段、升级数据库或修改 Worker,也要单独安排。两份 Web 还在同一台机器上,机器本身关机,它们一样会一起离线。

相关设置可以参考 Dokploy 的 Zero Downtime 文档。具体能不能平滑更新,最后还是要测自己的应用。

8. 真的发一次版本,边发边测

配置填好了,我还是不太放心。所以这次通过 Gitea 的 Test Push Event,实际触发了私密仓库拉取、Docker 构建和滚动发布。

发布期间持续检查四个目标:内网首页、内网健康接口,以及经过 Traefik 的首页和健康接口。

最终检测了 1,408 次请求,持续约 227 秒,失败为 0。

这代表这一次更新没有在探测中发现请求失败。以后代码改坏、数据库迁移不兼容,还是可能出问题,不能把一次通过当成永久保证。

除了检测 HTTP,我也确认了旧资料可读取、后台任务有新的活动记录,并在新服务器的日志里找到公网测试请求。这样才能确定访问的确实是新环境。

如果想单独检查 Traefik,示例命令是:

curl -fsS -H 'Host: app.example.com' \
  http://新服务器地址/api/health

这里特意带上 Host,因为直接访问 IP 时,可能根本没有匹配到这个项目的路由。

9. 新环境接手之后,旧站才下线

迁移期间,我暂时留了一个旧入口代理,把旧地址的请求转到新服务器,方便切换时过渡。

等新域名和 LAN 入口都验证正常后,再关闭这个代理、停掉旧应用、旧 Worker 和旧数据库,并取消旧容器的自动重启。

随后再访问新站、检查数据库连接,确认它没有偷偷依赖旧环境。新站开始接收资料以后,也补了一份新的数据库备份。

最后清理掉演练容器、临时数据库卷、演练网络和多余的环境配置。正式数据卷、旧库备份和必要的恢复资料继续保留。

旧服务器上还有其他项目,所以共享的 cloudflared 和 Gitea 都继续运行。这次只停用了旧应用入口,没有删除它原来的 Tunnel/DNS 路由,因此旧域名当时返回 502。需要彻底退役这个域名时,再单独清理对应路由,不要把整条 Tunnel 一起关掉。

还有一点要记住:新站开始有新数据以后,就不能直接启动旧快照当作回滚了。应用版本可以退,新增的数据也要一起考虑。

折腾完之后,日常更新总算可以回到比较简单的流程:本地改代码,检查,push main,再看一下部署结果。

数据库和后台任务各自待着,网站需要更新的时候再换网站。以后再搬一次,也不用靠记忆去猜当时到底配了什么。

(写下来,主要也是怕未来的自己忘记。这个概率还蛮高的。)