在现有服务旁增加新服务
假设 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 公网开放端口:
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 作为唯一公网入口,而不是给每个容器随意发布端口。
三种网络入口
需要跨 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。
监控管理界面使用本地转发隧道,不增加公网路由:
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 绑定还不足以证明服务已经隔离。
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 成功连接,都应判定为验收失败。
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 和有边界的回滚步骤。