网站访问统计需要同时看日志、请求趋势和访客来源。我尝试过几种方案,最后用 Grafana Loki 接收 OpenResty 日志,再在 Grafana 里用 LogQL 做 PV/UV 和访问趋势查询。轻量部署可以直接使用 Grafana Cloud 和现成的 Nginx Dashboard。下面是这套配置的实际过程和仍未解决的问题。
最终效果展示#
经过配置和优化,最终实现的监控面板效果如下:

图:基于 Loki 的网站访问统计面板,包含 PV/UV、地理分布、访问趋势等关键指标
网站技术架构#
网站由 Hugo 生成静态文件,再用 Docker 和 Nginx 打成镜像运行在云主机上。Watchtower 负责拉取新镜像,Pipeline 负责构建并推送到 Docker Hub。这个规模不需要 Kubernetes,单机容器更省维护时间。
入口使用 OpenResty 做反向代理,让多个服务共用 443 端口;缓存、压缩和 WAF 规则 也放在这一层。访问日志从这里进入后续的 Loki 链路。
Loki 的工作方式#
Loki 只为日志流标签建立索引,日志正文保存在块存储中。查询使用 LogQL,语法接近 PromQL,可以直接在 Grafana 中检索、画图和配置告警。它支持多租户,也能从单进程模式拆成多个组件;个人网站先用托管服务或单实例即可。
Loki 架构组件#
Loki 可以把各组件放在同一个进程中运行,也可以按负载拆开部署。
Promtail:Loki 的代理,负责收集日志并将它们发送到 Loki。Promtail 通常在产生日志的机器上运行,可以直接读取日志文件,也可以接收由其他进程(如 Fluentd 或 Fluent Bit)转发的日志。
Loki:主要的日志聚合和查询组件,接收并存储日志,同时提供查询接口。Loki 通过索引日志流(而不是每一行日志)来提供高效的存储和查询。
Distributor:负责接收来自 Promtail 的日志数据,然后将这些数据分发到多个 Ingester。
Ingester:负责接收日志数据,将数据压缩后存储在内存中,然后定期将这些数据刷新到长期存储(如 Amazon S3 或 Google Cloud Storage)。
Querier:负责处理来自用户的查询请求。Querier 会从 Ingester 和长期存储中获取数据,然后返回查询结果。
Query Frontend:负责优化和加速查询。Query Frontend 会将大查询分解为多个小查询,然后并行执行这些小查询。
Compactor:负责压缩和优化在长期存储中的数据。
Ruler:负责执行预定义的规则和警报。
配置日志格式#
由于我使用的是 OneinStack 一键部署的 OpenResty,按照该 Dashboard 中的描述,需要对日志配置 GeoIP,需要对 OpenResty 重新编译开启。查看了 GeoIP 的选项,我选择 GeoIP2 Databases 作为数据库,只需要编译一下 module,在使用时进行 load 即可。
相关资源:
- 模块仓库地址:https://ghproxy.com/https://github.com/leev/ngx_http_geoip2_module.git
- 参考文档:https://www.electrosoftcloud.com/en/compile-geoip2-in-openresty-and-how-to-use-it/
OpenResty 编译 GeoIP2 模块#
安装依赖#
按照上面文档的描述,首先需要安装 maxminddb 依赖。在 CentOS 7 系统中使用以下命令安装:
yum install -y libmaxminddb-devel libmaxminddb
克隆 GeoIP2 模块代码#
克隆 GeoIP2 模块仓库代码到目录(编译时需要):
mkdir -p /tmp/compile/openresty-$(nginx -v 2>&1|cut -d "/" -f2)/modules
cd /tmp/compile/openresty-$(nginx -v 2>&1|cut -d "/" -f2)/modules
git clone https://ghproxy.com/https://github.com/leev/ngx_http_geoip2_module.git
配置编译环境#
进入 OpenResty 源码目录进行编译。使用 OneinStack 安装的话,包会统一存放在 ./src 目录下。
注意:这里 OneinStack ROOT_PATH 为
/data/scripts/oneinstack,请替换为你的实际路径
# 进入 OpenResty 源码目录
cd /data/scripts/oneinstack/src/openresty-1.19.3.1/bundle/nginx-1.19.3
# 配置编译时所需的环境变量(否则将失败)
export LUAJIT_LIB="/usr/local/openresty/luajit/lib/"
export LUAJIT_INC="../LuaJIT-*/src/"
# 获取已安装的 OpenResty 编译选项,以避免"二进制不兼容"错误
COMPILEOPTIONS=$(nginx -V 2>&1|grep -i "arguments"|cut -d ":" -f2-)
# 使用这些选项配置编译,将 GeoIP2 添加为动态模块
eval ./configure $COMPILEOPTIONS --add-dynamic-module=/tmp/compile/openresty-1.19.3.1/modules/ngx_http_geoip2_module/

编译模块#
上一步成功后,开始执行编译:
# 仅编译模块
make modules

这一步成功后,会在当前 objs 目录下生成所需的动态库文件:
ls -lh objs/*.so
-rwxr-xr-x 1 root root 86K Jul 27 11:22 objs/ngx_http_geoip2_module.so
-rwxr-xr-x 1 root root 62K Jul 27 11:22 objs/ngx_stream_geoip2_module.so # 这个动态库文件为 L4 使用,我们使用上面那个即可
配置 OpenResty 加载 GeoIP2 动态库#
# 创建模块目录
mkdir -p /usr/local/openresty/nginx/modules
# 复制动态库文件,方便后续引用
cp -a objs/*.so /usr/local/openresty/nginx/modules/
# 编辑 Nginx 主配置文件,加入模块加载指令
vim /etc/nginx/nginx.conf
# 在配置文件开头添加以下行
load_module modules/ngx_http_geoip2_module.so;

OpenResty 配置对接 GeoIP 数据库#
模块加载后,实际使用还需要一个 GeoIP 数据库。到官网下载需要注册账号,有账号的可以通过官网下载。如果注册遇到问题,可以使用 GitHub 上的镜像仓库:
我下载的是 GeoLite2-Country.mmdb,目前已够用。
下载并配置 GeoIP2 数据库#
下载 GeoIP2 数据库到 /etc/nginx/geoip2 并配置加载:
# 在 Nginx 主配置文件的 http 段中加入以下内容
vim /etc/nginx/nginx.conf
# GeoIP 配置
geoip2 /etc/nginx/geoip2/GeoLite2-Country.mmdb {
auto_reload 5m;
$geoip2_metadata_country_build metadata build_epoch;
$geoip2_data_country_code default=US country iso_code;
$geoip2_data_country_name country names en;
}

配置日志格式#
按照 Dashboard 文档配置日志格式 json_analytics:
注意:
geoip_country_code这里因为我们使用的是 GeoIP2,需要替换为我们上面所定义的变量geoip2_data_country_code
vim /etc/nginx/nginx.conf
# 添加 Dashboard 所需的日志格式
log_format json_analytics escape=json '{'
'"msec": "$msec", ' # request unixtime in seconds with a milliseconds resolution
'"connection": "$connection", ' # connection serial number
'"connection_requests": "$connection_requests", ' # number of requests made in connection
'"pid": "$pid", ' # process pid
'"request_id": "$request_id", ' # the unique request id
'"request_length": "$request_length", ' # request length (including headers and body)
'"remote_addr": "$remote_addr", ' # client IP
'"remote_user": "$remote_user", ' # client HTTP username
'"remote_port": "$remote_port", ' # client port
'"time_local": "$time_local", '
'"time_iso8601": "$time_iso8601", ' # local time in the ISO 8601 standard format
'"request": "$request", ' # full path no arguments if the request
'"request_uri": "$request_uri", ' # full path and arguments if the request
'"args": "$args", ' # args
'"status": "$status", ' # response status code
'"body_bytes_sent": "$body_bytes_sent", ' # the number of body bytes exclude headers sent to a client
'"bytes_sent": "$bytes_sent", ' # the number of bytes sent to a client
'"http_referer": "$http_referer", ' # HTTP referer
'"http_user_agent": "$http_user_agent", ' # user agent
'"http_x_forwarded_for": "$http_x_forwarded_for", ' # http_x_forwarded_for
'"http_host": "$http_host", ' # the request Host: header
'"server_name": "$server_name", ' # the name of the vhost serving the request
'"request_time": "$request_time", ' # request processing time in seconds with msec resolution
'"upstream": "$upstream_addr", ' # upstream backend server for proxied requests
'"upstream_connect_time": "$upstream_connect_time", ' # upstream handshake time incl. TLS
'"upstream_header_time": "$upstream_header_time", ' # time spent receiving upstream headers
'"upstream_response_time": "$upstream_response_time", ' # time spend receiving upstream body
'"upstream_response_length": "$upstream_response_length", ' # upstream response length
'"upstream_cache_status": "$upstream_cache_status", ' # cache HIT/MISS where applicable
'"ssl_protocol": "$ssl_protocol", ' # TLS protocol
'"ssl_cipher": "$ssl_cipher", ' # TLS cipher
'"scheme": "$scheme", ' # http or https
'"request_method": "$request_method", ' # request method
'"server_protocol": "$server_protocol", ' # request protocol, like HTTP/1.1 or HTTP/2.0
'"pipe": "$pipe", ' # "p" if request was pipelined, "." otherwise
'"gzip_ratio": "$gzip_ratio", '
'"http_cf_ray": "$http_cf_ray",'
'"geoip_country_code": "$geoip2_data_country_code"'
'}';
更改虚拟主机日志格式#
# 更改对应虚拟主机中的日志格式为上面定义的 json_analytics
vim vhost/www.conf
server {
listen 443 http2;
server_name www.treesir.pub;
access_log /data/wwwlogs/nps_www_access_nginx.log json_analytics;
...
}
查看效果,已变成我们所期望的格式:

处理真实 IP 获取问题#
注意:这里日志所返回的 geoip_country_code 也有可能不是你所期待的效果。比如你的站点位于 CDN 或者代理服务器的后端时,会导致 remote_addr 地址不是真正客户端的真实 IP,影响到最终结果。
解决方案:
- 使用 Nginx 自带的
set_real_ip_from - 使用模块提供的 geoip2_proxy 相关参数
推荐使用 set_real_ip_from 方式,同时可以让日志中 remote_addr 也获取到真实的 IP,使日志便于后续统计和分析:
vim /etc/nginx/nginx.conf # 更改主配置文件,在 http 段加入以下内容
# 获取 CDN 后真实 IP
set_real_ip_from 0.0.0.0/0;
real_ip_header X-Forwarded-For;
配置 Loki#
由于不想在自己的 HomeLab 中部署 Loki(主要是部署单实例的 Loki 使用体验不好,部署微服务架构又太重了),我们这里基于 Grafana Cloud 实现 Loki 和 Dashboard 的展示。这里省略创建账号等操作,登录入口地址如下,有 Google 账号的可以直接第三方登录:
什么是 Grafana Cloud#
Grafana Cloud 是 Grafana Labs 提供的一种托管服务,它提供了 Grafana、Prometheus 和 Loki 的托管版本。这意味着你可以使用这些强大的开源监控和可视化工具,而无需自己管理和维护底层的基础设施。
Grafana Cloud 的主要特性:
托管的 Grafana:可以使用最新版本的 Grafana,无需自己进行安装和升级。
托管的 Prometheus 和 Alertmanager:可以使用 Prometheus 和 Alertmanager 来收集和管理指标数据,无需自己进行安装和配置。
托管的 Loki:可以使用 Loki 来收集和查询日志数据,无需自己进行安装和配置。
托管的 Grafana Tempo:Grafana Tempo 是一个高度可扩展的、易于操作的分布式追踪后端。可以使用它来存储和查询追踪数据。
集成的警报和通知:可以使用 Grafana Cloud 的警报和通知功能,及时了解系统状态。
安全和可靠:Grafana Cloud 提供了数据加密、备份和高可用性等安全和可靠性特性。
部署 Promtail 日志代理#
Promtail 是 Loki 的日志代理,通过将主机中的日志收集起来,POST 到 Loki 中,实现日志的统一存储。下面我们使用 Docker Compose 来部署 Promtail。
安装 Docker Compose#
如果已安装请跳过此步骤:
curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose \
&& chmod +x /usr/local/bin/docker-compose \
&& ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose \
&& docker-compose --version
初始化 Promtail 部署#
注意:
/data/wwwlogs为你要收集日志的目录,请按实际情况更改
mkdir -p /data/docker-compose/loki-promtail/
cd /data/docker-compose/loki-promtail/
# 创建 docker-compose.yaml 文件
cat > docker-compose.yaml << EOF
version: "3"
networks:
loki:
services:
promtail:
image: grafana/promtail:2.7.4
volumes:
- /data/wwwlogs:/data/wwwlogs:ro
- ./config/config.yml:/etc/promtail/config.yml:ro
- /etc/localtime:/etc/localtime
command: -config.file=/etc/promtail/config.yml
EOF
创建 Promtail 配置文件#
创建 config/config.yml 配置文件:
注意:更改
USER_ID和TOKEN为你在 Grafana Cloud 页面生成的实际值,登录 Cloud 后找到 Loki 相关配置
mkdir -p config
cat > config/config.yml << EOF
server:
http_listen_port: 0
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: https://\${USER_ID}:\${TOKEN}@logs-prod-021.grafana.net/loki/api/v1/push
scrape_configs:
- job_name: system
pipeline_stages:
- replace:
expression: '(?:[0-9]{1,3}\.){3}([0-9]{1,3})'
replace: '***'
static_configs:
- targets:
- www.treesir.pub
labels:
job: nginx_access_log
host: ali-vps
agent: promtail
__path__: /data/wwwlogs/nps_www_access_nginx.log
EOF

启动 Promtail 日志收集#
docker-compose up -d

配置 Dashboard#
回到 Grafana Cloud 主页,点击进入 Grafana。

加载 Dashboard#
按照以下步骤导入 Dashboard:




调整变量和 Dashboard#
导入时会有一些报错,不用担心,我们调整一下变量即可,这是缺少变量导致的。



在 datasource 这里添加过滤 .*-logs,然后点击 Apply。为了防止刷新页面失效,记得点击 Save Dashboard。

选择 Dashboard 参数,就可以看到对应的数值了:

可以看到 Top Countries 这里有乱码,点击编辑,右边选项栏向下滑动,找到 Mapping 把问号删除即可,或者改成你想要的映射内容。


更改后不要忘记点击 Save:

添加 PV & UV 指标#
该图表,默认没有提供 PV & UV 指标,Loki 提供了一套与 Prometheus 类似的查询语法,叫 LogQL , 我们可以通过此查询语法,通过自定义 Visualization ,得到我们想要的内容。
获取 24H PV 指标, logQL 示例
由于我使用了 Blackbox Exporter 监控探针,避免影响数据的真实性,我这里把这部分请求添加了过滤
sum(count_over_time( {job="$job"} | json | __error__="" and remote_addr != "" and http_user_agent !~ "^Blackbox.*" [24h] ))新建图形


输入 logQL 测试运行,可以看到已经有结果了,由于我们这里且需要基于
现在时间往前推 24h 的 PV 统计,但可以看到下图,它自动查询到了 1473 份数据,并按照这个数据绘制了区间水平线,这其实有点没有必要,我们进行优化一下。
优化查询参数,更改最大查询数据为
1, 时间区间选择15s, 同时隐藏时间信息。这样就得到我们预期的结果了。
获取 24H UV 指标, logQL 示例
配置方法与 上面的 PV 配置一致,如果你想要好看的样式,可以基于现有 Dashbaord 的图表进行参考配置,这部分就由自己的自由发挥,此篇文档不做这里介绍。
sum(sum by (remote_addr) ( sum by (remote_addr, geoip_country_code) ( count_over_time( {job="$job"} | json | __error__="" and remote_addr != "" and http_user_agent !~ "^Blackbox.*" [24h] ) ) ^ 0 ))统计区间PV 增长情况, logQL 示例如下这里的区间我们选择使用
$__interval内置变量,可以在使用时很好的和主页上的区间选择器,进行联动查询。
sum by (remote_addr) ((count_over_time( {job="$job"} | json | __error__="" and remote_addr != "" and http_user_agent !~ "^Blackbox.*" [$__interval] )))这次是用到的区间查询,配置方法与上面的两个不一样了,再次点击 New Visualization,选择
Time series类型
优化显示,现在看这个图,显的有的臃肿,我们优化一下。Legend 这里我们输入
{{remote_addr}}
左边找到
Legend,我们把它的 可见效关掉。现在就看起来舒服多了
最终效果如下。已经与我最初所展示的效果接近。美化的工作交给你自己,如果实在不行,那你参考我这个导出的 Json 文件吧。

这次实践的结果#
Grafana Cloud 的查询体验符合个人网站的需求;自建 Loki 的微服务模式维护成本更高,暂时没有继续投入。当前面板已经能展示 PV/UV、地理分布和访问趋势,主要成本是配置查询和处理日志格式。
1. UV 区间增长统计问题#
统计 UV 的区间增长时,会得到唯一的 1,如果与 PV 同时只有一个访客时,会出现重叠现象:
sum(sum by (remote_addr) (
sum by (remote_addr, geoip_country_code) (
count_over_time(
{job="$job"}
| json
| __error__="" and remote_addr != "" and http_user_agent !~ "^Blackbox.*"
[24h]
)
) ^ 0
))

2. GeoIP 城市信息获取问题#
加载 GeoLite2-City.mmdb 库时,无法获取正确的城市名称(目前暂时用不到,后续可能会需要)。
3. Promtail 时区问题#
Promtail 打印日志时存在时区问题,目前只能通过修改源码并重新编译来解决。
后续#
后面需要继续处理 UV 区间统计、城市级 GeoIP 信息和 Promtail 时区。告警规则和 LogQL 查询也会随访问量变化再调整。
