从零搭建轻量级可观测平台:Grafana + Loki + Prometheus + Alloy 实战与踩坑记录
在个人项目和中小型服务部署中,日志和监控往往是比较容易被忽略的一部分。
项目刚上线时,我们可能只需要通过 docker logs 查看异常,或者登录服务器使用 top、free、df 等命令检查资源。但是随着服务数量增加,这种方式的问题就会逐渐显现:
- 服务异常时,需要反复登录服务器翻找日志。
- Docker 容器日志分散,缺少统一查询入口。
- 无法直观了解 CPU、内存、磁盘和网络使用趋势。
- 数据库连接数、事务、慢 SQL 等缺少监控。
- 反向代理层的访问量、请求耗时、HTTP 错误无法集中分析。
- 无法把应用异常与服务器、数据库、网络指标关联起来。
为了解决这些问题,我利用现有的阿里云 ECS 和极空间 NAS,搭建了一套轻量级的可观测平台。
核心技术栈为:
Grafana + Loki + Prometheus + Grafana Alloy
整套方案尽可能复用已有服务器资源,将数据存储和可视化部署在 NAS 上,而阿里云 ECS 只负责采集和传输。
本文介绍实际搭建流程、关键配置、Grafana Dashboard,以及过程中遇到的 DNS、权限、指标标签和 LogQL 等问题。
一、整体架构与技术选型
1.1 为什么选择这套方案
常见日志和监控方案包括 ELK、EFK、Loki、Prometheus、Grafana 等。
ELK 更适合复杂的日志全文检索和分析,但 Elasticsearch 的内存、存储和运维成本相对较高。
对于个人项目和中小型业务,我更关注以下几点:
- 资源消耗尽可能低。
- 能够同时采集日志和系统指标。
- 能够构建自定义 Dashboard。
- 支持 Docker、PostgreSQL、Linux 和反向代理监控。
- 日志和指标数据可以集中存储在 NAS。
- 云服务器尽可能不额外运行大型存储服务。
因此最终选择:
| 组件 | 作用 | 部署位置 |
|---|---|---|
| Grafana | Dashboard、日志查询、监控展示 | 极空间 NAS |
| Loki | 集中存储和查询日志 | 极空间 NAS |
| Prometheus | 存储时序监控指标 | 极空间 NAS |
| Grafana Alloy | 日志和指标采集、转发 | 阿里云 ECS |
| Tailscale | 打通云服务器与 NAS 内网 | ECS + 家庭网络 |
Grafana 是统一可视化入口;Loki 主要负责日志;Prometheus 负责指标;Alloy 负责采集和转发。
需要特别说明:Alloy 本身不是日志数据库,也不是指标数据库,它是采集和处理组件。
1.2 整体数据流
阿里云 ECS
┌──────────────────────────────────────────────┐
│ │
│ Node.js API 容器 ── Docker Logs ──┐ │
│ PostgreSQL 容器 ── PG Exporter ───┤ │
│ 其他 Docker 容器 ─ Docker Logs ───┤ │
│ Linux 宿主机 ───── Unix Exporter ─┤ │
│ Docker 容器资源 ── cAdvisor ──────┤ │
│ Caddy ── /var/log/caddy/access.log ┤ │
│ ▼ │
│ Grafana Alloy │
└────────────────────────────────────┬─────────┘
│
Tailscale 内网
│
▼
极空间 NAS(Docker)
┌─────────────────────────┐
│ Loki 日志存储 │
│ Prometheus 指标存储 │
│ Grafana Dashboard │
└─────────────────────────┘
这里采用两条不同的数据管道:
日志管道
Docker / Caddy → Alloy → Loki → Grafana
指标管道
Linux / Docker / PostgreSQL
↓
Alloy
↓
Prometheus Remote Write
↓
Prometheus
↓
Grafana
这样可以在 Grafana 中统一查看服务器、容器、数据库及业务访问情况。
二、在极空间 NAS 部署监控服务
2.1 Docker Compose
在 NAS 中创建一个目录,例如:
monitoring/
├── docker-compose.yml
├── prometheus/
│ └── prometheus.yml
├── loki/
│ └── config.yml
└── data/
├── prometheus/
├── loki/
└── grafana/
下面是一份参考 Compose 配置,版本采用本次环境中使用的版本:
services:
loki:
image: grafana/loki:3.7.0
container_name: loki
restart: unless-stopped
ports:
- "3100:3100"
volumes:
- ./loki/config.yml:/etc/loki/config.yml:ro
- ./data/loki:/loki
command:
- -config.file=/etc/loki/config.yml
prometheus:
image: prom/prometheus:v3.15.0
container_name: prometheus
restart: unless-stopped
ports:
- "9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./data/prometheus:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --web.enable-remote-write-receiver
grafana:
image: grafana/grafana:12.2.0
container_name: grafana
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./data/grafana:/var/lib/grafana
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
说明:
- Loki 使用本地文件系统存储日志。
- Prometheus 保存最近 15 天指标。
- Grafana 用于查询日志和建立 Dashboard。
- 各个数据目录持久化到 NAS。
- Grafana 管理员密码通过
.env提供,不写死在 Compose 中。
实际部署时还需要提供与所用 Loki 版本兼容的完整 config.yml,同时确保数据目录权限正确。上面的 Compose 主要展示服务之间的关系。
2.2 Prometheus 配置
创建 prometheus/prometheus.yml:
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: prometheus
static_configs:
- targets:
- prometheus:9090
- job_name: loki
static_configs:
- targets:
- loki:3100
- job_name: grafana
metrics_path: /metrics
static_configs:
- targets:
- grafana:3000
其中最关键的启动参数是:
--web.enable-remote-write-receiver
默认情况下,Prometheus 主要通过主动抓取目标采集指标。
而本次架构中,阿里云 ECS 主动将指标推送到 NAS,因此需要启用 Remote Write Receiver:
http://NAS_IP:9090/api/v1/write
需要注意,Prometheus 不是专门面向任意规模推送场景的通用数据接收网关。这种配置适合当前小规模、私有网络的部署方式。
在生产环境中,建议通过私有网络、防火墙或反向代理限制接收端访问。
2.3 启动服务
docker compose up -d
docker compose ps
检查:
http://NAS_IP:3000 Grafana
http://NAS_IP:9090 Prometheus
http://NAS_IP:3100 Loki
其中 Loki 的 /ready 可以用于检测是否准备就绪:
curl http://NAS_IP:3100/ready
Prometheus 可通过 /api/v1/query 查询指标。
最后在 Grafana 中添加两个 Data source:
- Prometheus:
http://prometheus:9090 - Loki:
http://loki:3100
如果 Grafana 和其他服务属于同一个 Docker 网络,可使用容器服务名访问。
三、在阿里云 ECS 部署 Grafana Alloy
3.1 Alloy 的作用
Alloy 可以统一采集:
- Linux 宿主机 CPU、内存、磁盘、网络指标。
- Docker 容器 CPU、内存、网络和文件系统指标。
- Docker stdout/stderr 日志。
- PostgreSQL 统计指标。
- 宿主机上的 Caddy 文件日志。
相比在每台服务器分别运行多个 Exporter 和日志 Agent,Alloy 可以集中管理采集配置。
创建目录:
mkdir -p ~/workspace/docker-compose/alloy
cd ~/workspace/docker-compose/alloy
目录结构:
alloy/
├── docker-compose.yml
├── config.alloy
└── .env
3.2 Docker Compose 配置
示例:
services:
alloy:
image: grafana/alloy:latest
container_name: alloy
restart: unless-stopped
privileged: true
pid: host
environment:
PG_MONITOR_DSN: ${PG_MONITOR_DSN}
volumes:
- ./config.alloy:/etc/alloy/config.alloy:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro,rslave
- /sys:/sys:ro
- /var/lib/docker:/var/lib/docker:ro
- /dev/disk:/dev/disk:ro
- /var/log/caddy:/host/var/log/caddy:ro
command:
- run
- /etc/alloy/config.alloy
networks:
- default
- backend-network
networks:
backend-network:
external: true
name: backend-network
这里有几个重要注意事项:
- Docker Socket 可以暴露较高的宿主机管理权限,只读挂载路径并不等于只读 Docker API。
privileged: true是为了兼容当前 cAdvisor 采集方案,并不是所有 Alloy 部署都需要。- 生产环境应尽量减少挂载和特权范围。
- PostgreSQL 容器和 Alloy 容器必须有可互通的网络。
- 不建议长期使用
latest,正式部署应固定经过验证的 Alloy 版本。
本次 PostgreSQL 使用 Docker 自定义网络 backend-network,因此 Alloy 需要显式加入该网络。
四、配置 Docker 日志和 Linux、Docker 指标
4.1 配置日志写入 Loki
在 config.alloy 中定义:
loki.write "nas" {
endpoint {
url = "http://<NAS_PRIVATE_IP>:3100/loki/api/v1/push"
}
}
其中 <NAS_PRIVATE_IP> 是本次环境中 NAS 的局域网地址,由 Tailscale 子网路由实现云服务器访问。
4.2 自动发现 Docker 容器
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
增加容器名称标签:
discovery.relabel "docker_logs" {
targets = discovery.docker.containers.targets
rule {
source_labels = ["__meta_docker_container_name"]
regex = "/(.*)"
target_label = "container_name"
}
rule {
target_label = "host"
replacement = "cloud-server-01"
}
rule {
target_label = "env"
replacement = "production"
}
}
采集容器日志:
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.relabel.docker_logs.output
forward_to = [loki.write.nas.receiver]
}
实际部署中,还可以在进入 Loki 前增加 loki.process,实现日志过滤、标签提取和脱敏。
4.3 Linux 宿主机指标
通过 Alloy 内置 Unix Exporter 采集:
prometheus.exporter.unix "ecs" {
rootfs_path = "/host/root"
procfs_path = "/host/proc"
sysfs_path = "/host/sys"
}
定义 Prometheus Remote Write:
prometheus.remote_write "nas" {
external_labels = {
host = "cloud-server-01",
}
endpoint {
url = "http://<NAS_PRIVATE_IP>:9090/api/v1/write"
}
}
采集主机指标:
prometheus.scrape "ecs" {
targets = prometheus.exporter.unix.ecs.targets
job_name = "integrations/unix"
scrape_interval = "15s"
forward_to = [
prometheus.remote_write.nas.receiver,
]
}
Grafana 可以使用 Node Exporter 类 Dashboard 展示主机状态。
4.4 Docker 容器指标
使用 Alloy 内置 cAdvisor:
prometheus.exporter.cadvisor "docker" {
docker_host = "unix:///var/run/docker.sock"
rootfs_path = "/host/root"
}
采集:
prometheus.scrape "docker" {
targets = prometheus.exporter.cadvisor.docker.targets
job_name = "integrations/cadvisor"
scrape_interval = "15s"
forward_to = [
prometheus.remote_write.nas.receiver,
]
}
实际采集时,cAdvisor 可能还需要访问宿主机的 /sys、Docker 数据目录和设备信息,这也是前面 Compose 中增加相关挂载的原因。
本次配置的指标标签为:
job="integrations/cadvisor"
host="cloud-server-01"
这里曾经遇到一个问题:Dashboard 查询使用了错误的 job,导致所有面板显示 No data。
因此,配置 Dashboard 前应先在 Prometheus 中确认实际标签:
container_memory_working_set_bytes
再查看返回结果中的 job、instance、name、host 等字段。
五、构建 Linux 和 Docker Dashboard
Linux 宿主机可以使用 Grafana Dashboard 1860(Node Exporter Full)作为起点,再按自己的指标标签进行调整。
Docker Dashboard 则可以自行构建,主要包括:
- 当前容器数量。
- Docker 总 CPU 使用率。
- Docker 总内存占用。
- CPU 使用率 Top 10。
- 内存占用 Top 10。
- 容器网络收发趋势。
- 容器磁盘读写趋势。
- 容器筛选变量。
5.1 容器总内存
sum(
container_memory_working_set_bytes{
job="integrations/cadvisor",
host="cloud-server-01",
name!=""
}
)
Grafana Unit 选择 bytes (IEC)。
5.2 容器 CPU Top 10
topk(
10,
sum by (name) (
rate(
container_cpu_usage_seconds_total{
job="integrations/cadvisor",
host="cloud-server-01",
name!=""
}[5m]
)
) * 100
)
这里乘以 100 的结果是相对于单个 CPU 核心的百分比,多个核心同时使用时可以超过 100%,不等于宿主机整体 CPU 使用百分比。
5.3 内存 Top 10
topk(
10,
sum by (name) (
container_memory_working_set_bytes{
job="integrations/cadvisor",
host="cloud-server-01",
name!=""
}
)
)
5.4 容器筛选变量
Grafana 中创建 container Query 变量:
Data source: Prometheus
Query type: Label values
Metric: container_memory_working_set_bytes
Label: name
Filter: job=integrations/cadvisor
开启 Multi-value、Include All,并将 All 值设置为 .*。
面板中通过:
name=~"${container:regex}"
动态筛选容器。
一个很实用的经验是:Grafana 变量显示正常,不代表指标一定存在匹配结果。最终要看变量展开后的 PromQL。
六、接入 PostgreSQL 监控
6.1 创建只读监控账号
本次数据库为 PostgreSQL 15.19,运行在 Docker 容器中。
创建监控用户:
CREATE ROLE monitor_user
WITH LOGIN PASSWORD '请使用独立强密码';
GRANT pg_monitor TO monitor_user;
这里曾经踩过一个坑:尝试创建以 pg_ 开头的角色名,PostgreSQL 返回错误。
原因是 pg_ 前缀保留给系统角色使用。
因此采用普通角色名 monitor_user。
6.2 配置 Alloy PostgreSQL Exporter
在 .env 中配置连接串:
PG_MONITOR_DSN=postgresql://monitor_user:YOUR_PASSWORD@postgres:5432/postgres?sslmode=disable
如果密码包含 @、#、: 等特殊字符,需要按 URI 规则编码。
sslmode=disable 只适用于受控的 Docker 内网连接,不能直接照搬到不可信网络。
Alloy 配置:
prometheus.exporter.postgres "ecs_pg" {
data_source_names = [
sys.env("PG_MONITOR_DSN"),
]
}
prometheus.scrape "ecs_pg" {
targets = prometheus.exporter.postgres.ecs_pg.targets
job_name = "integrations/postgres"
scrape_interval = "15s"
forward_to = [
prometheus.remote_write.nas.receiver,
]
}
验证:
pg_up{job="integrations/postgres"}
正常情况下应返回:
1
6.3 实际踩坑:PostgreSQL DNS 无法解析
启动后出现:
dial tcp: lookup postgres on 127.0.0.11:53:
server misbehaving
最初怀疑是数据库账号或 PostgreSQL 访问控制问题,但检查发现根本原因是 Docker 网络隔离。
执行:
docker inspect postgres \
--format '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}'
返回:
backend-network
而 Alloy:
docker inspect alloy \
--format '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}'
返回:
alloy_default
这说明两个容器不在同一个 Docker 用户自定义网络中。
临时解决:
docker network connect backend-network alloy
首次执行成功,没有输出。
再次执行:
endpoint with name alloy already exists
这说明容器已经加入网络,不需要重复操作。
但还有一个问题:docker network connect 是针对当前容器的操作。
如果执行:
docker compose up -d --force-recreate alloy
容器被重新创建,手动连接的网络可能丢失。
因此最终需要在 Compose 中永久声明外部网络:
services:
alloy:
networks:
- default
- backend-network
networks:
backend-network:
external: true
name: backend-network
这是本次最重要的 Docker 网络踩坑之一。
6.4 为什么 pg_up=0,连接数却显示 11?
实际搭建 Dashboard 时曾出现:
pg_up{job="integrations/postgres"}
返回 0,而:
sum(
pg_stat_database_numbackends{
job="integrations/postgres"
}
)
返回 11。
这两个指标含义不同:
pg_up:Exporter 最近一次数据库采集是否成功。pg_stat_database_numbackends:数据库连接数统计指标。
不能因为能查到连接数,就判断当前数据库监控一定正常。
排查时应同时查看:
up{job="integrations/postgres"}
以及:
pg_up{job="integrations/postgres"}
前者用于检查 Prometheus 对采集目标的抓取是否成功,后者用于判断 PostgreSQL Exporter 是否成功连接数据库。
还可以查询:
time() - timestamp(
pg_stat_database_numbackends{
job="integrations/postgres"
}
)
判断指标样本是否足够新。
排查时一定要区分:采集器可访问、数据库可连接、统计数据仍存在,是三个不同的问题。
6.5 PostgreSQL Dashboard 指标
当前主要监控:
| 指标 | Prometheus 指标 |
|---|---|
| 数据库在线状态 | pg_up |
| 当前连接数 | pg_stat_database_numbackends |
| 事务提交 | pg_stat_database_xact_commit |
| 事务回滚 | pg_stat_database_xact_rollback |
| 数据库大小 | pg_database_size_bytes |
| 插入行数 | pg_stat_database_tup_inserted |
| 更新行数 | pg_stat_database_tup_updated |
| 删除行数 | pg_stat_database_tup_deleted |
| 死锁 | pg_stat_database_deadlocks |
| 缓存命中 | pg_stat_database_blks_hit |
例如每秒提交事务:
sum(
rate(
pg_stat_database_xact_commit{
job="integrations/postgres"
}[5m]
)
)
七、扩展 PostgreSQL SQL 性能监控
仅有连接数、事务、磁盘和缓存指标,还无法定位具体的高耗时 SQL。
因此继续考虑接入:
- 慢 SQL Top 10。
- SQL 总耗时 Top 10。
- SQL 执行次数 Top 10。
- 当前正在执行的长 SQL。
- 当前锁等待。
- 长事务。
7.1 pg_stat_statements
检查数据库:
SHOW server_version;
SHOW shared_preload_libraries;
SHOW compute_query_id;
本次环境中得到:
PostgreSQL 15.19
shared_preload_libraries = 空
compute_query_id = auto
同时:
SELECT extname
FROM pg_extension
WHERE extname = 'pg_stat_statements';
未查到记录。
说明当前数据库尚未安装该扩展,并且尚未进行预加载。
先确认扩展可用:
SELECT name, default_version
FROM pg_available_extensions
WHERE name = 'pg_stat_statements';
然后配置:
ALTER SYSTEM SET
shared_preload_libraries = 'pg_stat_statements';
这里必须注意,如果已经预加载其他扩展,需要保留原有扩展名称,不能直接覆盖。
该设置需要 PostgreSQL 重启才能生效,因此应选择维护窗口操作。
重启后在业务库中执行:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
查询平均执行时间最高的 SQL:
SELECT
queryid,
calls,
ROUND(mean_exec_time::numeric, 2) AS avg_ms,
ROUND(max_exec_time::numeric, 2) AS max_ms,
ROUND(total_exec_time::numeric, 2) AS total_ms,
LEFT(query, 300) AS sql_text
FROM pg_stat_statements
WHERE calls > 0
ORDER BY mean_exec_time DESC
LIMIT 10;
需要注意,pg_stat_statements 提供的是累计 SQL 统计,而不是自动按 Grafana 时间范围筛选的历史慢查询记录。
本次对话已经确认扩展最初未启用,以上属于后续实施方案,不代表慢 SQL 采集已经全部完成。
7.2 查看当前长查询
使用 PostgreSQL 系统视图:
SELECT
pid,
datname,
usename,
application_name,
ROUND(
EXTRACT(
EPOCH FROM (clock_timestamp() - query_start)
)::numeric,
2
) AS running_seconds,
wait_event_type,
wait_event,
LEFT(query, 500) AS sql_text
FROM pg_stat_activity
WHERE state = 'active'
AND query_start <
clock_timestamp() - INTERVAL '5 seconds'
AND pid <> pg_backend_pid()
ORDER BY query_start;
该查询适合放到 Grafana PostgreSQL 数据源的 Table 面板中。
区别在于:
- Prometheus 适合监控 SQL 统计数字和趋势。
- PostgreSQL 数据源适合查询具体 SQL 文本。
- Loki 适合保存数据库错误日志和慢查询日志。
不建议直接把所有完整 SQL 文本作为 Prometheus 标签,以免造成高基数时序数据和敏感信息泄露。
八、接入宿主机 Caddy HTTP 访问日志
这次还有一个比较特殊的部署情况:
Caddy 直接运行在阿里云 ECS 宿主机上,不在 Docker 容器中。
因此 Alloy 原有的:
loki.source.docker
只能采集 Docker 容器日志,不能自动读取 Caddy 的访问日志。
需要额外增加文件采集。
8.1 启用 Caddy 访问日志
编辑:
sudo nano /etc/caddy/Caddyfile
在站点配置中加入:
api.example.com {
log {
output file /var/log/caddy/access.log {
roll_size 50MiB
roll_keep 7
roll_keep_for 168h
}
format json
}
reverse_proxy 127.0.0.1:3000
}
这是示例站点,实际使用时应保留已有的反向代理、TLS、路由和其他业务配置。
这里使用 JSON 格式,是为了方便 Loki 后续提取字段。
例如实际访问日志包含:
{
"level": "info",
"logger": "http.log.access.log0",
"msg": "handled request",
"request": {
"client_ip": "192.0.2.10",
"proto": "HTTP/3.0",
"method": "GET",
"host": "api.example.com",
"uri": "/api/orders?page=1"
},
"duration": 0.0216,
"status": 200,
"size": 8923
}
能够提取:
- HTTP 请求方法。
- 域名、路径和查询参数。
- 客户端 IP。
- HTTP 协议版本。
- HTTP 响应状态。
- 请求耗时。
- 响应大小。
8.2 踩坑:unrecognized directive: output
第一次修改 Caddyfile 后出现:
Error: adapting config using caddyfile:
unrecognized directive: output
原因是 output 没有正确放在 log {} 配置块中。
正确结构:
example.com {
log {
output file /var/log/caddy/access.log
format json
}
}
错误结构:
example.com {
log
output file /var/log/caddy/access.log
}
Caddyfile 对大括号层级有明确要求。
可以执行:
sudo nl -ba /etc/caddy/Caddyfile |
sed -n '35,65p'
检查配置行号和括号位置。
修改后先验证:
sudo caddy validate --config /etc/caddy/Caddyfile
8.3 踩坑:Caddy reload 一直卡住
当时执行:
sudo systemctl reload caddy
长时间没有结束。
查看 systemd:
sudo systemctl status caddy
发现:
Active: reloading (reload)
并且这个异常状态已经持续较长时间。
日志中的关键错误为:
opening log writer:
open /var/log/caddy/access.log:
permission denied
同时还有:
HTTP 400
Reload operation timed out
这说明两个问题同时存在:
- Caddy 运行账号没有访问日志目录的写入权限。
- systemd 的重载状态也存在异常。
修复权限时,需要先确定实际服务用户:
sudo systemctl show caddy -p User -p Group
如果使用 caddy 用户:
sudo install -d \
-o caddy \
-g caddy \
-m 0750 \
/var/log/caddy
如果日志文件已经存在:
sudo chown caddy:caddy /var/log/caddy/access.log
sudo chmod 640 /var/log/caddy/access.log
随后以服务用户校验配置:
sudo -u caddy caddy validate \
--config /etc/caddy/Caddyfile
还需要注意 systemd 可能通过 ProtectSystem、ReadWritePaths 或其他沙箱选项限制目录写入。
本次服务还存在:
/etc/systemd/system/caddy.service.d/override.conf
因此在彻底排查时,还应检查:
sudo systemctl cat caddy
不能仅凭 permission denied 就把所有问题归因于普通 Linux 文件权限。
对于仍在正常处理流量的服务,不建议在原因未确定时直接强制 restart,避免造成业务中断。
8.4 多个域名如何统一开启日志
Caddyfile 的站点 HTTP 访问日志通常在各个站点块内启用。
如果有很多域名,逐个重复配置比较麻烦。
可以使用 Snippet:
(access_log) {
log {
output file /var/log/caddy/access.log {
roll_size 50MiB
roll_keep 7
roll_keep_for 168h
}
format json
}
}
每个域名中引入:
api.example.com {
import access_log
reverse_proxy 127.0.0.1:3000
}
blog.example.com {
import access_log
reverse_proxy 127.0.0.1:3001
}
多个域名共用一个日志文件,后续通过 JSON 中的 request.host 区分。
要注意:
Caddy 全局 log 选项主要用于运行日志,不等于自动启用所有站点的 HTTP 访问日志。
另外,针对同一个日志文件修改轮转参数,部分参数需要完整重启才能生效,普通 reload 不一定立即应用。
九、使用 Alloy 采集 Caddy 日志
9.1 挂载宿主机日志目录
在 Alloy Compose 中增加:
volumes:
- /var/log/caddy:/host/var/log/caddy:ro
注意,这里只是示意新增项,要合并到原来的 volumes,不能覆盖全部挂载。
9.2 Alloy 文件采集配置
在 config.alloy 中添加:
loki.source.file "caddy_access" {
targets = [
{
__path__ = "/host/var/log/caddy/access.log",
job = "caddy",
host = "cloud-server-01",
service_name = "caddy",
log_type = "access",
},
]
tail_from_end = true
forward_to = [
loki.write.nas.receiver,
]
}
tail_from_end = true 的作用是:当没有已保存的读取位置时,从日志文件末尾开始读取,避免首次采集就把大量历史日志发送到 Loki。
长期运行时,建议为 Alloy 配置持久化 storage path,以保存文件读取偏移量,减少容器重建后重复采集的风险。
验证配置:
docker compose config --quiet
验证 Alloy:
docker compose run --rm --no-deps \
--entrypoint /bin/alloy alloy \
validate /etc/alloy/config.alloy
重新创建:
docker compose up -d --force-recreate alloy
这里必须特别注意此前的 PostgreSQL 网络问题:确认 backend-network 已经永久写进 Compose。
9.3 Grafana 验证日志
进入:
Grafana → Explore → Loki
执行:
{job="caddy"}
如果能看到 JSON 请求日志,说明采集成功。
格式化:
{job="caddy"}
| json
| __error__=""
| line_format "{{.request_method}} {{.request_host}}{{.request_uri}} | HTTP {{.status}} | {{.duration}}s | IP: {{.request_client_ip}}"
这里 | json 会把嵌套字段解析出来,例如:
request.method → request_method
request.host → request_host
request.uri → request_uri
request.client_ip → request_client_ip
这样就可以直接使用对应字段进行日志查询和统计。
十、制作 Caddy HTTP Dashboard
本次主要设计以下面板:
| 面板 | 可视化类型 |
|---|---|
| HTTP 请求总量 | Stat |
| 每秒请求数 RPS | Stat |
| HTTP 错误请求数 | Stat |
| P95 响应时间 | Stat |
| HTTP 请求趋势 | Time series |
| HTTP 状态码分布 | Pie chart |
| 域名访问量 Top 10 | Bar chart |
| 热门 URL Top 10 | Bar chart |
| 客户端 IP Top 10 | Bar chart |
| P95 响应时间趋势 | Time series |
| 实时 HTTP 错误日志 | Logs |
10.1 请求总量
sum(
count_over_time(
{job="caddy"}[$__range]
)
)
使用 Stat,查询类型设为 Instant。
10.2 请求速率 RPS
sum(
rate(
{job="caddy"}[1m]
)
)
展示最近一分钟的平均每秒请求数。
10.3 HTTP 状态码分布
sum by (status) (
count_over_time(
{job="caddy"}
| json
| __error__=""
[$__range]
)
)
使用 Pie chart,查询类型选择 Instant。
10.4 HTTP 5xx 请求趋势
sum(
count_over_time(
{job="caddy"}
| json
| __error__=""
| status >= 500
[1m]
)
)
使用 Time series。
10.5 域名访问排行
topk(
10,
sum by (request_host) (
count_over_time(
{job="caddy"}
| json
| __error__=""
[$__range]
)
)
)
10.6 客户端 IP Top 10
topk(
10,
sum by (request_client_ip) (
count_over_time(
{job="caddy"}
| json
| __error__=""
[$__range]
)
)
)
注意客户端 IP 不适合作为高基数的 Loki 索引标签。这里是在查询阶段提取 IP 并统计。
10.7 P95 请求耗时
quantile_over_time(
0.95,
{job="caddy"}
| json
| unwrap duration
| __error__=""
[5m]
) * 1000
Caddy 的 duration 单位是秒,乘以 1000 后得到毫秒。
如果查询匹配多条 Loki 日志流,quantile_over_time 会为每条流分别计算分位数,不能简单平均这些结果来代表全局 P95。
10.8 HTTP 错误实时日志
{job="caddy"}
| json
| __error__=""
| status >= 400
| line_format "{{.request_method}} {{.request_host}}{{.request_uri}} | HTTP {{.status}} | {{.duration}}s | IP: {{.request_client_ip}}"
面板类型选择 Logs,查询类型选择 Range。
Dashboard 自动刷新设置为 5~10 秒,即可定期查看最新的 4xx 和 5xx 请求。
需要注意:Caddy 的 HTTP 500 访问日志可能仍然使用 level="info",所以筛选 HTTP 错误时,应根据 status 判断,而不是只筛选 level="error"。
十一、Grafana 与 LogQL 踩坑总结
11.1 $__rate_interval 报错
曾经在 Loki 中使用:
sum(
rate({job="caddy"}[$__rate_interval])
)
出现:
parse error:
not a valid duration string: "$__rate_interval"
原因是 $__rate_interval 主要用于 Prometheus,并不能保证在 Loki 数据源中正确插值。
Loki 可使用:
sum(
rate({job="caddy"}[$__auto])
)
或者使用固定窗口:
sum(
rate({job="caddy"}[1m])
)
在 Grafana 的 Loki 指标查询中,$__auto 可以根据查询时间范围和步长自动选择窗口。
11.2 域名变量创建后没有选项
在 Caddy Dashboard 中,希望通过顶部变量筛选域名。
最初尝试使用 request_host 字段和以下统计查询:
sum by (request_host) (
count_over_time(
{job="caddy"} | json [1h]
)
)
但是 Grafana 变量的 Query type 选择为 Detected field values,又同时配置了针对统计序列的正则表达式,导致没有得到预期选项。
实际上,Detected field values 是从日志中提取字段值,不需要传入复杂的统计查询。
合适配置为:
Query type: Detected field values
Field: request_host
LogQL query: {job="caddy"}
Regex: 留空
随后可以在面板中写:
{job="caddy"}
| json
| __error__=""
| request_host=~"${domain:regex}"
前提是变量名称叫 domain。
还有一个容易搞混的地方:
request_host 最初是 JSON 中解析出来的字段,并不是采集时已经设置的 Loki 索引标签。
因此不能直接写成:
{job="caddy",request_host="api.example.com"}
除非已经在 Alloy 中将它提升为 Loki 标签。
对于域名数量有限的场景,可以考虑将域名作为低基数索引标签,方便 Dashboard 筛选。
但不要把完整 URL、请求 ID、用户 ID 或客户端 IP 随意设置成索引标签。
11.3 Grafana 显示 No data
在搭建过程中,No data 出现过多次。
遇到这种问题,建议按以下顺序排查:
- 数据源本身是否可用。
- 指标或日志是否真的已经采集。
- 查询的
job、host、container_name是否正确。 - Grafana 当前时间范围内是否存在数据。
- 变量展开结果是否与标签匹配。
- 面板的 Instant、Range 类型是否正确。
尤其当刚刚部署 Prometheus 或 Loki 时,系统中还没有足够长的历史数据。
此时直接选择 Last 24 hours,可能让部分面板看起来没有趋势。
建议初期使用 Last 15 minutes 或 Last 1 hour 进行验证。
11.4 Alloy 重建导致指标标签变化
Alloy 作为 Docker 容器运行,如果直接使用容器内部 hostname 作为 instance 标签,那么每次重建容器可能导致 instance 改变。
一些依赖固定 instance 变量的 Grafana Dashboard,可能因此显示 No data。
建议对宿主机指标使用稳定标签,例如:
host="cloud-server-01"
必要时通过 discovery.relabel 统一 instance,避免容器重建影响 Dashboard 查询。
11.5 Docker 日志过旧导致 Loki 拒绝
首次接入 Docker 时,容器可能已经运行很长时间。
Alloy 如果直接读取旧日志并发送给 Loki,可能出现:
timestamp too old
这是因为日志时间超出了 Loki 当前允许接收的时间范围。
解决方式不是简单删除历史日志,而是根据业务需求:
- 对首次采集设置合理的起始位置。
- 在 Alloy 中过滤超过指定时间的旧日志。
- 评估 Loki 的时间限制和保留策略。
本次曾使用 Alloy 日志处理组件过滤超过六天的旧记录,避免初次采集反复失败。
十二、资源、安全与后续优化
这套方案的优势在于利用已有的极空间 NAS 存储数据,让云服务器只承担较轻的采集和转发工作。
不过长期运行仍需要关注三个方面。
12.1 资源与容量
建议持续监控:
- Loki 日志存储占用。
- Prometheus TSDB 目录大小。
- Grafana 内存占用。
- Alloy CPU 和内存使用率。
- NAS 存储空间和可用内存。
Caddy 访问日志尤其容易增长,因为每一次 HTTP 请求都可能产生一条记录。
可以通过限制日志保留期限、过滤无价值请求、控制标签基数以及合理使用日志轮转来降低资源消耗。
还要区分 Caddy 日志轮转与 Loki 日志保留:它们属于两个独立的生命周期管理机制。
12.2 网络与数据安全
由于 Prometheus 和 Loki 部署在家庭 NAS 上,建议只允许 ECS 通过 Tailscale 等私有网络访问。
不应直接把 Prometheus Remote Write 接口、Loki 写入接口以及 PostgreSQL 5432 暴露到公网。
同时建议:
- PostgreSQL 使用独立只读监控用户。
- 减少 Alloy Docker Socket 权限。
- 保护 Grafana 管理员账号。
- 不在 Git 仓库提交数据库密码。
- 对 Caddy URL 查询参数和请求头进行脱敏。
- 避免在 Prometheus 标签中写入敏感内容。
- 按需限制监控数据的保留期限。
12.3 后续扩展方向
完成基础 Dashboard 后,可以继续增加:
告警
使用 Grafana Alerting 监控磁盘容量、CPU、内存、HTTP 5xx、PostgreSQL 连接失败等。
SQL 级性能分析
通过 pg_stat_statements、pg_stat_activity 和数据库日志,建立慢 SQL、锁等待、长事务等分析面板。
业务日志关联
为 Node.js 应用日志统一添加 requestId、trace ID、服务名和环境标签,使 Caddy 访问日志与 Node.js 业务日志更容易关联。
服务链路追踪
如果以后项目发展到多个服务相互调用,可以继续引入 OpenTelemetry 与 Tempo,实现日志、指标、Trace 的关联分析。
十三、总结
通过这次搭建,原本分散在 Docker、Linux、PostgreSQL 和 Caddy 中的数据,逐步接入了统一的 Grafana 平台。
整套架构最大的价值并不是某一张 Dashboard,而是建立了统一的可观测数据入口。
遇到服务异常时,可以先查看 Caddy 的 HTTP 错误和请求耗时,再检查 Node.js 业务日志、Docker 资源使用情况和 PostgreSQL 连接及事务状态,从而缩小排查范围。
这次实践中,几个尤其值得记住的经验是:
- 采集链路打通比 Dashboard 更重要。 没有可靠的数据来源,再漂亮的图表也没有意义。
- Docker 网络需要持久化配置。 手动
docker network connect能解决当前问题,但不代表容器重建后仍然有效。 - 日志权限和 systemd 状态需要分别排查。 Caddy 的
permission denied与长期卡在reloading状态可能同时存在。 - Grafana 变量、Loki 字段和索引标签不是同一回事。 要明确字段来自采集阶段还是查询解析阶段。
- 监控数据要考虑真实含义。 例如
pg_up=0、数据库连接数 11 并不必然互相矛盾。 - 指标与日志适合解决不同问题。 Prometheus 负责数值变化趋势,Loki 负责日志查询,SQL 详情可以通过 PostgreSQL 数据源进一步分析。
对于个人项目或者中小型服务器环境,Grafana + Loki + Prometheus + Alloy 是一套值得实践的方案:功能覆盖比较全面,又可以按需逐步扩展。
最重要的是,先搭建出一条稳定可靠的采集链路,再随着业务增长逐步完善 Dashboard、告警和链路追踪,而不必在项目初期就部署过于复杂的可观测平台。
参考资料
