从裸机部署到全容器化:服务器现代化改造的完整路线

本文是一份通用教程,梳理如何把一台跑着十几个裸机服务、apt 包和 systemd 单元散落各处的 Ubuntu 云主机,逐步重构成「全容器化 + 统一编排 + 自动 HTTPS + 可备份可恢复」的完整思路与常见坑。文中出现的域名、目录、端口、主机信息均为示例。

起点:裸机部署的混乱

假设你接手了一台云服务商 Ubuntu 24.04 LTS 主机,4 vCPU / 4GB 内存 / 60GB 磁盘,已经跑了半年以上,状态可以用「能跑,但没人敢动」来形容:

最要命的是没有可靠的备份与恢复路径。数据散在系统目录、各种数据库文件、配置文件里,一旦磁盘故障或误操作,损失不可估量。

目标:现代化的四个硬指标

改造前先定下四条可验收的目标:

  1. 全容器化:能用容器跑的都用 Docker 跑,运行时依赖封进镜像,宿主机只保留 Docker 引擎和极少数必须裸机的服务(如需要直接操作网络栈/内核的网络代理)。
  2. 统一编排:所有服务用 Docker Compose 描述,统一放在 /srv/stacks/ 下按服务分子目录,一条命令拉起/重建。
  3. 自动 HTTPS:反向代理层统一入口,证书自动签发续期,业务服务不再各自处理 TLS。
  4. 可备份可恢复:每日自动备份 + 定期恢复演练,备份不是「有」就行,而是「能恢复」。

第一步永远是全量备份

动手之前先备份,这是铁律。容器化重构会大量改动目录、数据库位置、网络配置,任何一步出错都可能丢数据。推荐做法:

后文会反复看到,正是这个「先备份、校验、再迁移」的习惯,能在每一步都兜住底。

目录结构与 Docker 网络规范

重构后所有 Compose 项目统一收敛到 /srv/stacks/ 下,一个服务一个子目录:

/srv/stacks/
├── proxy/          # 反向代理 + ACME 自动 HTTPS
├── auth/           # 统一登录门户
├── web-a/          # 业务应用 A(如 PHP / Python 应用)
├── web-b/          # 业务应用 B
├── db/             # 数据库(容器化)
├── cache/          # 缓存(容器化)
├── monitor/        # 服务监控
├── logview/        # 容器日志查看
├── scripts/        # 运维脚本(备份/同步/健康检查)
├── backups/        # 每日自动备份落地目录
├── secrets/        # 密钥(不纳入版本控制)
└── logs/

网络方面,所有需要互访的容器挂到同一个自定义 Docker 网络里,靠服务名互相解析(如 PHP 应用连 mariadb、redis),端口不再往宿主机上乱映射。只有真正需要对外暴露的入口(Traefik 的 80/443)才映射到宿主机。

反向代理层:从 Nginx 切到 Traefik

原来宿主机上的 Nginx 手动维护着一堆 server 块和证书路径,续期靠 cron + certbot,改一次配置提心吊胆。更省心的做法是整体换成 Traefik v3.7.12:

切换建议渐进进行:先让 Traefik 与旧 Nginx 并存,把域名一个个迁到 Traefik 的标准端口上,验证通过后再停掉裸机 Nginx。最终所有对外域名都走 Traefik:

域名(示例) 后端服务(示例)
app.example.com 某个业务 Web 应用
status.example.com 监控面板
logs.example.com 日志查看器
auth.example.com 统一登录门户

关于 Traefik 的两个大坑——Docker Provider 与 File Provider 的路由竞争导致认证被绕过、以及某日志查看器的 Service Worker 撞上 ForwardAuth——会在系列第二、三篇里专门展开。

数据层容器化:MariaDB / Redis 与数据迁移

某个 PHP 业务应用依赖的 MariaDB 和 Redis 原来是裸机 apt 装的。迁移步骤:

  1. 先在容器里起 MariaDB 10.11 和 Redis 7,挂命名卷做持久化。
  2. 从裸机数据库 mysqldump 导出,导入容器实例。
  3. 改业务应用的连接配置指向容器服务名 mariadb / redis。
  4. 双跑验证一段时间,确认无异常后停掉裸机数据库服务。

Django 业务站用的是 SQLite,这里有一个非常隐蔽的坑:db.sqlite3 被 COPY app/ 打进了镜像,而 compose 里挂的数据卷指向的却是一个从未被使用的空目录。结果每次 compose up --build 都会用镜像里的旧库覆盖容器可写层,运行期 migrate / 写入的数据(比如新建的博客文章)直接丢失,页面报 no such table 500。

根治办法是把数据库落到命名卷:

# settings.py:数据库路径改到卷挂载点
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db' / 'db.sqlite3',   # 写入 app-db 卷
    }
}
# Dockerfile:预建目录,并在 .dockerignore 里排除 db.sqlite3,不再烘焙进镜像
WORKDIR /srv/app
RUN mkdir -p /srv/app/db

迁移流程严格遵守「备份 live 库 → PRAGMA integrity_check 校验 → 播种到卷 → 改 settings 指向卷 → 重建容器 → 逐表记录数比对」。这样做可以做到全表记录数 100% 一致、零丢失,顺带还能让镜像体积明显瘦身。教训:SQLite 必须放命名卷,绝不能躺在镜像内或容器可写层,否则重建即丢数据。

业务服务容器化

管理与监控工具容器化

除了业务应用,监控与日志类工具也可以一并容器化,遵守同样的「数据落命名卷 + 只经反代暴露」原则:

有些带「安全登录 / 可信域名」机制的管理后台,经反代访问时会返回 "Access Temporarily Unavailable" 之类的错误。这通常不是路由配错了,而是后台自身的安全机制在拦截——需要在它的配置里显式声明可信域名(例如把 BindDomain 设为反代域名)后重启服务。不同产品的配置项不同,遇到报错先查它的安全机制文档。

端口收敛与旧环境清理

容器化完成后,对外的 TCP 端口收敛到少数几个,攻击面大幅缩小(下表端口号均为示例):

端口(示例) 服务
22 SSH
80 Traefik(跳转 HTTPS)
443 Traefik(承载所有域名)
自定义高位端口 确有需要、且无法放进容器网络的少量服务

UDP 端口同理,只保留业务确实需要的那些。清理旧环境时,逐个 systemctl stop && disable 停掉裸机的 Nginx、数据库、旧 PHP 服务,确认容器版本接管后再卸载 apt 包。需要直接操作网络栈/内核的服务评估后可以保留裸机——全容器化不是教条,该裸机的就裸机。

每日备份 + 恢复演练

最后一块拼图是备份。策略可以是每日凌晨 3 点自动备份,保留 7 天日备 + 周备,落到 /srv/stacks/backups/。但只备份不演练等于没备份——建议定期做恢复演练:把备份拉到一台干净环境,按文档一步步还原,验证服务能起来、数据记录数对得上。演练中暴露的问题(比如某个卷没被备份脚本覆盖)比真出事时才发现要好得多。

经验总结

回看整个重构,几条经验最值钱:

  1. 备份优先:任何破坏性操作前先全量备份并校验,「先备份、校验、再迁移」形成肌肉记忆。
  2. compose 目录规范:一服务一目录,统一根路径,配 .dockerignore 明确什么进镜像、什么落卷。数据永远落命名卷,不进镜像、不留在可写层。
  3. 健康检查与渐进迁移:新旧并存、逐个域名切换、验证通过再停旧的,不要一把梭。
  4. 端口收敛:容器互访走内部网络靠服务名解析,只有入口代理对外暴露,其余端口一律关闭。
  5. 备份要能恢复:定期恢复演练,用记录数比对确认零丢失。

下一篇讲 Traefik 自动 HTTPS 与 Authelia 统一登录门户的实践。