跳到主要内容

在现有服务旁增加新服务

假设 VPS 已运行一个 Discord Bot,现在想加入 RSSHub、个人 API 或网页。核心问题不是“Docker 能不能再跑一个容器”,而是怎样让每个服务的配置、生命周期、网络和数据边界保持清楚。

推荐:一项应用一个 Compose 项目​

例如可以把各应用分别放在:

  • /srv/discord-bot/:compose.yaml、不进 Git 的 .env 和 data/;
  • /srv/rsshub/:compose.yaml 和 .env;
  • /srv/example-api/:compose.yaml 和 data/;
  • /srv/caddy/:compose.yaml 和 Caddyfile。

Compose 默认用目录名作为 project name,因此这些项目的容器、默认网络和资源名称会自然分组。也可以在 Compose 顶层明确写 name: discord-bot。独立项目的优点是:更新 Bot 不会顺手重建 RSSHub,日志和回滚范围也更清楚。

一个巨大的全局 compose.yaml并非永远错误,但会把不相关服务绑在同一生命周期里。单机个人服务器从独立项目开始,通常更容易理解。

先判断服务是否需要入站端口​

Discord Gateway Bot 通常主动建立到 Discord 的持久 WebSocket 出站连接。仅使用 Gateway 的 Bot 一般不需要在 VPS 公网开放端口:

/srv/discord-bot/compose.yaml
name: discord-bot

services:
bot:
image: ghcr.io/example/discord-bot:<pinned-version>
restart: unless-stopped
env_file:
- .env
volumes:
- ./data:/app/data

如果 Bot 同时接收 HTTP interactions、webhook 或提供管理面板,它才可能需要入站 HTTP。此时优先让 Caddy 作为唯一公网入口,而不是给每个容器随意发布端口。

三种网络入口​

服务类型建议入口示例
纯出站 worker/Bot不写 portsDiscord Gateway Bot、定时抓取器
仅管理员访问绑定 loopback 或 Tailscale127.0.0.1:3000:3000,或仅 tailnet
公网站点/APICaddy 反向代理,经 80/443不直接公开应用端口

需要跨 Compose 项目让 Caddy 访问应用时,可建立一个明确命名的外部 Docker 网络:

docker network create ingress

各项目声明使用它:

networks:
ingress:
external: true

只把需要被代理的服务接入 ingress。数据库和缓存应留在各自项目的内部网络,不发布数据库端口到公网。

已验证的共享入口模型​

这台 VPS 的 Phase 2 与 Phase 3 已经把上述原则落地为一个可复用模型:

互联网 -> 主机 TCP 80/443 -> Caddy -> infra-edge -> RSSHub:1200
WSL2 -> Tailscale SSH -> VPS 127.0.0.1:3001 -> Uptime Kuma

Caddy 是唯一绑定公网主机地址的 Docker 服务。可复用的外部桥接网络命名为 infra-edge;现在 Caddy 与 RSSHub 都接入,Caddy 通过别名 rsshub-prod 找到 RSSHub,而 RSSHub 不发布任何主机端口。Uptime Kuma 刻意不接入公网代理路径,只发布精确绑定 127.0.0.1:3001:3001。

可达范围接受的主机端口
公网Caddy 占用 TCP 80 与 TCP 443
仅 tailnetTCP 22,只经 tailscale0
仅 VPS loopbackUptime Kuma 占用 TCP 3001
仅 Docker 网络RSSHub TCP 1200,不发布到主机
其他不允许任何 Docker 主机端口发布

监控管理界面使用本地转发隧道,不增加公网路由:

[LOCAL: WSL2]
ssh -i "$LOCAL_SSH_PRIVATE_KEY" \
-o IdentitiesOnly=yes \
-L 3001:127.0.0.1:3001 \
"$VPS_USER@$TAILSCALE_IPV4"

然后打开 http://127.0.0.1:3001。这条隧道让管理入口保持私有,公开文档也不需要记录密码、cookie 或管理员身份。

UFW 不能代替 Docker 暴露面审计​

UFW 保持 active 很重要,但这并不能证明 Docker 发布端口是私有的。Docker 官方防火墙文档说明,已发布容器端口的流量会在到达 UFW 常用链之前被转发。因此 Compose 中的绑定本身就是安全边界的一部分:仅限 loopback 的服务必须明确写出 127.0.0.1,并分别从 Docker、主机监听和另一台机器验证结果。

复用这种隔离方式前,先检查 Docker Engine 版本。Docker 文档指出,在 28.0.0 之前的版本中,同一二层网段内的其他主机能够访问发布到 localhost 的端口。在这些版本上,仅有 127.0.0.1 绑定还不足以证明服务已经隔离。

[REMOTE: VPS]
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Status}}'
sudo ss -lntup
sudo ufw status verbose
sudo ufw status numbered
docker network inspect infra-edge

KUMA_ID=$(docker compose -f /srv/uptime-kuma/compose.yaml ps -q uptime-kuma)
docker inspect "$KUMA_ID" --format '{{json .HostConfig.PortBindings}}'

RSSHUB_ID=$(docker compose -f /srv/rsshub/compose.yaml ps -q rsshub)
docker inspect "$RSSHUB_ID" --format '{{json .HostConfig.PortBindings}}'

Kuma 被接受的检查结果应为:

{"3001/tcp":[{"HostIp":"127.0.0.1","HostPort":"3001"}]}

RSSHub 被接受的主机绑定结果是 {}。docker ps 单独显示 1200/tcp 只表示容器内部监听,不代表主机发布。

Caddy 镜像端口列表里出现 443/udp 或 2019/tcp,本身不代表主机已经发布这些端口。只有 Compose 映射、docker inspect 或主机监听显示 HostIP/HostPort 绑定时,才算对主机暴露。

还要从 WSL2 同时验证允许路径和拒绝路径。公网 IP 的 3001 返回响应,或公网 IP 的 SSH 成功连接,都应判定为验收失败。

[LOCAL: WSL2]
curl --fail https://infra.example.com/healthz
curl --connect-timeout 5 --max-time 7 http://"$VPS_PUBLIC_IPV4":3001/
curl --connect-timeout 5 --max-time 7 http://"$VPS_PUBLIC_IPV4":1200/
curl --connect-timeout 5 --max-time 7 http://"$TAILSCALE_IPV4":1200/
ssh -o ConnectionAttempts=1 -o ConnectTimeout=5 \
"$VPS_USER@$VPS_PUBLIC_IPV4"

Phase 2 已经用一次完整 VPS 重启证明主机基线。新加入应用时应先做范围更小的受控重启:只重启对应 Compose service,等待健康状态,再复验 HTTP、访问控制、Docker 绑定和依赖服务。只有后续任务确实需要主机级恢复证据时,才再次重启整台 VPS。

密钥与配置​

Discord bot token 是高价值秘密:

  • 放在权限受限的 .env或更合适的 secrets 管理中;
  • .env加入 .gitignore,不要提交到仓库;
  • 不在 docker compose config、截图或日志分享中暴露展开后的值;
  • 怀疑泄露时在 Discord Developer Portal 轮换,而不是仅从文件删除。

示例:

cd /srv/discord-bot
touch .env
chmod 600 .env

笔记只写变量名,例如 DISCORD_TOKEN,不写真实值。

新增服务的固定流程​

1. 盘点资源和冲突​

docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
docker network ls
docker volume ls
ss -lntup
free -h
df -h /

确认端口未占用、内存和磁盘有余量,并知道新服务的数据位置。

2. 创建独立目录​

mkdir -p /srv/new-service/data
cd /srv/new-service

3. 审查 Compose 的最终配置​

docker compose config --quiet
docker compose config --services
docker compose config --images

docker compose config可能展开环境变量;不要把完整输出复制到公开渠道。

4. 拉取并启动​

docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail 100

生产环境优先固定明确版本或 digest,不盲目依赖 latest。镜像更新可能包含不兼容变化。

5. 验证服务自己的健康​

容器显示 running只说明主进程没退出。还要验证业务行为:Bot 是否成功连接 Gateway 并响应测试命令,HTTP 服务是否通过 Caddy 返回预期内容,数据是否写入预期卷。

6. 记录回滚点​

更新前记录当前镜像版本和 Compose diff。回滚通常是恢复旧版本、再次 docker compose up -d,并恢复兼容的数据;如果迁移改变了数据库格式,仅改回镜像可能不够。

单独操作每个项目​

cd /srv/discord-bot
docker compose ps
docker compose logs -f --tail 100
docker compose pull
docker compose up -d

cd /srv/rsshub
docker compose ps

也可在任意目录使用:

docker compose -f /srv/discord-bot/compose.yaml ps

不要在 /srv顶层随手运行 docker compose down,也不要用 docker system prune -a解决普通磁盘或部署问题;前者可能针对错误项目,后者会做大范围清理。

更新一个服务而不碰其他服务​

cd /srv/discord-bot
docker compose pull bot
docker compose up -d --no-deps bot
docker compose ps
docker compose logs --tail 100 bot

这会只重建指定 service。依赖、数据库迁移和版本说明仍需逐项检查,--no-deps不是通用安全开关。

什么时候再考虑合并​

只有两个组件共享同一发布周期、必须一起启动/回滚,并且由同一套配置拥有时,才适合放入同一个 Compose 项目。例如应用与它专用的 worker 可以同项目;Discord Bot 与完全独立的 RSSHub 通常不需要合并。

服务器逐渐增长时,清晰的边界比少几个 YAML 文件更省事。容器很多并不可怕,没人知道哪个命令会重启谁才可怕。

RSSHub 生产影子部署 是这套流程的真实范例:官方固定摘要镜像、一个受保护的 env 文件、内存缓存、infra-edge、零主机端口、认证 canary 和有边界的回滚步骤。

探索关联打开关联网络