在NixOS上使用GoatCounter进行无机器人自托管分析
摘要
作者详细介绍了在NixOS上部署GoatCounter,一个开源的隐私友好分析工具,用于过滤机器人并替代Google Analytics,包括自定义JavaScript和本地代理设置。
<p><a href="https://lobste.rs/s/1xxu7n/bot_free_self_hosted_analytics_with">评论</a></p>
查看缓存全文
缓存时间: 2026/09/21 00:19
# 在NixOS上使用GoatCounter实现无机器人自托管分析
来源:https://vincent.bernat.ch/en/blog/2026-goatcounter
2016年,我从本博客移除了Google Analytics(https://github.com/vincentbernat/vincent.bernat.ch/commit/4c3be65404031bf6638c1ea3d891a3ea58e0a0fc),以避免成为数据收割机器的帮凶。此后,我依赖GoAccess(https://goaccess.io/)分析服务器日志。¹(https://vincent.bernat.ch/en/blog/2026-goatcounter#sidenote-scrub)
过去几年,尽管我尝试过滤机器人,但统计数据始终不合理:AI爬虫(https://herman.bearblog.dev/the-great-scrape/)将每日访问量虚增至约2,000。最终,我选择了GoatCounter(https://www.goatcounter.com/)——一个开源且注重隐私的网络分析平台。我替换了JavaScript客户端以更严格地过滤机器人,并添加了CSS回退方案。为提升可靠性,我为该博客的五台Web服务器各部署了一个本地代理。下文将详述各组件的协作方式,以及我在NixOS上的部署过程。
❄️ 目录
- [为何选择GoatCounter?](https://vincent.bernat.ch/en/blog/2026-goatcounter#why-goatcounter)
- [定制JavaScript客户端](https://vincent.bernat.ch/en/blog/2026-goatcounter#custom-javascript-client)
- [本地代理](https://vincent.bernat.ch/en/blog/2026-goatcounter#local-proxy)
- [在NixOS上部署](https://vincent.bernat.ch/en/blog/2026-goatcounter#deploying-on-nixos)
- [容器化部署应用](https://vincent.bernat.ch/en/blog/2026-goatcounter#deploying-applications-in-containers)
- [GoatCounter服务器](https://vincent.bernat.ch/en/blog/2026-goatcounter#goatcounter-server)
- [GoatCounter代理](https://vincent.bernat.ch/en/blog/2026-goatcounter#goatcounter-proxy)
- [使用Litestream备份SQLite数据库](https://vincent.bernat.ch/en/blog/2026-goatcounter#backup-of-the-sqlite-database-with-litestream)
## 为何选择GoatCounter?
# (https://vincent.bernat.ch/en/blog/2026-goatcounter#why-goatcounter)
GoatCounter不收集个人数据(https://www.goatcounter.com/help/sessions#technical-details-11181):它不存储读者IP地址或依赖Cookie,而是根据用户代理和IP地址生成一个有效期为8小时的会话标识符。其功能集虽简练,但足以满足博客需求。如欲体验界面,可访问GoatCounter作者维护的公开实例(https://stats.arp242.net/)(对应其网站(https://www.arp242.net/))。托管版本允许你在自建实例前先行试用。
GoatCounter仅需单个二进制文件与SQLite数据库,是轻量级的自托管解决方案之一。其他隐私友好型工具按复杂度递增排列包括:Umami(https://umami.is/)、Plausible(https://plausible.io/)和Rybbit(https://rybbit.com/)。
GoatCounter仪表盘展示本博客的部分统计数据,包括一篇关于生成树协议的文章在过去一周获得1,224次浏览,同时显示了流量来源及浏览器分布(52% Chrome,32% Firefox,其中Firefox 155占16%,Firefox 156占6%)。
## 定制JavaScript客户端
# (https://vincent.bernat.ch/en/blog/2026-goatcounter#custom-javascript-client)
GoatCounter内置一个轻量JavaScript客户端(https://github.com/arp242/goatcounter/blob/main/public/count.js)——压缩后仅2,189字节。它包含我未使用的一些功能:访问计数器、点击追踪、可配置设置等。我将其替换为以下注册访问的函数:
```javascript
const count = ({ event, title } = {}) => {
const params = new URLSearchParams({
p: event || location.pathname,
t: title || document.title,
r: document.referrer,
q: location.search,
s: document.documentElement.clientWidth,
e: !!event,
rnd: Math.random().toString(36).slice(2, 7),
});
fetch(`/count?${params}`, { keepalive: true }).catch(() => {});
};
```
为过滤机器人,²(https://vincent.bernat.ch/en/blog/2026-goatcounter#sidenote-bot-filter)我通过要求用户交互来加强过滤——此方法借鉴自Bear Blog(https://bearblog.dev/)。
```javascript
let sendHit = () => (sendHit = () => {}, count());
["touchmove", "mousemove", "keydown", "pointerdown"].forEach((eventName) =>
document.addEventListener(eventName, sendHit, { once: true, passive: true })
);
```
若读者在浏览器中禁用了JavaScript,我会通过CSS图片记录访问。`:hover`伪类仅在交互后加载,这同样是从Bear Blog(https://herman.bearblog.dev/how-bear-does-analytics-with-css/)学到的技巧。约2%的访问者属于此情况。³(https://vincent.bernat.ch/en/blog/2026-goatcounter#sidenote-referrer)
```css
/* 本博客的JavaScript代码要求ES6支持 */
if ("noModule" in HTMLScriptElement.prototype)
document.documentElement.classList.remove("nojs");
.nojs body:hover {
border-width: 0;
border-image: url('/count?p=/en/blog/2026-kpi-goodhart&t=Building...&r=NoJS&e=false');
}
```
GoAccess曾报告日访问量约2,000,而GoatCounter仅统计到不足200名真人。我推测AI爬虫采用低成本策略:若内容无访问障碍(如本博客),它们不会启动复杂的浏览器来触发页面浏览。即使运行JavaScript的爬虫(如采用无头Chromium的Googlebot(https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics)),也不会与页面交互(https://developers.google.com/search/docs/crawling-indexing/javascript/lazy-loading#:~:text=Google%20Search%20does%20not%20interact%20with%20your%20page)且不会触发我监听的事件。因此,基于交互的"人类验证"机制很可能持续有效。
## 本地代理
# (https://vincent.bernat.ch/en/blog/2026-goatcounter#local-proxy)
全球五台服务器(欧洲及北美)为本网站提供内容,但GoatCounter仅运行在其中一台。为避免GoatCounter宕机时丢失访问数据,我在每台服务器上运行一个监听相同`/count`端点的本地代理。它将访问记录暂存于内存缓冲区(足以容纳数天宕机时间),随后通过`/api/v0/count`认证端点(https://www.goatcounter.com/help/api)批量发送至上游。
服务器分布示意图:web02位于巴黎,web03位于赫尔辛基,web04位于纽伦堡,web05位于阿什本,web06位于芝加哥。
我曾在Pull Request #909(https://github.com/arp242/goatcounter/pull/909)中提交了代理代码,但GoatCounter维护者认为此类小众用例不值得维护过多代码。作为开源开发者,我对此深有同感:单次贡献可能演变为长期的维护负担。
为避开广告拦截器,我将代理端点暴露在本网站域名下。这看似不尊重读者选择,但鉴于GoatCounter的隐私友好性,我认为可接受。
```nginx
location = /count {
access_log off;
proxy_pass http://127.0.0.3:8087/count;
proxy_pass_request_headers off;
proxy_set_header Accept-Language $http_accept_language;
proxy_set_header User-Agent $http_user_agent;
proxy_set_header X-Real-Ip $remote_addr;
}
```
## 在NixOS上部署
# (https://vincent.bernat.ch/en/blog/2026-goatcounter#deploying-on-nixos)
我的Web服务器运行NixOS(https://nixos.org/),一个具有内置配置管理的声明式Linux发行版。我使用Colmena(https://colmena.cli.rs/)管理这套小型服务器集群——这是一个面向NixOS的无状态部署工具。我的配置文件已发布在GitHub(https://github.com/vincentbernat/nixops-take1/)。
### 容器化部署应用
# (https://vincent.bernat.ch/en/blog/2026-goatcounter#deploying-applications-in-containers)
为更好隔离,每个应用运行于由systemd-nspawn(https://manpages.debian.org/systemd-nspawn.1.html)驱动的轻量级临时容器中。每个容器运行精简版NixOS实例。一个NixOS模块(https://github.com/vincentbernat/nixops-take1/blob/master/modules/container.nix)封装了容器的`containers`选项(https://github.com/vincentbernat/nixops-take1/blob/master/modules/container.nix),避免为每个应用重复配置。⁵(https://vincent.bernat.ch/en/blog/2026-goatcounter#sidenote-module)容器与主机共享网络命名空间:额外的隔离带来的复杂度增加并不值得。为减少资源占用,我还禁用了一些非必要服务。
```nix
{ config, lib, ... }:
let
cfg = config.luffy.containers;
in
{
# 我们自定义模块的用户可配置设置
options.luffy.containers = lib.mkOption {
default = { };
description = "共享主机网络的临时容器。";
type = lib.types.attrsOf (lib.types.submodule {
options = {
config = lib.mkOption {
type = lib.types.deferredModule;
default = { };
description = "容器的NixOS配置。";
};
};
});
};
# 将我们的选项转换为NixOS容器配置
config = {
containers = lib.mapAttrs (name: container: {
ephemeral = true;
autoStart = true;
privateNetwork = false;
extraFlags = [ "--resolv-conf=replace-host" ];
config = {
imports = [ container.config ];
networking.firewall.enable = false;
system.stateVersion = config.system.stateVersion;
systemd.services = {
console-getty.enable = false;
systemd-logind.enable = false;
systemd-oomd.enable = false;
};
};
}) cfg;
};
}
```
要配置一个在容器中运行并监听`127.0.0.4:8088`的GoatCounter实例,我们导入模块⁶(https://vincent.bernat.ch/en/blog/2026-goatcounter#sidenote-import)并在`config.luffy.containers`属性集中声明容器:
```nix
{ pkgs, config, ... }:
{
imports = [ ./modules/container.nix ];
config.luffy.containers.goatcounter = {
config = {
services.goatcounter = {
enable = true;
address = "127.0.0.4";
port = 8088;
proxy = true;
};
};
};
}
```
由于容器是临时的,我们需要将持久化数据保存在主机的目录中。我们添加`mounts`选项,并通过NixOS容器的`bindMounts`选项暴露配置目录。
```nix
{ config, lib, ... }:
let
cfg = config.luffy.containers;
in
{
options.luffy.containers = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
mounts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "以读写方式挂载到主机相同位置的目录。";
};
};
});
};
config = {
containers = lib.mapAttrs (name: container: {
bindMounts = lib.genAttrs container.mounts (path: {
hostPath = path;
isReadOnly = false;
});
}) cfg;
};
}
```
例如,要将GoatCounter的数据库持久化在主机的`/var/db/goatcounter`目录中,我们将该目录添加到`mounts`选项,并修改服务定义以告知GoatCounter数据库位置。
```nix
{ config, ... }:
let
databaseDirectory = "/var/db/goatcounter";
in
{
config.luffy.containers.goatcounter = {
mounts = [ databaseDirectory ];
config = {
services.goatcounter = {
extraArgs = [ "-db=sqlite+${databaseDirectory}/db.sqlite" ];
};
};
};
}
```
容器可能需要一些密钥。Colmena可以上传密钥(https://colmena.cli.rs/unstable/features/keys.html)而无需将其存储在Nix Store中。我们为容器添加`keys`选项,它接受一个将密钥名称映射到填充命令的属性集。随后,模块在`deployment.keys`选项中声明所需密钥,使容器依赖密钥存在,并将其暴露给容器。
```nix
{ config, lib, ... }:
let
cfg = config.luffy.containers;
in
{
options.luffy.containers = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
keys = lib.mkOption {
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
default = { };
description = "密钥,作为本地运行的命令。挂载在/etc目录下。";
};
};
});
};
config = {
# Colmena将每个密钥上传到`/var/keys`,并使"keys"组可访问
deployment.keys = lib.concatMapAttrs (_: container:
lib.mapAttrs (_: keyCommand: {
inherit keyCommand;
group = "keys";
permissions = "0640";
destDir = "/var/keys";
}) container.keys
) cfg;
# 容器仅在所需密钥可用时才能启动
systemd.services = lib.mapAttrs' (name: container:
let
units = map (key: "${key}-key.service") (lib.attrNames container.keys);
in
lib.nameValuePair "container@${name}" {
requires = units;
after = units;
}
) cfg;
# 将每个密钥挂载到容器内
containers = lib.mapAttrs (name: container: {
bindMounts = lib.mapAttrs' (key: _:
lib.nameValuePair "/etc/${key}" {
hostPath = "/var/keys/${key}";
isReadOnly = true;
}
) container.keys;
}) cfg;
};
}
```
例如,GoatCounter需要凭证来下载GeoIP数据库。我提供了一个本地命令从密码管理器中获取密钥,并通过`/etc/goatcounter.env`环境文件将其暴露在容器内。
```nix
{ pkgs, config, ... }:
let
keyCommand = variable: [
"${pkgs.runtimeShell}" "-c"
"pass show personal/nixops/secrets | grep '^${variable}='"
];
in
{
config.luffy.containers.goatcounter = {
keys."goatcounter.env" = keyCommand "GOATCOUNTER_GEODB";
config = {
systemd.services.goatcounter.serviceConfig = {
EnvironmentFile = "/etc/goatcounter.env";
SupplementaryGroups = [ "keys" ];
};
};
};
}
```
### GoatCounter服务器
# (https://vincent.bernat.ch/en/blog/2026-goatcounter#goatcounter-server)
Nixpkgs已打包GoatCounter。通过覆盖`src`和`vendorHash`属性,我复用其定义来构建包含代理功能的自定义版本:
```nix
{ goatcounter, fetchFromGitHub }:
goatcounter.overrideAttrs (_: {
src = fetchFromGitHub {
owner = "vincentbernat";
repo = "goatcounter";
rev = "feature/proxy";
hash = "sha256-dJRlQlFu3tjcEgabT1LEbyFrasJlhmYu4L/T7EkoNcY=";
};
vendorHash = "sha256-c9Q5OrbZR+q6pD3SgPPWe8JUzcZco1AVUKGaV61k5DE=";
})
```
我编写了一个NixOS模块(https://github.com/vincentbernat/nixops-take1/blob/master/modules/goatcounter.nix)来封装GoatCounter:包括容器定义、服务定义和密钥管理。该模块接受以下选项:`package`、`serve.enable`、`serve.listenAddress`、`serve.port`和`serve.databaseFile`。
前一节已详细说明容器配置。最终,我选择不复用NixOS中的GoatCounter模块:它结构简单,但将其隔离有助于避免未来意外变更的影响。
```nix
{ config, pkgs, lib, ... }:
let
cfg = config.luffy.goatcounter;
databaseDirectory = builtins.dirOf cfg.serve.databaseFile;
chown = "${pkgs.coreutils}/bin/chown -R";
in
{
config.luffy.containers.goatcounter = {
config.systemd.services.goatcounter = {
description = "GoatCounter Web Analytics";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
EnvironmentFile = "/etc/goatcounter.env";
SupplementaryGroups = [ "keys" ];
DynamicUser = true;
Restart = "always";
ExecStart = lib.escapeShellArgs [
(lib.getExe cfg.package) "serve"
"-listen=${cfg.serve.listenAddress}:${toString cfg.serve.port}"
"-tls=none"
"-db=sqlite+${cfg.serve.databaseFile}"
"-automigrate"
];
# 将数据库所有权转移给动态分配的"goatcounter"用户
ExecStartPre = "+${chown} goatcounter:goatcounter ${databaseDirectory}";
ReadWritePaths = databaseDirectory;
};
};
};
}
```
以下代码片段配置GoatCounter监听`127.0.0.4:8088`:
```nix
{ luffy.goatcounter = {
serve = {
enable = true;
listenAddress = "127.0.0.4";
port = 8088;
};
};
}
```
最后一步是配置nginx将GoatCounter暴露到互联网。由于本地代理处理`/count`端点,我禁用了此路径。
```nix
{ config, ... }:
let
cfg = config.luffy.goatcounter.serve;
in
{
services.nginx.virtualHosts."goatcounter.luffy.cx" = {
forceSSL = true;
locations = {
"/" = {
proxyPass = "http://${cfg.listenAddress}:${toString cfg.port}";
};
"= /count".extraConfig = ''
return 404;
'';
};
};
}
```
### GoatCounter代理
# (https://vincent.bernat.ch/en/blog/2026-goatcounter#goatcounter-proxy)
该NixOS模块(https://github.com/vincentbernat/nixops-take1/blob/master/modules/goatcounter.nix)同样定义了代理服务。
```nix
{ config, pkgs, lib, ... }:
let
cfg = config.luffy.goatcounter.proxy;
in
{
config.systemd.services.goatcounter-proxy = {
description = "GoatCounter Local Proxy";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "goatcounter-proxy";
Group = "goatcounter-proxy";
DynamicUser = true;
Restart = "always";
ExecStart = lib.escapeShellArgs [
"${cfg.package}/bin/goatcounter" "proxy"
"-listen=${cfg.listenAddress}:${toString cfg.listenAddress}"
"-api-key=${cfg.apiKey}"
];
ReadWritePaths = [ cfg.bufferPath ];
};
};
}
```
配置示例:
```nix
{ luffy.goatcounter.proxy = {
enable = true;
listenAddress = "127.0.0.3";
port = 8087;
apiKey = "your-api-key-here";
bufferPath = "/var/lib/goatcounter-proxy";
};
}
```
### 使用Litestream备份SQLite数据库
# (https://vincent.bernat.ch/en/blog/2026-goatcounter#backup-of-the-sqlite-database-with-litestream)
Litestream(https://litestream.io/)可对SQLite数据库进行连续备份。我配置它定期将数据库同步到Backblaze B2存储桶。
```nix
{ config, pkgs, lib, ... }:
let
cfg = config.luffy.goatcounter;
dbPath = "${cfg.serve.databaseFile}";
in
{
systemd.services.litestream = {
description = "Litestream for GoatCounter database";
wantedBy = [ "multi-user.target" ];
after = [ "goatcounter.service" ];
serviceConfig = {
User = "goatcounter";
Group = "goatcounter";
ExecStart = ''
${pkgs.litestream}/bin/litestream replicate -exec \
"${cfg.package}/bin/goatcounter serve ..."
${dbPath} s3://your-bucket-name/path/to/backup
'';
Restart = "always";
};
environment = {
LITESTREAM_ACCESS_KEY_ID = "your-access-key";
LITESTREAM_SECRET_ACCESS_KEY = "your-secret-key";
};
};
}
```
此配置确保即使GoatCounter宕机,数据库仍能持续备份,并可随时恢复。
---
以上即为我在NixOS上部署GoatCounter自托管分析平台的完整过程,涵盖定制客户端、本地代理、容器化部署及数据库备份。通过组合这些组件,实现了轻量、隐私友好且可靠的博客访问分析。
相似文章
Show HN: OSS Cross-Harness 自托管AI智能体注册与分析平台
Observal 是一个开源、自托管的注册与分析平台,适用于AI组件(如智能体和MCP服务器),为组织提供可发现性和使用洞察。
自托管MCP用于AI引用追踪——无需后端,无需注册,自带密钥
一个自托管、开源的MCP服务器,用于AI引用追踪,完全运行在用户机器上,支持离线工具和直接API调用,无需后端或遥测。
构建了一个只读分析代理(路由 → 获取 → 叙述 → 验证)。在我让它写入任何内容之前,我可能遗漏了什么?
一位开发者描述了使用Node.js、TypeScript和Gemini Flash构建用于餐厅POS系统的只读分析代理,并寻求关于工具选择、数字验证技术和实现写入操作的模式的建议。
@x5cnhp: 捣鼓了这么多年自建梯子,偶然发现这个仓库——是不是有点太…
该推文分享了一个仓库,其中包含设置稳定AI流量代理策略的透明指南,使用Hysteria2和WARP等工具,不夸大安全性承诺。
@hasantoxr: 发现这个后,我再也不愿每月花 500 美元购买反检测浏览器了。它叫 CloakBrowser。一款隐秘的 Chromium……
本文介绍了 CloakBrowser,这是一款基于 Chromium 的开源隐秘浏览器,旨在绕过 reCAPTCHA 和 Cloudflare Turnstile 等机器人检测系统。它声称通过直接修补 C++ 源代码而非注入 JavaScript 来提供卓越的隐秘能力,定位自己为昂贵商业反检测浏览器的免费替代方案。