在网站运营过程中,访问数据分析是了解用户行为和优化网站性能的重要手段。本文将分享如何使用 Grafana Loki 构建一个轻量级且功能强大的网站访问统计分析系统。
项目背景#
现有方案的局限性#
在寻找网站统计解决方案的过程中,传统的统计工具存在一些局限性:
- Google Search Console:主要关注搜索引擎相关数据,对整体访问统计支持有限
- CDN 厂商统计:功能相对简单,无法提供基于 IP 的精确 PV(页面浏览量)和 UV(独立访客)统计
- 第三方统计服务:可能存在数据隐私和依赖性问题
解决方案选择#
经过调研,我选择了基于 Grafana Loki 的解决方案,主要原因包括:
- 轻量化部署:可以使用 Grafana Cloud 免费版本,无需自建基础设施
- 强大的日志分析能力:Loki 专为日志数据设计,查询性能优秀
- 丰富的可视化:基于现成的 Nginx Dashboard 快速构建
- 成本控制:免费额度足够个人网站使用
最终效果展示#
经过配置和优化,最终实现的监控面板效果如下:

图:基于 Loki 的网站访问统计面板,包含 PV/UV、地理分布、访问趋势等关键指标
网站技术架构#
当前技术栈#
在开始配置监控之前,先介绍网站的技术架构,这有助于理解后续的配置过程:
静态网站生成#
- 框架:Hugo - 高性能的静态网站生成器
- 优势:构建速度快,生成的静态文件便于部署和缓存
容器化部署#
- 容器化:使用
Docker + Nginx将静态文件打包成镜像 - 部署方式:在云主机上运行 Docker 容器
- 自动更新:使用 Watchtower 实现镜像自动更新
反向代理层#
- 代理服务:OpenResty - 基于 Nginx 的高性能 Web 平台
- 功能特性:
- 端口复用:通过反向代理实现 443 端口的多服务共享
- 安全防护:集成 WAF 规则 提供 Web 应用防火墙功能
- 性能优化:提供缓存、压缩等性能优化功能
CI/CD 流程#
- 构建:基于 Pipeline 自动构建和推送镜像到 Docker Hub
- 部署:Watchtower 监控镜像更新并自动重新部署
这种架构的优势在于:
- 简单可靠:避免了 Kubernetes 等复杂编排工具的维护成本
- 成本效益:适合中小型网站的流量规模
- 易于维护:自动化程度高,减少人工干预
Loki 技术介绍#
什么是 Loki#
Loki 是由 Grafana Labs 开发的现代化日志聚合系统,专为云原生环境设计。它采用了与传统日志系统不同的设计理念,重点关注成本效益和查询性能。
核心特性#
1. 高效的索引策略#
- 标签索引:仅对日志流的标签创建索引,而不是日志内容本身
- 成本优势:大幅降低存储成本和索引维护开销
- 查询效率:通过标签快速定位相关日志流
2. 与 Grafana 生态集成#
- 原生支持:在 Grafana 中直接查询和可视化 Loki 数据
- 统一界面:将日志、指标和链路追踪数据整合在同一个界面中
- 告警集成:支持基于日志数据的告警规则
3. 多租户架构#
- 数据隔离:每个租户拥有独立的日志数据空间
- 权限控制:细粒度的访问控制和数据安全
- 资源共享:多租户共享基础设施,提高资源利用率
4. 水平扩展能力#
- 微服务架构:各组件可独立扩展
- 云原生设计:支持 Kubernetes 等容器编排平台
- 弹性伸缩:根据负载自动调整资源
5. PromQL 兼容的查询语言#
- LogQL:与 Prometheus 的 PromQL 语法相似
- 学习成本低:熟悉 Prometheus 的用户可以快速上手
- 强大功能:支持复杂的日志查询、过滤和聚合操作
Loki 架构组件#
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 的可玩性确实很高,免费的 Grafana Cloud 体验非常好。Grafana Cloud 的查询性能表现出色,我尝试自建 Loki 并使用微服务模式进行部署,但始终无法达到 Cloud 上的使用体验。
总体来说,使用 Loki 统计博客日志的整体体验很不错,通过本文的配置,我们成功实现了:
项目成果#
- 完整的日志收集链路:从 OpenResty 日志格式配置到 Promtail 收集,再到 Loki 存储
- 丰富的可视化面板:包含 PV/UV 统计、地理分布、访问趋势等关键指标
- 成本控制:基于 Grafana Cloud 免费版本,无需自建基础设施
- 实时监控:支持实时的访问数据分析和告警
遗留问题#
经过这次实践,还有几个问题有待解决:
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 打印日志时存在时区问题,目前只能通过修改源码并重新编译来解决。
后续优化方向#
- 完善 LogQL 查询:优化 UV 统计的查询语句,解决重叠问题
- 扩展地理信息:研究城市级别的地理位置统计
- 告警配置:基于访问异常情况配置告警规则
- 性能优化:优化日志格式和查询性能
这套基于 Loki 的网站访问统计系统为个人网站提供了企业级的监控能力,同时保持了轻量化和成本效益的优势。
