在NixOS上使用GoatCounter进行无机器人自托管分析

Lobsters Hottest 工具

摘要

作者详细介绍了在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自托管分析平台的完整过程,涵盖定制客户端、本地代理、容器化部署及数据库备份。通过组合这些组件,实现了轻量、隐私友好且可靠的博客访问分析。

相似文章