如何在 VPS 上自托管 OpenAnalytics
部署前先确认真实资源需求:ClickHouse、Postgres、Valkey、4 GB 内存、25 GB 可用磁盘和 4 条 DNS 记录,并了解安装后磁盘空间的增长。
部署前的资源需求
要自托管 OpenAnalytics,您需要一台约有 4 GB RAM、25 GB 可用磁盘空间的 Linux VPS,安装 Docker 和 Compose plugin,并提前准备好 4 条已指向该服务器的 DNS 记录。这是必须先说明的实际要求,应放在第一个命令之前,而不是之后。
该技术栈包含 6 个应用服务和 3 个数据存储。Postgres 保存控制平面数据,包括账户、站点、API keys 和共享链接。ClickHouse 保存原始事件,以及 dashboard 读取的汇总数据。Valkey 运行 2 次:一次作为持久化事件队列,另一次作为可以丢失的缓存,因为这两种用途需要相反的驱逐策略。只有 query gateway 可以读取 ClickHouse,并且会在执行每个查询前验证其查询封装中的 Ed25519 签名。
如果您需要的是一个二进制文件和一个配置文件,那么这套方案并不符合要求。GoatCounter 是此类工具中的单二进制方案:一个 Go 可执行文件,默认使用 SQLite,完全不依赖外部数据库。更重的技术栈提供了漏斗分析、Web Vitals、从您自己的 Stripe 账户进行收入归因,以及 MCP(model context protocol)服务器。如何选择自托管分析工具 这篇文章会权衡其中的取舍。本指南假设您已经做出了选择。
先将4条 DNS 记录指向服务器
开始任何操作前,4个子域名必须解析到服务器的公网 IP。Caddy 首次启动时会申请 Let's Encrypt 证书。尚未解析的域名会导致验证失败。
app.example.com提供仪表板。api.example.com提供 API 和 OAuth 回调。c.example.com提供采集器和跟踪脚本。rt.example.com提供实时流。
使用4条 A 记录,或使用1条 A 记录和3条指向该记录的 CNAME。继续之前,使用 dig +short app.example.com 确认解析结果。刚在1分钟前添加的域名,Let's Encrypt 使用的解析器仍可能将其缓存为 NXDOMAIN。因此,首次申请证书失败时,应等待 DNS 缓存更新,并查看 Caddy 日志。重复运行安装程序不会加快 DNS 传播速度。
如何使用 Docker Compose 自托管 OpenAnalytics
检出一个已标记的发行版。默认分支用于开发,已发布的镜像实际对应的是发行版标签。以下命令假定已安装 Docker 和 Compose 插件。在 VPS 上运行 Docker Compose 服务介绍了安装和运行方法。
git clone https://github.com/OpenLabs-so/openanalytics
cd openanalytics
git checkout "$(git tag -l 'v*' --sort=-v:refname | sed '/-/d' | head -1)"
cd infra/selfhost
./generate-secrets.sh --domain example.com --email you@example.com --with-geoip
docker compose pull && docker compose up -d检出命令中的 sed '/-/d' 会排除预发行标签,因此会定位到最新的稳定版本,而不是候选版本。--with-geoip 会在生成过程中获取 DB-IP 城市数据库。如果跳过该步骤,每个事件的国家字段都会是 null,地理视图将完全没有数据。之后可以运行 infra/selfhost/geoip/fetch-dbip.sh,在 env/collector.env 中设置 GEOIP_DB_PATH=/geoip/dbip-city-lite.mmdb,再使用 docker compose up -d --force-recreate collector 重新创建 collector,以补充该数据库。该数据库每月更新一次,因此也应每月重新获取,否则城市数据会逐渐过时。
继续操作前先备份生成的密钥
生成器会写入三类内容。.env包含域名和镜像引用。env/*.env为每个服务分别保存一个密钥文件。docker-compose.override.yml以 YAML 块标量的形式保存 3 对 Ed25519 密钥,因为多行 PEM 不能存放在 env 文件中。所有这些文件都已加入 git-ignore,且无法重新生成出相同的值。
现在将这些文件复制到其他设备。每项数据丢失都会造成特定影响:
- 丢失存储密码后,您将无法访问 Postgres 和 ClickHouse;只能从容器内部重置密码。
- 丢失
OA_CREDENTIAL_KEYRING后,所有已存储的第三方凭据都无法恢复,因此连接过 Stripe 账户的用户必须重新连接。 - 丢失
ANONYMOUS_IDENTITY_SECRET后,访客身份基线会重新建立:昨天的访客都会被计为新访客,图表中也会显示这次中断。 - 丢失
AUTH_SECRET后,所有会话都会失效,因此所有用户都必须重新登录。 - 丢失签名私钥后,只需轮换密钥对,不会丢失任何数据。
其中两个密钥必须分别在两组文件中保持字节级完全一致。ANONYMOUS_IDENTITY_SECRET同时出现在 collector.env 和 worker.env 中,因为 collector 计算访客哈希,而 worker 写入该哈希。OA_CREDENTIAL_KEYRING同时出现在 api.env 和 worker.env 中。其他所有密钥都严格限定给一个服务使用,这是有意设计的。如果向某个服务提供了它不应持有的密钥,该服务会直接退出,而不是启动。
启动整个服务栈并检查
grep OA_IMAGE .env
docker compose pull
docker compose up -d
docker compose logs -f migrate
docker compose psmigrate 应用 Postgres 和 ClickHouse schema 后退出,因此 migrate 容器停止是正确的最终状态。tracker-build 将 oa.js 编译到由 Caddy 提供服务的卷中,然后也会退出。其他所有服务都应在 docker compose ps 中读取 healthy。如果某个服务不断重启,通常表示环境变量验证失败;日志会在同一份列表中打印所有问题,而不是每次重启只打印一个问题。最常见的两个原因是变量留空(系统会拒绝空值,而不是将其视为未设置)以及将密钥放在了错误的服务文件中。
在 arm64 上或从某个分支构建时,没有已发布的镜像,因此需要使用 docker compose up -d --build 在本地构建。4 GB 的主机在构建过程中可能耗尽内存。先添加 swap;swap 仅在构建期间需要:
fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab构建大约需要十分钟。拉取只需几分钟,这也是发布镜像存在的原因。
立即认领首个账户
打开 https://app.example.com。尚未有任何人登录的部署不会显示登录表单,而是提供创建首个账户的选项。该账户将永久拥有特权,也是唯一能看到部署设置页面的账户。该账户创建后,该路由会返回 409,因此其他人无法在您之后进入。堆栈运行正常后立即执行此操作,不要拖到下一周。
安装跟踪器
在控制面板中添加站点,系统会提供跟踪标签。其格式固定:
<script
async
src="https://c.example.com/oa.js"
data-key="YOUR_TRACKING_KEY"
data-collector="https://c.example.com"
></script>将其放入页面的 head 中。跟踪密钥按设计就是公开的,因此应放在任何人都能读取的 HTML 中。该脚本会安装 window.oa。像 oa("track", ...) 这样的调用会先由存根排队,文件加载后再刷新,因此过早触发的自定义事件不会丢失。如果页面上的其他代码已经占用 window.oa,跟踪器会改用 window.openanalytics 安装。如果同一站点也通过 洋葱服务 提供访问,请不要将该标签放入该构建中,因为从 c.example.com 获取脚本会将 Tor Browser 访客带回明网,并在同一次页面加载中关联这两个地址。
然后端到端检查完整链路:
curl -s https://c.example.com/oa.js -o /dev/null -w '%{http_code} %{size_download}\n'
curl -s https://api.example.com/health | head -c 200
docker compose logs --tail=50 worker | grep -i batch第一条命令应输出 200 和几 KB。加载站点中的一个页面,然后在几秒内检查 worker 日志中是否出现批处理行。collector 接受事件后会立即返回 202,而 202 表示已排队,不表示已存储。worker 负责将事件写入 ClickHouse。事件已被接受,但控制面板中没有任何显示,这表示 worker 被阻塞;如果 Valkey 队列深度持续增加,也可以确认这一点。常见原因是 worker.env 中的 ClickHouse 凭据错误,或新迁移添加的表缺少相应授权。
保持采集器公开访问,让仪表板置于身份验证之后
Caddy 已包含在 compose 文件中,并会自行为这 4 个域名获取证书,因此默认路径无需您配置代理。如果服务器上已经运行Nginx 反向代理,请改用随附的 infra/selfhost/nginx.conf.example 将该堆栈置于代理之后,并保留其请求头处理逻辑:
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header CF-Connecting-IP "";
proxy_set_header True-Client-IP "";
proxy_set_header Fly-Client-IP "";采集器根据客户端 IP 生成每日访客哈希,因此必须从连接中获取该地址,不能从请求头中获取。将不受信任的跳转节点传来的 CF-Connecting-IP 原样转发,会让任何调用方冒充任意地址,同时破坏地理定位结果并夸大访客数量。
不同主机名的访问权限可以明确分开。每个被统计的网站的所有访客都必须能够访问 c. 和 rt.,因此不要在这两个地址前配置 basic auth 或 IP allowlist。只有登录用户需要访问 app. 和 api.。仪表板由应用自身的身份验证负责保护:通过 env/api.env 中的 AUTH_PASSWORD_SIGNIN=enabled,密码登录默认启用;只有在对应提供商的 client ID 和 client secret 均存在时,才会显示 Google 或 GitHub 按钮。Magic link 需要邮件传输服务;未配置邮件传输服务时,API 只会将发送操作写入 outbox,因此邮件不会送达,也不会报告错误。如果其他自托管应用已经置于统一的 Authentik 登录之后,请尽早决定此仪表板是加入该体系,还是保留独立账户,因为您在此处创建的第一个账户将永久拥有特权。
有一项设置决定仪表板是否能够正常工作。env/api.env 中的 AUTH_TRUSTED_ORIGINS 必须与仪表板来源完全匹配。该值错误或缺失时,API 不会发送 CORS(跨源资源共享)响应头,浏览器会拒绝所有调用;此时仪表板可以渲染布局,但不会显示数据,而 docker compose ps 仍会报告一切正常。
配置代理时,同时处理自动化流量。爬虫与其他客户端一样会访问采集器,其页面浏览量会写入 ClickHouse 并计入统计数据。在服务器端阻止 AI 爬虫可在这些请求影响准确性并占用磁盘空间之前,将其中一部分挡在数据库之外。
此处的无 Cookie 含义及其代价
不会设置 Cookie。访客身份是加盐哈希值,盐值每天轮换,且绝不存储原始 IP 地址。地理位置在本地通过您自己磁盘上的 DB-IP 文件解析,因此关于访客的任何查询都不会离开主机。在本地执行查询可以去除第三方供应商,但不会去除数据本身。这与自行运行 SearXNG 实例时的限制相同:搜索引擎看到的是您服务器的 IP 地址。
这样做的好处是,访客设备上不会持久化存储标识符。正是这类标识符会使跟踪器受到欧盟 ePrivacy 同意规则的约束。因此,此类仅聚合数据的部署通常无需显示同意横幅。GDPR 仍适用于您实际存储的数据及其保留期限。您的具体情况应由您自己的法律顾问判断,而不是由 README 决定。
代价是无法识别跨天访客。由于盐值会轮换,周一和周三再次访问的同一个人会被计为两名访客。这是设计如此,且没有变通办法。每日独立访客数是可靠的。每周和每月独立访客数基于每日数据计算,因此会高估覆盖范围。任何较长时间窗口的“回访访客”数据都不能准确反映其标签所称的含义。单日内的会话和访问路径是可靠的。轮换 ANONYMOUS_IDENTITY_SECRET 与跨越一天具有相同效果,因此应将该轮换视为数据变更,而不是例行维护。
采集器遵循 Do Not Track 和 Global Privacy Control。这是浏览器发送的信号,用于告知网站不要出售或共享个人数据。脚本标签包含用于实现相同控制的开关:data-respect-gpc、data-respect-dnt 和 data-require-consent。其中 data-require-consent 会在获得同意前暂停所有采集,并将选择结果以键 oa.consent 保存在 localStorage 中。设置 data-storage="none" 可完全关闭浏览器存储。
磁盘为什么会在半年后被占满
这正是自托管分析服务器最常见的故障原因,而事件数据通常不是主因。
先看镜像。一次发布会生成 10 个镜像,总共约占用 13 GB 磁盘空间。升级时,系统会先拉取新一代镜像,再删除旧一代镜像,因此一段时间内会同时保留两代镜像。这已经占用了 25 GB 磁盘需求中的大部分,而且此时还没有产生任何页面浏览数据。
然后是快照。snapshot.sh 会停止整个服务栈,将两个数据卷和所有密钥一起归档,然后重新启动。这里唯一安全的方式是创建冷副本,因为 ClickHouse 会在后台合并数据分片,而在合并期间创建的副本不一致。upgrade.sh 会在每次升级前自动创建快照,因此归档文件会一直累积在同一块磁盘上,直到达到保留上限。
./snapshot.sh create --label before-something-risky
./snapshot.sh list
./snapshot.sh --keep 3如果主机接近磁盘上限,请在升级前回收上一代镜像。服务栈运行期间执行此操作是安全的,因为运行中容器所使用的镜像仍被引用:
docker image prune -a -f接下来是事件数据本身。ClickHouse 会对列式数据进行高强度压缩,因此原始事件数据的增长速度通常低于预期,而仪表板读取的汇总表相比原始表很小。请通过测量而不是猜测来判断:
docker system df -v
docker compose exec clickhouse df -h /var/lib/clickhouse要获取每张表的占用空间,请使用生成器写入 infra/selfhost/env/ 下的 ClickHouse 凭据运行以下命令:
SELECT table, formatReadableSize(sum(bytes_on_disk)) AS size, sum(rows) AS row_count
FROM system.parts
WHERE active
GROUP BY table
ORDER BY sum(bytes_on_disk) DESC;在第 1 周和第 4 周分别记录一次结果。两个数据点即可计算增长速率,而增长速率可以告诉您何时需要扩容数据卷。截至 2026 年 8 月,自托管指南没有记录用于原始事件的保留期限或 TTL 配置项。因此,应根据实测增长速率规划磁盘容量,不要假设旧数据会自动过期。
还有一个删除陷阱需要提前了解。删除站点或账户后,系统会将任务加入 worker 队列,而该 worker 需要设置 CLICKHOUSE_MAINTENANCE_USER 和 CLICKHOUSE_MAINTENANCE_PASSWORD,并且 ClickHouse 中必须存在相匹配的 oa_maintenance 用户。缺少这些配置时,删除任务会永久停留在队列中。站点会从仪表板中消失,但所有数据行仍留在磁盘上,因此看起来像是完成了清理,实际却没有释放任何空间。
升级及三项成本
git fetch --tags
git checkout "$(git tag -l 'v*' --sort=-v:refname | sed '/-/d' | head -1)"
cd infra/selfhost
./upgrade.shupgrade.sh执行前会打印三项成本。停机成本是真实存在的:采集器停机期间尝试发送的事件会丢失,因为跟踪器不会重试。回滚会丢失数据,因为 rollback.sh --to backups/<snapshot> 会整体替换两个存储,并丢弃创建该快照后写入的所有行。磁盘是第三项成本,也就是上文所述的快照堆积。
有两条重启规则很容易弄错。先启动查询网关,再启动 API,因为较新的 API 会发送旧网关拒绝的查询字段。ClickHouse 则需要重新创建容器,而不是重启,因为 docker compose restart 会复用容器的原始环境,并静默忽略你的修改:
docker compose up -d --force-recreate clickhouse仪表板也存在同类陷阱。env/web.env 中的三个 NEXT_PUBLIC_* 源地址会编译进浏览器 bundle,并在容器启动时替换。因此,仪表板调用错误主机名时,应使用 docker compose up -d --force-recreate web 修复,不能使用 restart。Web 容器的日志会打印启动时使用的源地址,这是确认修复已生效的最快方法。
如果 ClickHouse 在修改配置后拒绝启动,请读取其日志的第一行。以 oa-entrypoint: 开头的行表示 entrypoint 拒绝了你设置的值。其他情况通常表示配置文件不是有效的 XML。最常见的原因是 XML 注释中包含双连字符,而 XML 注释不允许出现双连字符。
AGPL-3.0 和名称
该代码采用 AGPL-3.0 许可证。未经修改地运行该代码来服务于您自己的网站,完全不会产生任何公开发布义务。只有在您修改代码,并将修改后的版本作为网络服务运行时,才会产生该义务:许可证要求您向该服务的用户提供修改后的源代码。这包括在您的实例上为客户提供仪表板,也包括将其打包到您销售的产品中。将修改内容保存在公开 fork 中即可满足该要求,无需执行其他流程。
品牌与代码相互独立。“OpenAnalytics”名称和项目的托管域名用于标识其作者运营的实例,不属于许可证授予范围。您的部署运行的是该软件,但不应继续使用该品牌;在向付费客户提供服务之前,请为该服务设置自己的名称。
FAQ
我可以在 1 GB VPS 上运行 OpenAnalytics 吗?
不可以。该项目需要约 4 GB 内存和 25 GB 可用磁盘空间,因为一次部署会运行 6 个应用服务,以及 Postgres、ClickHouse 和 2 个 Valkey 实例。仅 ClickHouse 就不是一个小型进程。在 1 GB 服务器上,容器会先启动,随后内核的内存不足杀手会终止其中一个容器,通常是 ClickHouse。如果 1 GB 套餐是硬性限制,请使用 GoatCounter 这类单二进制工具。它使用 SQLite,不依赖外部数据库。
使用 OpenAnalytics 需要添加 Cookie 横幅吗?
这应由您的律师判断,但从技术事实看,您处于有利地位。系统不使用 Cookie,访客身份是每天轮换的加盐哈希,也不会存储原始 IP 地址,因此不会写入用于持久识别访客的数据。GDPR 仍然适用于您存储的数据及其保留期限。如果您希望明确要求用户同意后再收集数据,请在脚本标签上设置 data-require-consent:在获得同意前,跟踪器不会收集任何数据,并会将用户的选择保存在 localStorage 的 oa.consent 下。
为什么事件返回 202,却始终不显示在仪表板中?
202 表示收集器已接受事件并将其加入队列,并不表示事件已经存储。工作进程会将该队列中的事件写入 ClickHouse,因此请求成功但仪表板为空,通常说明问题出在工作进程上。读取 docker compose logs --tail=50 worker,并监控 Valkey 队列深度。队列持续增长表示工作进程受阻,常见原因是 worker.env 中的 ClickHouse 凭据错误,或者最近的迁移创建了新表,但相应表上缺少授权。
为什么所有容器都运行正常,但仪表板仍为空?
先检查 env/api.env 中的 AUTH_TRUSTED_ORIGINS。它必须与仪表板来源完全匹配。如果不匹配,API 不会发送 CORS 响应头,浏览器会拒绝所有请求,因此您只能看到正常加载的界面,却看不到数据。第二项是检查 env/web.env 中的 3 个 NEXT_PUBLIC_* 值。这些值会在 Web 容器启动时进行替换。修改后必须执行 docker compose up -d --force-recreate web,因为普通重启会保留旧值。
AGPL-3.0 会阻止我向客户提供这项服务吗?
不会,但它附带一个条件。直接运行未修改的代码时,您无需向任何人提供额外内容。如果修改代码,并将修改后的版本作为其他人使用的服务运行,则必须向这些用户提供修改后的源代码;公开 fork 可以满足这一要求。另外,“OpenAnalytics”这一名称并未随代码一起授权,因此您销售的任何产品或服务都需要使用自己的名称。