折腾实验室草稿

从零搭建轻量级可观测平台:Grafana + Loki + Prometheus + Alloy 实战与踩坑记录

在个人项目和中小型服务部署中,日志和监控往往是比较容易被忽略的一部分。项目刚上线时,我们可能只需要通过 docker logs 查看异常,或者登录服务器使用 top、free、df 等命令检查资源。但是随着服务数量增加,这种方式的问题就会逐渐显现...

2026.10.3023 分钟阅读 更新于 2026.10.30
从零搭建轻量级可观测平台:Grafana + Loki + Prometheus + Alloy 实战与踩坑记录
从零搭建轻量级可观测平台:Grafana + Loki + Prometheus + Alloy 实战与踩坑记录 · Cover

从零搭建轻量级可观测平台: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 的内存、存储和运维成本相对较高。

对于个人项目和中小型业务,我更关注以下几点:

  1. 资源消耗尽可能低。
  2. 能够同时采集日志和系统指标。
  3. 能够构建自定义 Dashboard。
  4. 支持 Docker、PostgreSQL、Linux 和反向代理监控。
  5. 日志和指标数据可以集中存储在 NAS。
  6. 云服务器尽可能不额外运行大型存储服务。

因此最终选择:

组件作用部署位置
GrafanaDashboard、日志查询、监控展示极空间 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

这里有几个重要注意事项:

  1. Docker Socket 可以暴露较高的宿主机管理权限,只读挂载路径并不等于只读 Docker API。
  2. privileged: true 是为了兼容当前 cAdvisor 采集方案,并不是所有 Alloy 部署都需要。
  3. 生产环境应尽量减少挂载和特权范围。
  4. PostgreSQL 容器和 Alloy 容器必须有可互通的网络。
  5. 不建议长期使用 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

这说明两个问题同时存在:

  1. Caddy 运行账号没有访问日志目录的写入权限。
  2. 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
每秒请求数 RPSStat
HTTP 错误请求数Stat
P95 响应时间Stat
HTTP 请求趋势Time series
HTTP 状态码分布Pie chart
域名访问量 Top 10Bar chart
热门 URL Top 10Bar chart
客户端 IP Top 10Bar 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 出现过多次。

遇到这种问题,建议按以下顺序排查:

  1. 数据源本身是否可用。
  2. 指标或日志是否真的已经采集。
  3. 查询的 job、host、container_name 是否正确。
  4. Grafana 当前时间范围内是否存在数据。
  5. 变量展开结果是否与标签匹配。
  6. 面板的 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 连接及事务状态,从而缩小排查范围。

这次实践中,几个尤其值得记住的经验是:

  1. 采集链路打通比 Dashboard 更重要。 没有可靠的数据来源,再漂亮的图表也没有意义。
  2. Docker 网络需要持久化配置。 手动 docker network connect 能解决当前问题,但不代表容器重建后仍然有效。
  3. 日志权限和 systemd 状态需要分别排查。 Caddy 的 permission denied 与长期卡在 reloading 状态可能同时存在。
  4. Grafana 变量、Loki 字段和索引标签不是同一回事。 要明确字段来自采集阶段还是查询解析阶段。
  5. 监控数据要考虑真实含义。 例如 pg_up=0、数据库连接数 11 并不必然互相矛盾。
  6. 指标与日志适合解决不同问题。 Prometheus 负责数值变化趋势,Loki 负责日志查询,SQL 详情可以通过 PostgreSQL 数据源进一步分析。

对于个人项目或者中小型服务器环境,Grafana + Loki + Prometheus + Alloy 是一套值得实践的方案:功能覆盖比较全面,又可以按需逐步扩展。

最重要的是,先搭建出一条稳定可靠的采集链路,再随着业务增长逐步完善 Dashboard、告警和链路追踪,而不必在项目初期就部署过于复杂的可观测平台。


参考资料

ZZC

ZZC

写代码,也写一些生活。