跳到正文
SL Blog 技术探索 · 工程实践 · AI 时代思考
返回

goaccess-traffic-monitoring-and-tradeoffs

文章目录
  1. 为什么不继续用前端埋点
  2. 一、落地:最小可用架构
  3. 1.1 没有 root 也能从源码构建
  4. 1.2 实时仪表盘的三个坑
  5. 二、比搭建更容易踩的坑:数字口径
  6. 2.1 「独立访客」的指纹是什么
  7. 2.2 爬虫:排到哪一层是个取舍
  8. 2.3 静态资源会淹没内容
  9. 2.4 按站点分的判据是域名,不是文件名
  10. 三、鉴权:GoAccess 自己没有登录系统
  11. 四、可复用的取舍清单
  12. 小结

为什么不继续用前端埋点

我一开始做流量统计,走的是自建前端埋点:在页面里插一段脚本,把访问上报到自己的后端。要区分人和机器人,就得在埋点逻辑里加判断——站点一多,每来一个新站、每改一次版,都得回去动一遍前端代码。

站点少的时候这不算什么,站点多起来之后,这种侵入性开始变得难以接受:埋点点位散落在各个页面模板里,改版容易漏、容易重复;更要命的是它天然只看得到「能跑 JS、装了脚本、又没被拦截」的那部分访问者。

于是我转头去找现成的统计工具,就这样发现了 GoAccess。这条路走通之后,原来那套前端埋点整个撤掉了。

回头看,这个取舍背后是三条理由,按重要性排序:

一、不可侵入。 统计从业务代码里彻底剥离:站点再多、页面怎么改版,流量监控都不用跟着动。这一点在站点变多之后成了决定性因素。

二、埋点式的观测是有偏的。 前端脚本会被广告拦截器、隐私插件、无 JS 客户端干掉。你看到的是 ” 装了统计脚本并且没被拦的那部分人 “,而不是全部访问者。日志视角没有这个问题——请求打到服务器上就一定有一行记录。

三、可解释性。 埋点给你一个 ” 用户数 “,但你很难回答 ” 这个数是怎么算出来的 “。而日志分析可以一路追到源码,这在我后面踩口径坑的时候救了很多次。

代价当然也有:日志里没有 Cookie、没有会话、没有事件,想知道 ” 用户点了哪个按钮 ” 是做不到的。所以这不是替代关系,而是知道自己在数什么的问题。对于 ” 有多少人来、看了什么、从哪来 “,日志足够且更可信。

以下内容基于 GoAccess(一个用 C 写的实时日志分析器,MIT 协议)。它把 nginx 的 access log 解析成终端面板或自包含的 HTML 报表,支持 WebSocket 实时推送。

一、落地:最小可用架构

目标:几个站点各自的实时仪表盘,统一入口、统一登录,只在已有的 HTTPS 端口上对外。

浏览器 ── https://blog.example.com/stats/           ┐
         └─ wss://blog.example.com/stats/<站点>/ws ─┤
                                                    ▼
                                            nginx (443, TLS)
  /stats/                  → 鉴权服务(登录页 / 索引 / 会话)
  /stats/<站点>/           → nginx 直接读盘提供该站点的实时页面
  /stats/<站点>/ws         → 反代到该站点的 GoAccess WebSocket
  /__authz (internal)      → 反代到鉴权服务的会话校验端点

要点:GoAccess 只负责解析日志和推 WebSocket,它不提供网页,也没有登录系统。 页面托管和鉴权都得自己补——这是后面第三节的主题。

1.1 没有 root 也能从源码构建

我的环境一开始没有 sudo,而且缺 autoconf/automake/m4;GoAccess 的仓库里只有 configure.ac,没有生成好的 configure,官方又没有预编译包。可行的路子是用 apt-get download(不需要 root)把工具链的 deb 解包到自己的目录里自举:

mkdir -p ~/toolchain && cd ~/debs
apt-get download m4 autoconf automake gettext autotools-dev
for d in *.deb; do dpkg -x "$d" ~/toolchain/; done
export PATH=$HOME/toolchain/usr/bin:$PATH

这一步有两个坑,都不是 ” 报错就停 ” 那种,值得记下来:

坑一:deb 里的脚本硬编码了绝对路径。 解包出来的 autoconf/automake 是 Perl 脚本,内嵌 /usr/share/autoconf、/usr/bin/m4 这类路径,指向不存在的位置。需要把这些默认值改写成解包前缀,并让 PERL5LIB 指向解包出的模块目录。

坑二:msgfmt 加载失败时 configure 不报错,而是静默降级。 解包出的 gettext 工具依赖同目录的 libgettextsrc-*.so;加载不到时 configure 不会失败,而是把 MSGFMT 悄悄替换成 :(一个 no-op 命令),直到 make 阶段才以 mv: cannot stat 't-de.gmo' 这种风马牛不相及的错误炸掉。排查方式是看 config.log 里 ac_cv_path_MSGFMT 的值:

grep ac_cv_path_MSGFMT config.log      # 期望是真实路径,不是 ":"

中文化也有一处反直觉的地方:GoAccess 没有任何 --lang 之类的参数,界面语言完全走 setlocale(LC_ALL, "") + gettext。所以前提是目标 locale 必须存在。系统里只有 en_US.UTF-8 时,可以在不装 locale 包的前提下自己生成并让 glibc 找到它:

localedef --no-archive -i zh_CN -f UTF-8 /opt/goaccess/locale/zh_CN.UTF-8
LOCPATH=/opt/goaccess/locale LC_ALL=zh_CN.UTF-8 date '+%A %B'   # 验证:星期四 九月

顺带一个细节:把这个 locale 交给 systemd 服务时,不要 export LC_ALL 就完事,而是用 Environment= 声明。因为 shell 在启动时就缓存了 locale,之后再改 LC_ALL 会报警告(setlocale: LC_ALL: cannot change locale)——警告无害但会让人以为出事了。

构建选项上,--enable-utf8(宽字符终端)、--with-openssl、--with-zlib(能直接读 .gz 的轮转日志,不用手动 zcat)都值得开;--enable-geoip=mmdb 需要额外的 .mmdb 国家库,注意 MaxMind 的免费库现在需要账号,没有库时对应面板就是空的。

1.2 实时仪表盘的三个坑

实时模式本身很简单:

goaccess /var/log/nginx/blog-access.log \
  --config-file=/opt/goaccess/goaccess.conf \
  --real-time-html \
  --port=9300 \
  --ws-url=wss://blog.example.com/stats/blog/ws \
  --output=/srv/reports/blog/index.html

它先解析整个文件,然后持续 tail,通过 WebSocket 把增量推给浏览器。但把这条命令放进「nginx 反代 + 只对外开 443」的架构里,会连撞三个坑:

坑一:--port 默认监听 0.0.0.0,绑定地址得另用 --addr 指定。

--port 只决定端口号,监听地址默认落在 0.0.0.0(gwsocket.c 里 ws_init ("0.0.0.0", "7890", …))。也就是说:如果不额外处理,未鉴权的实时流量数据会直接暴露在公网——任何人连上就能读走。

GoAccess 提供了 --addr=<addr> 来指定绑定地址,--addr=127.0.0.1 就是官方 README 里给出的写法:

goaccess /var/log/nginx/blog-access.log \
  --real-time-html --addr=127.0.0.1 --port=9300

我这里最终是在防火墙上把这一段端口限制为回环,并且单独写一个 systemd unit 来管这条规则:

iptables -A INPUT ! -i lo -p tcp --dport 9300:9309 -j DROP

这里有个容易做错的地方:如果把规则写进每个实例的 ExecStartPre/ExecStopPost,停掉任意一个实例就会删掉规则,导致其余仍在运行的实例端口裸奔。规则的生命周期必须覆盖所有实例,所以它得是独立的 unit,由各实例 Requires= 它。

坑二:--port 不只是监听端口,它还会被写进页面,当作「浏览器该连的端口」。

源码 output.c 里两行说明了一切:

fpskeysval (fp, "url",  (conf.ws_url ? conf.ws_url : ""));        // 来自 --ws-url
fpskeyival (fp, "port", (conf.port ? atoi (conf.port) : 7890));   // 只能来自 --port(7890 是上游内置默认值)

生成出来的页面里是:

{"url": "wss://blog.example.com/stats/blog/ws", "port": 9300, "ping_interval": 0}

前端 buildWSURI() 会把这两者拼起来:wss://<hostname>:<port><pathname>。所以浏览器会去连 :9300——而 9300 正是我刚刚在防火墙上封掉的内部端口。

症状特别有误导性:页面能打开,数据却半天不出现,最后显示一句 “Unable to authenticate WebSocket.”。实际上根本不是鉴权失败——查 app.js 会发现这句话挂在 socket.onclose 上,是个通用的断开提示。真正的证据在 nginx 访问日志里:页面加载是 200,但完全没有 /ws 的握手记录,因为请求根本没发到服务器。

GoAccess 没法把 ” 监听端口 ” 和 ” 对外端口 ” 分开配置——页面里写的端口就是它监听的端口。在我的架构里,最省事的解法是在 nginx 出站时改写这一个字段:

location /stats/blog/ {
    alias /srv/reports/blog/;
    index index.html;
    gzip off;                                       # sub_filter 不能处理压缩响应
    sub_filter '"port": 9300' '"port": 443';
    sub_filter_once on;
    sub_filter_types text/html application/javascript;
}

坑三:会话 Cookie 的路径匹配规则比直觉严格。

所有页面都挂在 /stats/ 前缀下,会话 Cookie 的 Path=/stats。我最初把实时页面放在 /stats-live/,结果浏览器根本不带 Cookie,永远处于未登录状态。

原因是 RFC 6265 的路径匹配规则:cookie 路径的最后一个字符必须是 /,或者请求路径中紧跟在它后面的字符必须是 /。所以 /stats 能匹配 /stats/、/stats/blog/,但匹配不上 /stats-live/(下一个字符是 -)。

结论:共享会话的路径必须挂在同一前缀之下,而不是去放宽 Cookie 的作用域到 /。

二、比搭建更容易踩的坑:数字口径

架构搭好之后,真正的麻烦才开始:报出来的数字和你心里预期对不上。 我差不多把每个关键指标都追到了源码,下面按 ” 最容易误读 ” 的顺序排列。

2.1 「独立访客」的指纹是什么

parser.c 里生成访客指纹的函数注释写得很直白:

The fingerprint is made out of the IP and user agent hash; the date is implied by the per-date storage partitioning.

即:指纹 = hash(IP, User-Agent),日期由按天分区隐含。URL 不在其中。

而汇总函数(gkmhash.c)的注释是:

Get the total number of unique visitors across all dates. The per-date counters are authoritative…

它是把每天的计数相加。这两点合起来决定了一切:

场景计几次访客说明
同一天、同 IP、同 UA,访问 10 个不同页面1URL 不参与指纹
同一天、同 IP,但换了 UA(浏览器 vs 脚本)2UA 参与指纹
同 IP + 同 UA,连续 5 天每天来一次5跨天不去重
同 IP + 同 UA,同时访问两个不同站点1虚拟主机也不参与指纹
返回 4xx 的请求0见下
返回 301 / 500 的请求1只有 4xx 特殊

我用一份构造日志实测过,结论与源码一致:同一 IP 同一 UA 请求 4 个不同页面(含一个 302),访客数是 1;跨一天再访问一次,变成 2。

所以「独立访客」的准确含义是「人·天」,不是「人数」。 这就是它和第三方分析对不上的根本原因——后者通常用 Cookie 在整个统计周期内去重。

顺带一个反直觉的细节:4xx 默认不计入访客。gstorage.c 的 include_uniq() 只对 4xx 返回 ” 不计数 “,除非显式加 --4xx-to-unique-count。这个设计其实很合理(扫描器打出来的 404 不该算人),但意味着:过滤掉大量 404 时,” 总请求数 ” 会明显下降,而 ” 独立访客 ” 几乎不动。 我实测过滤掉一批 404 之后,请求数降了约 16%,而访客数只降了 0.3%。

2.2 爬虫:排到哪一层是个取舍

这里有三档口径,从宽到严:

档位参数行为
原始无全部计入
排除已知爬虫--ignore-crawlers只排 GoAccess 内置表能识别的爬虫 / AI 爬虫
连未知也排再加 --unknowns-as-crawlers把 ” 未知 OS / 浏览器 ” 也归类为爬虫

实测某台服务器上,爬虫占 ” 独立访客 ” 的比例:全站合并约 24%,而博客类站点高达 41%——内容站被爬是常态。所以这一档选择对结论影响很大。

两个必须知道的行为:

一、--ignore-crawlers 不会降低「总请求数」。 受控实测:同一份输入,加不加这个参数,总请求数一模一样(差 0)。GoAccess 在 ” 总请求 ” 这个指标上保留原始解析计数,过滤只作用于独立访客、传输量和各面板。所以看 ” 真实访客 ” 要看独立访客那一栏,别盯着总请求数。

二、--unknowns-as-crawlers 会误伤真实客户端。 它对应用/API 类站点尤其明显:脚本、CLI、自定义客户端的 UA 往往无法被识别,会被一并当作爬虫清掉。我的一台应用服务器上,切到这一档后访客数掉了两成多(约 -23%),其中一部分很可能是正常的 API 调用。

所以取舍是: 内容站(博客、文档)大胆用第三档,因为爬虫占比高且大部分是没有分析价值的抓取;API/应用站建议只用第二档,或者分站点设置不同口径。

2.3 静态资源会淹没内容

博客类站点的请求里,JS/CSS/字体/图片这类静态资源的占比往往过半。如果不把它们单独归类,你的 ” 请求文件(URL)” 面板会变成一长串 /_astro/xxx.webp、/_astro/xxx.woff2,真正的文章页被埋在下面。

GoAccess 有 --static-file=<扩展名> 来做这件事,但这里有两个坑,第一个尤其隐蔽:

坑一:使用了配置文件、却没有提供任何 static-file 时,默认列表根本不会加载。 源码 settings.c:

/* If a configuration file is used and, if no static-file extensions are provided,
   do not set the default static-file extensions. */
if (conf.iconfigfile != NULL && conf.static_file_idx == 0)
    return;

也就是说,只要你用了 --config-file,静态资源识别就整个被关掉了——” 静态文件 ” 永远是 0。这是我最开始怎么调都不对的原因。

坑二:默认列表本身有一批条目匹配不上。 即使触发了默认列表,set_default_static_files() 里的这一串字符串末尾多了空格:

".js ", ".doc ", ".ppt ", ".iso ", ".gz  ", ".rar ",
".svg ", ".bmp ", ".tar ", ".tgz ", ".tif ", ".ttf ", ".flv ",

而 verify_static_content() 只比较请求路径末尾 N 个字符:

if (nul - req > elen && !strncasecmp (nul - elen, ext, elen))

多一个空格的 .js 长度是 4,拿去和 /a.js 的最后 4 个字符比较,永远不相等。所以 .js 必须自己写进配置,别指望默认值。

配的时候还要注意白名单的方向。 我刻意没有把 .php、.env、.yml、.bak 收进来:这些扩展名在你自己的站点里几乎不会出现,但在扫描器的探测请求里出现频率极高(.env、/wp-admin/install.php 这类)。把它们当静态资源,等于把 ” 有人正在扫我 ” 这件事从 URL 面板里藏掉。同理 .txt/.xml 我也没有收——robots.txt、rss.xml 是真实端点,留在面板里更有意义。

配置示例:

static-file .js
static-file .mjs
static-file .css
static-file .map
static-file .png
static-file .webp
static-file .svg
static-file .woff
static-file .woff2
static-file .avif

效果:某博客当天的 ” 请求文件 ” 从几十项收敛到二十几项,剩下的正好是文章与标签页;访客数完全不变(静态资源本来也不改变访客指纹)。

另外一个反直觉的实测结论:--ignore-panel=REQUESTS_STATIC(不显示静态面板)不会让资源重新混回 URL 面板,它只是不显示静态计数与面板。所以 ” 精简面板 ” 和 ” 静态资源识别 ” 可以共存。

2.4 按站点分的判据是域名,不是文件名

这是我踩得最狠的一个坑,值得单独讲。

nginx 的默认 combined 日志格式里只有这些字段:

$remote_addr  $time_local  "$request"  $status  $body_bytes_sent  "$referer"  "$user_agent"

没有 $host。 所以日志本身无法告诉你 ” 这个请求打的是哪个域名 “。我一开始想当然地按文件名推断站点——blog-access.log 就当成 ” 博客站点 “。结果:

我以为是实际是
某个站点的日志listen 8081; server_name _; 的端口 catch-all,而且那份配置根本没启用,日志恒为空
另一个站点的日志server_name _ 的 catch-all,里面几乎全是扫描器(/login.asp、zgrab 的 UA、RDP 探测)
又是一个站点没有任何 nginx 配置写这个文件,恒为空
” 主站 “nginx.conf 里的全局兜底 access_log,收的是所有未匹配 server_name 的请求,内容基本是 /.env、/wp-admin/install.php

七个 ” 站点 ” 里有四个是空文件或扫描噪音桶。

正确的判据是 server_name + DNS 是否解析:只有配了独立 server_name、且域名确实解析的 server 块,它的日志才对应一个真实站点。

而如果日志里要真正带上域名(比如一个 server 块配了多个域名,或者想看兜底流量打的是谁),就得改 log_format:

log_format with_host '$host $remote_addr - $remote_user [$time_local] "$request" '
                     '$status $body_bytes_sent "$http_referer" "$http_user_agent"';

对应的 GoAccess 格式串把 %v 放在最前面:

log-format %v %h %^[%d:%t %^] "%r" %s %b "%R" "%u"

但直接换格式有个迁移问题:日志是按天轮转、追加写入的,改格式当天,同一个文件里会新旧格式混杂,而 GoAccess 一次只能用一种格式解析;同时,最近一个月的轮转日志会变成无法解析。

零破坏的做法是双写:nginx 允许一个 server 块配多条 access_log,所以保留原日志不动,另加一路带域名的:

access_log /var/log/nginx/blog-access.log;          # 老格式,历史与既有分析继续可用
access_log /var/log/nginx/host-blog.log with_host;  # 新格式,从启用时刻起干净

这样一来:历史日志一字未动、照常可解析;新日志从启用时刻起就是干净的带域名版本。

一个小细节:新日志要平铺在 /var/log/nginx/ 根目录下,不要放子目录。 因为 /etc/logrotate.d/nginx 里的 glob 是 /var/log/nginx/*.log,不递归——放子目录会绕开轮转规则、无限增长。

三、鉴权:GoAccess 自己没有登录系统

前面提过,GoAccess 只解析日志和推 WebSocket。它确实有一组看起来像鉴权的参数(--ws-auth、--ws-auth-url、--ws-auth-expire),但看清楚它们的语义会发现:它只做 JWT 的验证,签发仍然要靠你自己的后端。换句话说,它把 ” 谁来签发 token” 这件事留给了你。

我选择不引入 JWT 这一套,而是让 nginx 复用网页登录的会话:

  • 自己做一个小服务提供登录页(口令用 PBKDF2-HMAC-SHA256 + 随机盐存哈希,绝不明文落盘;登录限速;会话 Cookie 带 HttpOnly/SameSite=Lax/Secure)
  • 它额外暴露一个仅限回环的内部端点 /__authz,按会话 Cookie 返回 200/401
  • nginx 用 auth_request 在需要保护的位置调用它
location = /__authz {          # 内部端点,internal 保证外部无法直接访问
    internal;
    proxy_pass http://127.0.0.1:9391/__authz;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
}

location = /stats/blog/ws {    # WebSocket 同样要过鉴权
    auth_request /__authz;
    proxy_pass http://127.0.0.1:9300;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 86400;
    proxy_buffering off;
}

这样网页和 WebSocket 共用同一个会话,不需要第二套密钥体系。几个值得抄的细节:

  • 限速要按 IP 计数,而且只在直连方是回环地址时才信任 X-Real-IP——否则有人绕过反代直连就能伪造该头逃避限速。
  • ?next= 和 X-Forwarded-Prefix 这类参数必须做白名单。我在这里漏过一个真实的开放重定向:正则里字符类包含了 /,导致 X-Forwarded-Prefix: //evil.com 能拼出 Location: //evil.com/login。协议相对 URL 必须以显式判断挡掉,不能只靠字符类。
  • 自签目录下的静态文件也要防目录穿越:translate_path 之后再做一次 realpath 包含性校验。

四、可复用的取舍清单

把上面所有决定压缩成一张表:

决策点选项我的选择与理由
数据来源前端埋点 vs 直接读日志读日志:埋在业务代码里的统计被剥离,且不受脚本拦截影响
页面托管GoAccess 自带 vs 反代读盘反代:它不提供网页,且反代能统一复用会话鉴权
WebSocket 暴露直接对外 vs 仅回环仅回环(--addr=127.0.0.1,必要时再加防火墙兜底)
对外端口新开端口 vs 复用 443复用 443,避免动安全组、复用现有证书
访客口径原始 / 排已知爬虫 / 连未知也排内容站用最严;API 站只用中间档
静态资源混在 URL 面板 vs 单独归类单独归类(否则内容被资源淹没),且用白名单
按站点分文件名 vs 日志里的 $host短期按 server_name 判定;长期双写 $host 日志
鉴权GoAccess JWT vs 复用网页会话复用会话,少一套密钥体系
界面全面板 vs 按需裁剪裁剪(--ignore-panel 连解析一起关,省资源)

小结

三条我最有体会的经验:

一、” 这个数字的定义是什么 ” 要先于 ” 这个数字对不对 ”。 我最初以为访客数对不上是配置问题,查完源码才发现是定义问题——「人·天」和「去重人数」本来就是两个东西。任何监控指标,先搞清楚它的指纹/口径,再去和别的系统对比。

二、过滤类参数的 ” 作用层 ” 必须确认,不能想当然。 --ignore-crawlers 不降低总请求数、--ignore-panel=REQUESTS_STATIC 不同时禁用静态识别——这类行为只能靠实测确认,文档不会告诉你。

三、把自己顺手的假设写成判据。 ” 文件名就是站点名 ” 这个假设舒服又自然,但它错了四个。最后我把它换成了一条可验证的判据:看 server_name 和 DNS,不看文件名。

至于工具本身,GoAccess 是个踏实的选择:单文件、无依赖、能直接读压缩日志、实时模式够用。它的短板(没有登录、无法限制绑定地址、口径不能分离配置)都可以用很薄的运维层补上,而且源码可读——在口径这件事上,能读源码本身就是最大的优势。


RAG 智能问答

针对本文继续提问:《goaccess-traffic-monitoring-and-tradeoffs》