Self-hosted GitOps Pipeline: Komodo + Renovate

lkw123 lkw123 #gitops#komodo#docker

假如一台服务器上跑着十几二十个自托管服务:订阅记账、电子书库、照片备份、媒体服务器,外加一堆小工具,此时单纯靠手动维护,用不了多久就会变成一件头疼事。我们可以考虑把它们交给 Git 管理:

  • 每个服务写成一个 Compose 文件、pin 到固定的镜像版本
  • 由 Komodo 从仓库自动部署
  • 由 Renovate 监测上游镜像、发现新版本后提 PR 升级

overview of the article

若是单靠手动管一台服务器,时间久了状态会散到四处。一个服务的真实配置同时存在于:SSH 进去敲过的命令、某个 docker-compose.yml 的当前版本、.env 里的密钥、挂载的配置文件、半年前临时改的一行参数。等到机器挂了要重建,或者只是想回忆当初为什么这么配,能依赖的只有记忆和零散的备份。

而对于镜像版本,为了快速部署好服务,往往会选择 image: app:latest,于是每次 docker compose pull 都可能拉到一个不一样的镜像,若是更新失败很难找到引发 breaking change 的小版本。而若是把版本 pin 到部署时的稳定版本,后续升级又得记着去官网翻 changelog,于是镜像一放就是两年,安全补丁和新功能也跟着一起落下。每次的操作都让系统的混乱度增加,越来越难以维护。

这源于服务器的真实状态没有一个单一、可信、带历史的来源。GitOps 正是一个好的解决方案,将 Git 仓库作为服务器状态的唯一事实来源,机器自动与其对齐。

GitOps:让 Git 成为唯一事实来源

GitOps 这个词最早来自 Kubernetes 社区,但它的思想跟编排引擎无关,落到一台跑 Docker 的服务器上同样成立。CNCF 旗下的 OpenGitOps 把它收敛成四条原则,逐条对到自托管场景上看会很清楚。

  • 声明式。只需描述系统应该是什么样,怎么一步步达成交给工具去算。一个 compose.yaml 就是声明:这个服务用哪个镜像、开哪个端口、挂哪些目录。「先 pull 再 stop 再 up」这些动作不用写,工具会自动执行。

  • 有版本、不可变。期望状态存在一个能保留完整历史的地方,也就是 Git。每一次变更都是一条带作者和时间戳的 commit,想回到上周的状态,git revert 就够了。

  • 自动拉取。有一个软件代理自动把期望状态从仓库取下来,不靠人手动 git pulldocker compose up

  • 持续对账。代理不断比对「仓库里写的」和「机器上跑的」,发现漂移就拉回来。

严格的 GitOps 工具(Flux、Argo CD)跑的是一个持续拉取加不断对账的循环,适合大规模 Kubernetes 集群。一台个人服务器用事件驱动就足够了:每次推送触发一次同步,省资源也好理解。Komodo 正好提供这种事件驱动的能力。

这样带来的好处很直接。每一次变更可追溯、可回滚;机器整个没了,一次 git clone 加上密钥和数据的备份就能重建;两个月后回头看某个奇怪的参数,git blame 直接指出是哪次提交,配套的 commit message 也写了为什么。

gitops-loop

Komodo:Docker Compose 连接 Git 控制平面

Komodo 是一个开源的容器构建与部署平台,用来在一台或多台服务器上管理 Docker。很多人用过 Portainer 来管容器,Komodo 站在类似的位置上,提供 Web UI、API、告警和权限管理,把每个 Compose 项目当成一个叫 Stack 的资源来管理:查看状态、看日志、改配置、一键部署都在界面里完成。

它的部署模型分成两部分:

  • Core 是大脑,跑 Web UI、API 和一个数据库(默认 MongoDB),存放所有资源定义、变量、用户和审计日志。
  • Periphery 是手脚,是装在每台被纳管服务器上的一个轻量代理,Core 通过它在那台机器上执行真正的 Docker 命令。一个 Core 可以连任意多台跑着 Periphery 的服务器,所以同一套控制平面能横跨好几台机器统一管理。

komodo-architecture

Core 是控制平面,Periphery 是真正在每台机器上动手的代理

Komodo 真正适合 GitOps 的地方,是这些资源本身可以用代码声明。它提供一种叫 Resource Sync 的资源:把所有 Stack 的定义写进一个 TOML 文件、提交进 Git,Komodo 读这个文件,把里面声明的资源在服务器上创建出来并保持一致。这意味着「有哪些服务、各自怎么配」这件事也通过 Git 来管理,跟代码一样有版本、有历史。

sync.toml 里,每个服务对应一个 [[stack]] 块:

[[stack]]
name = "wallos"
description = "https://wallos.example.com"
deploy = false
tags = ["app"]
[stack.config]
server = "server-a"
linked_repo = "homelab-infra"
run_directory = "stacks/wallos"
file_paths = ["compose.yaml"]

字段含义并不复杂。server 指定部署到哪台被 Komodo 纳管的服务器(也就是哪个 Periphery);linked_repo 指向一个在 Komodo UI 里配好的 Repo 资源,Git 账号、仓库地址、访问令牌这些实例相关的东西因此留在 Komodo 里,仓库这个文件里只出现一个名字,干净、可移植;run_directoryfile_paths 指定去仓库的哪个子目录、读哪个 Compose 文件,deploy = false 则把部署这件事从 sync 里摘出去。

对应的 Compose 文件就是一份标准的 compose.yaml,若是需要脱离 Komodo,直接 docker compose up -d 就能跑,没有生态的锁定。Komodo 只是在外面包了一层声明、调度和可观测性。

services:
wallos:
container_name: wallos
image: bellamy/wallos:4.9.5
ports:
- "20000:80/tcp"
environment:
TZ: Asia/Shanghai
volumes:
- /srv/wallos/db:/var/www/html/db
- /srv/wallos/logos:/var/www/html/images/uploads/logos
restart: unless-stopped
networks:
default:
name: wallos

Komodo 自身的安装是这套体系里唯一一处手动操作。它由 Core、Periphery 和一个数据库组成,没法部署自己,所以这部分按官方手册在主机上 docker compose up -d 拉起一次即可,之后所有业务服务都归 Git 管。同样的,也可以把 Komodo 自己的 Compose 和 .env 也以脱敏形式留一份在仓库里,作为版本记录和灾备依据。

komodo-stacks

整套基础设施声明放在一个 Git 仓库里,不按服务或机器拆成多个,在一个仓库内可以囊括所有服务的配置、一套 Renovate 规则、一条部署流水线,对于跨服务的改动,一个 commit 就可以完成。

仓库文件树
homelab-infra/
├── sync.toml # 所有 Stack、Procedure、Server 的声明
├── renovate.json # Renovate 检测规则
├── ports.md # 端口登记表
├── stacks/ # 一个服务一个目录,目录名就是 Stack 名
├── wallos/
└── compose.yaml
└── immich/
├── compose.yaml
└── config.example.yaml # 带密钥的配置,只留脱敏样板
└── komodo/ # 控制平面自身,同样以脱敏样板留档
├── compose.yaml
└── compose.env.example
  • stacks/ 下一个服务一个目录,目录名跟 Stack 名对齐,sync.toml 里的 run_directory = "stacks/wallos" 正是把声明指到这个目录;加服务就是新建一个目录、放进 compose.yaml,再到 sync.toml 补一个 [[stack]] 指过去。
  • 服务运行在哪台机器由每个 Stack 的 server 字段决定,所以同一个仓库天然能管多台机器,加机器也只是在 sync.toml 里多一个 [[server]] 块。

Komodo 会把这个仓库 clone 到主机的工作目录,所以仓库里只存放声明,运行态的东西则留在仓库外:

  • 应用数据在 /srv/<service>/
  • 真实密钥在 Komodo 的加密变量库、部署那一刻才落成一个 git-ignored 的 .env
  • 控制平面自己的资源定义和账号在 Komodo 的数据库里。

一次 git push 的完整历程

当发起一次推送,从 Git 走到容器的过程中,入口是 Git 仓库上的一个 push webhook。Komodo 的 webhook URL 有固定结构:

https://<HOST>/listener/<认证类型>/<资源类型>/<id 或名字>/<执行项>

认证类型是 github 等,对应平台来源;资源类型则可以是 stacksyncprocedure 等;执行项随资源类型而定。配置 webhook 时把 content-type 设为 application/json,并填上在 Komodo 里设好的 WEBHOOK_SECRET,Komodo 在收到后会校验 GitHub 带来的 X-Hub-Signature-256 签名,确认这次调用确实来自该仓库,并挡掉伪造请求。

最朴素的做法是让这个 webhook 直接指向 Resource Sync,推送即同步即部署。但服务一多,可能会暴露两个问题:

  1. 双重部署竞争。既让 Resource Sync 在推送时部署,又另设一个部署动作,两者同时开跑,会去抢同一个 Stack 的部署锁,Komodo 可能会触发 Resource is busy
  2. 新服务首推漏部署:新加的 Stack 在它的定义还没被 sync 创建出来时就被部署动作轮到,于是第一次推送起不来,得手动去 UI 点一下。

合适的做法是把「同步定义」和「执行部署」拆成有先后的两步,交由 Komodo 的 Procedure 来编排。Procedure 把多个执行项组织成若干 Stage,Stage 之间顺序执行,同一个 Stage 内的执行项并行跑,前一个 Stage 全部完成才进下一个。据此搭一个 Redeploy On Push Procedure:

Renovate / 用户 合并 PR 到 main
Git push webhook ──► Procedure "Redeploy On Push"
├─ Stage 1: RunSync "homelab"
│ 读 sync.toml,协调资源定义(创建 / 更新 Stack)
└─ Stage 2: BatchDeployStackIfChanged "*"
只部署 compose 内容真正变过的 Stack
  1. Stage 1 跑 RunSync,让 Komodo 重新读 sync.toml,把里面声明的 Stack 定义对齐到最新 commit;
  2. Stage 2 跑 BatchDeployStackIfChanged,这是 Komodo 的批量执行项,用通配符匹配资源名,只对 compose 内容相比上次有变化的 Stack 触发部署。
[[procedure]]
name = "Redeploy On Push"
[procedure.config]
webhook_enabled = true
[[procedure.config.stage]]
name = "Sync resource definitions"
executions = [
{ execution.type = "RunSync", execution.params.sync = "homelab" },
]
[[procedure.config.stage]]
name = "Redeploy changed stacks"
executions = [
{ execution.type = "BatchDeployStackIfChanged", execution.params.pattern = "*" },
]

Renovate 把版本升级变成可审阅的 PR

Renovate 是一个自动更新依赖的机器人。它定时扫描仓库,对每一处声明的依赖去上游查是否有新版本,开 PR 把版本号改到最新,并会附上 changelog。

引入 Renovate 有两种方式:

  • Mend 托管的 GitHub App:在 Marketplace 装上、授权给目标仓库,Mend 自己的基础设施按大约每小时一次的节奏临时 clone 仓库、扫描、提 PR,跑完不留存代码,不用可以修改仓库里的 CI。
  • 自托管:把 Renovate 当 npm 包、Docker 镜像,或者一个定时跑的 GitHub Action 自己运行,控制更细。

整套体系有一条隐含前提,镜像 tag 需要 pin 到固定版本,Renovate 才能去实现检测。此外,固定版本也可以让部署可复现,整套体系更稳定。

数据库和缓存则依照另一套规则,锁到大版本线

image: pgvector/pgvector:pg17
image: redis:8

一个好用的判断标准是看升级要不要人介入数据迁移,需要的话就就锁大版本线,若只是无痛补丁,则锁精确版本让 Renovate 自由提 PR。

一个最小可用的 Renovate 配置很短:

{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":dependencyDashboard", ":semanticCommits"],
"timezone": "Asia/Shanghai",
"labels": ["dependencies"],
"prConcurrentLimit": 0,
"prHourlyLimit": 0
}
  • config:recommended 是官方推荐基线,把大部分合理默认值打开。
  • :dependencyDashboard 会在仓库里自动维护一个 Dependency Dashboard issue,把所有待升级、被忽略、有冲突的项汇总在一个看板上,一眼看清全局,也能在上面手动勾选触发某个升级。
  • :semanticCommits 规定 Renovate 的 commit 和 PR 与语义化提交风格统一,整体更规范。
  • prConcurrentLimitprHourlyLimit 则是限流阀门。Renovate 默认会限制同时打开的 PR 数和每小时新建的 PR 数。当服务数量比较多时,这个限制反而拖慢升级的节奏,此时可以把两个值都设为 0。

随着服务数的增加,为减少 PR 噪音,可以通过 packageRules 进行分组和调度。比如把同一个 Stack 目录下的多个镜像合并成一个 PR,或者只在每周固定时间开 PR:

"packageRules": [
{
"matchManagers": ["docker-compose"],
"matchFileNames": ["stacks/immich/**"],
"groupName": "immich",
"schedule": ["* 0-6 * * 1"]
}
]

分组让一组关联镜像(比如 immich 的 server 加 machine-learning)一起升、一起审、一起部署,避免它们版本错配;调度把 PR 收拢到固定的时间窗口。两者配合,PR 噪音基本能压到可接受的程度。

至于自动合并,Renovate 支持给某些低风险更新(比如 digest、pin、补丁)开 automerge。而在自托管个人服务上,review 一行版本号的 bump 成本很低,却能让每次变更都心里有数,所以多数人会把审核留给自己。

renovate-pr

整套升级流程始于 Renovate 创建 PR 更新 image tag,在审核合并后,push webhook 触发 Redeploy On Push Procedure,Komodo 检测到 compose 发生变化,便会重新部署对应 Stack。

理解 Compose 的环境变量

Docker Compose 的环境变量机制是密钥注入的基础,${VAR} 这个写法在 Compose 里身兼两职:

  1. 第一职是插值。Compose 在解析 compose.yaml 这个文件时,会把文件里出现的 ${VAR} 就地替换成具体值。值从两个地方取:执行 docker compose 时的 shell 环境变量,以及项目目录下一个名叫 .env 的文件。两者都有同名变量时,shell 环境变量优先。这一步发生在容器还没创建之前,只是简单的文本替换。

  2. 第二职是给容器设环境变量,靠 environment:env_file: 两个字段:

    services:
    app:
    environment: # 直接写进容器进程的环境变量
    - TZ=Asia/Shanghai
    - DB_HOST=postgres
    - DB_PASS=${DB_PASS} # 这里的 ${DB_PASS} 是「插值」,先被替换,再设进容器
    env_file:
    - ./app.env # 从这个文件逐行读 KEY=VAL,设进容器

分界线在这里:项目目录那个 .env 文件只为「插值」服务,它自己不会自动进容器。也就是说,在 .env 里写了 FOO=bar,容器里默认看不到 FOO,除非在 environment: 里显式写 FOO=${FOO},或者把它列进 env_file:env_file 指向的文件则相反,里面的每一行都会成为容器的环境变量。

有几点需要注意,比如:

  • environment 里同名变量会覆盖 env_file 里的;
  • .env 里的 KEY=VAL 等号两边不能有空格;
  • 要在 Compose 的值里写一个字面的 $,得转义成 $$,比如健康检查里用 shell 变量:
    healthcheck:
    test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
    这里 $$POSTGRES_USER 经 Compose 转义后,传给容器内 shell 的是 $POSTGRES_USER,交给容器内 shell 在运行时展开,绕开 Compose 的提前插值。

compose-env

密钥:仓库里只留占位符

既然要把配置放进 Git,密钥就一个都不能进。守住这条线靠两套机制,都建立在 .env 插值这个入口上。

第一套处理环境变量型的密钥(数据库密码、API key)。Compose 里只写占位:

environment:
POSTGRES_DSN: "postgres://app:${APP_DB_PASSWORD}@postgres:5432/app"

真实值定义成 Komodo UI 里的 Variables & Secrets,再在 sync.toml 对应 Stack 的 environment 字段里做映射:

environment = """
APP_DB_PASSWORD=[[APP_DB_PASSWORD]]
APP_REDIS_PASSWORD=[[APP_REDIS_PASSWORD]]
"""

部署时,Komodo 把 [[ ]] 里的变量名解析成真实值,写进该 Stack run directory 下一个 git-ignored 的 .env 文件,接着 docker compose 自然读这个同目录的 .env 完成 ${VAR} 插值。整条链路下来,Git 里始终只有 ${VAR} 占位和 [[VAR]] 映射,真实密钥只活在 Komodo 的加密变量库和部署那一刻生成的 .env 里,落盘那份还在仓库克隆之外。

非密钥的配置(时区、PUID/PGID、功能开关)就直接写在 compose 的 environment: 里,提交进 Git,让服务自包含、好读。区分标准很简单:泄露了会出事的进 Komodo 变量,不会的进 compose。

第二套机制处理整份带密钥的配置文件,比如某些应用要的一个 config.yaml,里面密钥和普通配置混在一起。这种文件的真实版本放在主机的数据目录里,bind 挂进容器,跟着数据一起备份,本体留在 Git 之外;仓库里只提交一份把密钥抹掉的 config.example.yaml 当参考。还有个 Docker 小陷阱:bind 挂载单个文件时,这个文件必须在容器启动前就存在于主机上,否则 Docker 会好心地创建一个同名目录顶上去,应用一读就报错。

控制平面自己的密钥(数据库密码、首个管理员密码、webhook secret)是这套规则的唯一例外。Komodo 正是提供变量库 UI 的那个东西,自己的密钥没处安放,所以留在主机上它自己的环境文件里,git-ignored,同样以脱敏的 .example 形式在仓库里留一份结构参考。

几条约定:端口、网络、数据落盘

GitOps 给了机制,真正让几十个服务长期不乱的是一组人定的约定。下面这几条,每一条都对应过一个真实的坑。

端口顺序分配,有唯一登记表。 给每个对外服务的 host 端口定一个起始基数,比如从 20000 开始按 1 递增,每个发布到公网的服务占一个号,全部登记在仓库里一张端口表(一个 markdown 文件就够)。加服务时取下一个空号、在同一个 commit 里登记。只在网络内部通信的容器(数据库、缓存、搜索引擎)不发布 host 端口,因此不占号。这张表是端口分配的唯一事实来源,新服务该用哪个号、哪些号还空着,看一眼就知道,端口冲突这种低级故障基本绝迹。

数据用绝对路径 bind 到一个固定根目录。 这条约定背后是 Komodo 的工作方式:它会把仓库 clone 到主机上自己的工作目录里,每次重新部署都可能重新 clone。数据要是放在相对路径或命名卷里,就有跟着 clone 一起被刷掉的风险。所以持久数据一律 bind 到仓库克隆之外的一个固定根目录,比如 /srv/<service>/,用绝对路径:

volumes:
- /srv/wallos/db:/var/www/html/db
- /srv/wallos/logos:/var/www/html/images/uploads/logos

这样数据独立于仓库克隆而存在,重新 clone、重新部署都不动它。备份也因此简单:备份 /srv 就备份了全部应用数据。命名卷只在镜像对 bind 挂载权限特别挑剔、或者那份数据纯属缓存可丢时才用,因为命名卷藏在 Docker 自己的目录里,更难备份,一个 docker compose down -v 还可能误删。

每个 Stack 显式命名默认网络。

networks:
default:
name: <stack>

漏掉这一段,Compose 会按项目名生成一个 <project>_default 网络,名字带前缀、不可控。显式命名后,网络名稳定、可预期,将来要跨 Stack 互联(比如让一个服务用固定 IP 去寻址另一个)时也好对接。

镜像归属跟着各自的用户走。 不同镜像跑的用户不一样:LinuxServer 系的镜像认 PUID/PGID 环境变量,有的应用镜像以 www-data(uid 82)跑,Postgres 数据目录归 uid 999。让宿主上的数据目录属主跟随每个镜像自己的用户即可,强行压一个全局 UID 反而会和镜像打架。迁移数据时用 cp -a 保留属主和权限,Postgres 的数据目录尤其要是 999:999 且权限 700,否则它会直接拒绝启动。

迁移:把手工部署的老服务搬进来

这套体系很少是一夜建成的,多数人是把原先用 docker compose 手工部署在 /opt/<service>/ 之类目录里的服务,一个个搬进来的。

迁移前先把现状摸清楚:看老服务的 compose、镜像和 tag、发布的端口、用的是命名卷还是 bind 挂载、有哪些环境变量、密钥藏在哪。然后定 pin(应用精确版本、数据库大版本线),确认 pin 的版本就是当前在跑的版本,再认领一个端口,写好仓库里的 compose.yamlsync.toml 条目和端口登记。

这里的顺序不能反:推送会自动触发部署,所以数据和密钥必须在推送之前就准备好。 正确的切换顺序是先停老服务,把数据 cp -a 到新的 /srv 目录,在 Komodo 里建好对应的 Variables,最后才推送让它部署。顺序反了,新容器会在一个空数据目录上起来,轻则报错,重则初始化出一份新数据盖掉旧的。

切换的核心动作大致是:

Terminal window
# 停旧服务,注意不要带 -v,命名卷要留着拷
cd /opt/<service> && docker compose down
# 把每个卷拷到 /srv 的 bind 目录,保留属主和权限
# 让 cp -a 自己创建叶子目录,不要先 mkdir,否则属主会变成 root
mkdir -p /srv/<service>
cp -a /var/lib/docker/volumes/<volume>/_data /srv/<service>/<dir>
# 核对权限:Postgres 数据目录必须 999:999、700
stat -c '%n %U:%G %a' /srv/<service>/*

下面几条是搬十几个服务攒下来的、最容易翻车的地方。

有些密钥藏在数据目录里,不在环境变量里。 一些应用把自己的加密密钥写在数据目录的某个文件里:工作流工具的加密 key、聊天应用的 secret key、Git 服务的配置文件里的 SECRET_KEY 和 INTERNAL_TOKEN。这些得随数据目录原样迁过去(cp -a 保住属主和权限),key 一变,用户会被登出、加密过的数据直接解不开。

:latest 可能比版本 tag 还新。 :latest 经常跟着上游的 main 分支走,可能跑在最新 release 之前。直接照着 release tag 去 pin,反而可能是一次悄悄的降级。pin 之前核对一下:把正在跑的镜像的 digest 和候选 tag 的 digest 比一下,确认一致再切。

共享数据库最好拆成每个 Stack 自带。 早期常见的做法是好几个服务共用一个外部 Postgres,各自连 host.docker.internal:5432。给每个 Stack 配一个自带的 Postgres 更清爽:隔离性更好、每个应用独立的版本和备份、没有共享的单点、host 的 5432 也不用再对外发布。改动其实很小,数据库名、用户、密码全保持原样(密码存成 Komodo Variable),应用配置里只有连接的 host 从 host.docker.internal 变成自带服务的名字(比如 app-postgres)。迁数据用逻辑 dump 最稳,它跨大版本可移植:

Terminal window
# 从共享库里只导出这一个数据库
docker exec <shared-pg> pg_dump -U postgres -Fc --no-owner --no-acl -d <db> > <db>.dump
# 用一个一次性容器初始化新数据目录并恢复,恢复完删掉容器,数据留在盘上
docker run -d --name tmp-pg -e POSTGRES_USER=<u> -e POSTGRES_PASSWORD=<pw> \
-e POSTGRES_DB=<db> -v /srv/<svc>/postgres:/var/lib/postgresql postgres:18-alpine
# 等就绪后 pg_restore,再 docker rm -f tmp-pg

需要小工具,就起一个 sidecar。 别往应用的官方容器里手装东西,那些东西每次拉新镜像就没了。把额外逻辑挪进一个本地构建的小服务,和官方镜像并排放:

services:
app:
image: ghcr.io/vendor/app:v1.80.0 # 官方镜像,保持干净
app-notify:
build: ./notify # 本地构建的小服务,应用通过服务名在网络内访问

再给这个 Stack 加 extra_args = "--build",部署时就会 docker compose up -d --build 把 sidecar 一起构建出来。官方镜像不被污染,Renovate 还能继续 bump 它的 tag 和 sidecar Dockerfile 的 FROM

还有几条零碎但要命的:匿名卷里也可能存着真实数据,用 docker inspect <容器> --format '{{json .Mounts}}' 把挂载全列出来,别只看 compose 文件;joined 到某个共享网络(带固定 IP)的服务迁移后得留在那个网络上;搜索引擎的索引(Elasticsearch、Meilisearch)是派生数据,迁移时可以不管,恢复后重建一遍即可;用了 network_mode: host 或 privileged 的特殊应用不套用端口和命名网络那套约定,只迁配置目录、保留它原本的网络模式和设备挂载。

切换后核对容器状态、curl 一下端口、看日志确认连上了数据库、数据还在。确认无误后,老的命名卷和 /opt/<service> 目录就是回滚后路,等放心了再清掉。

防火墙:怎么挡住公网又不会把自己锁死

很多自托管的人会把所有服务统一藏在一台反向代理后面:公网域名都解析到这台反代(下面叫它反代 B),由它统一终结 TLS,再回源到真正跑服务的应用服务器(叫它 A)。这套结构很常见,但它有一个默认状态下敞开的后门。

Docker 发布端口时,默认把端口绑在 0.0.0.0 上,并且不限制来源。也就是说,服务器 A 上 20000:80 这样的发布,让任何知道 A 公网 IP 的人都能直接打 http://A:20000,绕开反代 B 连同它的 TLS 和前置防护。要堵的就是这条直连路径:让 A 的这些端口只对反代 B 开放,另外放行少数几个确实需要面向公网的端口。

在 Docker 主机上做端口过滤有个经典陷阱,不绕开很容易把自己关在门外。Docker 发布的端口流量在内核的 prerouting 阶段就被 DNAT 改写目标地址,然后走 forward 链转发到容器,host 的 INPUT 链根本看不到它们。所以加在 INPUT 上的 drop 规则对 Docker 端口毫无作用。正确的拦截位置在 forward 路径上的 DOCKER-USER 链,这是 Docker 专门留给用户的链,它在 Docker 自己的 accept 规则之前先被求值,而且 Docker 重建规则时会保留它的内容。

顺着这条主线,有四件事要做对,规则才既有效、又不会反噬自己。

第一,过滤只动 DOCKER-USER 链,针对公网网卡。容器之间在内部桥接网络上的通信、容器对外的出站流量都不匹配「入站公网网卡」这个条件,所以内部互联和访问外网都不受影响。

第二,INPUT 策略保持 accept,host 进程的端口(SSH、反代用的 80/443)照常放行。要限制某个走 host 网络的端口,就单开一个子链、只丢弃那一个端口。这么设计的用意只有一个:就算规则写错了,也伤不到 SSH,人永远能登进来修。

第三,只用原生 iptables/ip6tables 管这一条链,别装 ufw 或 firewalld。那类工具会接管整套链和默认策略,跟 Docker 抢地盘,光一个 FORWARD 默认 DROP 就能让所有容器转发瘫掉。

第四,IPv4 和 IPv6 都要管。端口通常在两个协议栈上都发布了,如果反代 B 只有 IPv4 地址,那 IPv6 这一侧就没有可信来源,规则上只放行那几个公网例外、其余全丢,把「IPv4 配了白名单、IPv6 却敞着」这个常见漏洞堵上。

落到规则上,IPv4 的 DOCKER-USER 大致长这样:

Terminal window
# 作用在公网网卡 eth0 上
-A DOCKER-USER -i eth0 -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -i eth0 -s <PROXY_B_IP> -j RETURN # 只放行反代 B
-A DOCKER-USER -i eth0 -p tcp --ctorigdstport 222 -j RETURN # 比如 git over ssh,需公网
-A DOCKER-USER -i eth0 -p tcp --ctorigdstport 65231 -j RETURN # 比如 BT 端口,需公网
-A DOCKER-USER -i eth0 -j DROP # 其余一律丢弃

DNAT 还带来一个细节:经过 DNAT 之后,普通的 --dport 看到的是容器内部端口(80、8083),原始的 host 端口(20000)已经被改写掉了。所以匹配端口例外要用 conntrack --ctorigdstport 去对 DNAT 之前的原始 host 端口;而按来源 IP(反代 B)匹配不受 DNAT 影响,照常生效。

最后一条经验比规则本身更重要:任何有风险的防火墙改动都套一个定时自动回滚。先存好当前规则,用 systemd-run 设一个十分钟后自动恢复的定时器,再应用新规则,验证没问题就取消定时器;万一把自己关在外面,什么都不用做,十分钟后规则自己滚回来。

Terminal window
iptables-save > /root/fw-backup.rules
systemd-run --on-active=600 --unit=fw-rollback \
/bin/sh -c 'iptables-restore < /root/fw-backup.rules'
# 应用新规则、验证;没问题就:
systemctl stop fw-rollback.timer

防火墙数据路径:公网入站经 prerouting DNAT 后走 forward 到容器,在此被 DOCKER-USER 链按来源过滤;host 进程端口另走 INPUT 链保持放行

这张图画的是两条不同的包路径:Docker 发布的端口走 DNAT 加 forward,落在 DOCKER-USER 上拦截;SSH 这类 host 端口走 INPUT,始终放行。看懂它就明白 drop 规则该写在 forward 路径的 DOCKER-USER 上,因为 DNAT 之后 INPUT 已经看不到这些包。

备份:三层状态,分类控制

Git 里有全部声明,但光靠 git clone 重建不出一台服务器,因为真正的状态分散在三层,各有不同的归属和备份方式。要从彻底丢失里恢复,三层都得在手上。

内容 在哪 谁来备份 丢了会怎样
1. 代码 / IaC compose、sync.toml、文档 Git 仓库 推到远端(异地) git clone 找回
2. 控制平面元数据 资源定义、变量 / 密钥、用户、API key、Git 和 registry 账号 Komodo 的数据库 内置 dump(每天) 密钥和账号全没,得手工重配
3. 应用数据 各服务的库、上传文件、应用自持的 key 数据根目录 /srv 自己安排 用户数据真的没了

为什么三层缺一不可,用一个例子就清楚:仓库定义了每个 Stack 是什么,但每一个密钥要么是 Komodo 里的变量、要么藏在 /srv 里,Git 里只有占位符。所以恢复是三件事的合体:Git 重建定义,数据库 dump 找回密钥和账号,/srv 备份找回数据。

第一层最简单,推到一个异地的 Git 远端就行,这一步多数人本来就在做。

第二层,Komodo Core 自带一个 km 命令行工具,可以把数据库各集合 dump 成 gzip 文件。把它配成一个每天定时跑的 Procedure,dump 进一个带时间戳的目录、保留最近若干份,整个过程在 UI 里可见、可告警。要当心的是,dump 里那份变量文件装着全部明文密钥,没有加密,绝不能不加密就拷下机器。

第三层最容易被忽略,得自己安排。给数据库做一致的备份靠两种安全办法:对每个 Postgres 容器跑 pg_dumpall 做逻辑 dump(跨大版本可移植),或者把 Stack 停掉做冷拷贝。直接拷一个正在跑的数据库的数据目录,可能拷到一个写到一半、还原不了的状态。一个参考脚本的骨架:

#!/usr/bin/env bash
set -euo pipefail
dest="/srv/_backups/$(date +%F_%H-%M-%S)"; mkdir -p "$dest/pgdump"
# 逻辑 dump 每个 Postgres 容器(官方镜像本地 socket 是 trust,无需密码)
for c in $(docker ps --format '{{.Names}}'); do
docker inspect "$c" --format '{{.Config.Image}}' | grep -q postgres || continue
docker exec "$c" pg_dumpall -U postgres | gzip > "$dest/pgdump/$c.sql.gz"
done
# 文件级快照 /srv(上传、应用配置、应用自持的密钥),排除可重建的搜索索引
tar --exclude='/srv/_backups' --exclude='/srv/*/elasticsearch' \
-czf "$dest/srv.tar.gz" /srv

把它配成每天定时(Komodo Action 或主机 cron),排在数据库 dump 之后,这样一次异地推送能把两份一起带走。

第三层的重点是异地。如果所有备份都躺在和数据同一块磁盘上,一次磁盘或服务器故障会把数据连着备份一起带走。业界的 3-2-1 原则是底线:3 份副本、2 种介质、1 份异地。dump 里有密钥,异地之前必须客户端加密,用 resticrclone crypt 这类内置加密的工具,目标可以选任意对象存储。

恢复时按层来。回滚单个服务,就停掉它、把对应的 pg_dump 灌回去或把 /srv/<service> 解包回去(tar -p 保留属主权限),再从 UI 重新部署。整机重建则严格按「先密钥和数据、后部署」的顺序:装好 Docker,恢复 Komodo 自己的环境文件和密钥卷并把 Core 拉起来(数据库此时是空的),把数据库 dump 灌回去(变量、账号、资源定义随之回来),把 /srv 和各 pg_dump 恢复到位,最后推一次 main 让所有 Stack 在恢复好的数据上重新部署,再重建那些派生的搜索索引。

backup-layers

把更多服务器纳入管理

在 Komodo 的 Core/Periphery 模型架构下,添加管理更多服务器也并不困难,加一台机器的本质就是给它装上 Periphery、再连到同一个 Core。

multi-server

这张图画的是两种连接方向:有公网地址的机器由 Core 连进去,NAT 后的家用机自己拨出来连 Core。两种都挂在同一个 Core 下,归同一套 Git 管。

连接有两个方向,按新机器能不能被 Core 直接够到来选。

一种是 Core 主动连 Periphery。在新机器上跑 Periphery,让它监听一个端口(默认 8120),再在 Komodo 里加一个 Server 资源,地址填这台机器的地址,比如 http://server-b:8120。这适合有固定地址、Core 够得着的机器,比如另一台公网 VPS。这个 8120 端口要像别的端口一样锁好,只放行 Core 来访。

另一种是 Periphery 主动连 Core(较新版本的 outbound 模式)。Periphery 拿着一个 onboarding key 主动拨向 Core 的地址,建立一条双向 websocket。家里的机器多半在 NAT 后面、拿不到公网入口,这种主动外拨正好对症,省去在家里开端口、做端口映射的麻烦。

outbound 模式装起来就一条命令:先在 Komodo 的 Server 设置里生成一个 onboarding key,再到新机器上跑官方安装脚本。

Terminal window
curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py \
| python3 - \
--core-address="https://<Core 地址>" \
--connect-as="$(hostname)" \
--onboarding-key="O-..."

安装脚本执行好后,这台机器便以 connect-as 给的名称出现在 Komodo 的 Servers 列表里。

GitOps 那套原样套上去就行。既然「有哪些服务」能写进 sync.toml,「有哪些服务器」同样可以,加一个 [[server]] 块,连服务器清单本身都变成代码:

[[server]]
name = "server-b"
description = "家里的存储机"
tags = ["home"]
[server.config]
address = "http://localhost:8120"
enabled = true

要把某个服务跑到这台新机器上,只改它 [[stack]] 块里的一行:

[stack.config]
server = "server-b" # 从 server-a 改成新机器
run_directory = "stacks/<service>"
file_paths = ["compose.yaml"]

推送之后,那条 Redeploy On Push Procedure 一视同仁地把变更过的 Stack 部署到它各自声明的服务器上。一个仓库、一套 Renovate、一条流水线,现在横跨好几台机器;一个面板里就能看到所有机器的所有服务,升级 PR 也覆盖全部。

多机之后有几件事要分开算。每台机器有自己的 /srv 数据根目录,备份也各做各的,那套三层备份在每台机器上各跑一份。端口登记表可以继续用一张全局表,也可以每台一张,端口只在同一台机器内才会撞。跨机器通信走网络地址:Docker 的桥接网只在单机内部有效,一台机器上的服务要访问另一台上的,得通过公网地址,或者用一层内网穿透(比如 Tailscale 这类 mesh)把它们接进同一个虚拟网段。

最终是一套各司其职的小集群:对外的归公网 VPS,消耗存储的由家里的 homelab 负责,全部收敛于同一个 Git 仓库、同一个面板下。

这套做法适合谁,怎么开始

最适合的场景是:一两台长期跑、服务数量在十个以上、又希望升级和回滚都留痕的自托管环境。服务再少,手动管的心智负担本来就低,上这套略显重;真到几十上百台、多人协作的规模,该看的是 Flux、Argo CD 这类 Kubernetes 原生的 GitOps 工具。Komodo 加 Renovate 这套正好接住中间这段,比纯手工可靠得多,又比 Kubernetes 轻得多,对个人和小团队的自托管刚刚好。

实际搭建,按这个顺序起步最顺。先在一台主机上手动拉起 Komodo(Core、Periphery、数据库),这是唯一一处手动操作。建一个 Git 仓库,放一个最简单的服务,照 wallos 那个例子写好 compose.yaml,把数据 bind 到 /srv、端口登记进一张表。在 Komodo UI 里配好指向仓库的 Repo 资源和相关变量,写一个 sync.toml 把这个 Stack 声明出来、全部设 deploy = false。再建一个两段式的 Redeploy On Push Procedure(先 RunSync、后 BatchDeployStackIfChanged),把它的 push webhook 挂到仓库上。最后装上 Renovate,合并那个引导 PR。

跑通这一个服务之后,加第二个、第三个就只是重复同样的四步:写 compose、加 sync 条目、认领端口、推送。此后改动都走 Git、Renovate 提 PR、人工审核合并,服务器自己跟上,整台机器的状态再也不会散落在 Git 之外。

参考链接