OpenObserve + Vector:把 Nginx 日志接入自建可观测平台

内网服务越堆越多,日志全靠 tail 和 grep,出问题得挨个容器找。想找个开源可观测平台,ELK 太重,Loki 查询语法劝退,最后选了 OpenObserve(下称 O2)–单容器、Rust 写的、资源占用小。两天时间从零部署,再用 Vector 把 Nginx Proxy Manager 的访问日志接进去,全流程记一下。

为什么选 OpenObserve

ELK 那套 Elasticsearch + Logstash + Kibana,个人/小团队用就是杀鸡用牛刀,内存起步 4G。Loki + Grafana 轻是轻,但 LogQL 和查询体验实在提不起兴趣。O2 的卖点:

  • 单二进制单容器,Rust 实现,内存占用小
  • 日志/指标/链路追踪三合一,还带 Dashboard 和告警
  • 数据落 Parquet 列存 + S3,官方号称存储成本比 ELK 低 140 倍
  • UI 是类 Kibana 的查询界面,SQL 语法直接查

小规模自托管场景,O2 基本是目前最省心的选择。

部署形态

O2 生产模式建议外部 Postgres 存元数据,流数据落 S3。我的拓扑:

1
2
3
公网域名 -> NPM 反代 -> O2 容器 (host 网络, 端口 5080)
├─ 元数据: Postgres 18
└─ 流数据: RustFS S3 (自建 MinIO 兼容存储)

Postgres 元数据库

O2 的组织、用户、Dashboard 定义都存 Postgres,流数据(日志本体)不进库。新建一个库就行:

1
CREATE DATABASE openobserve;

S3 侧给 O2 建一个专用账号,只授权目标 bucket 的 5 个最小 action,别图省事直接用 admin key。

容器配置

镜像用 Docker Hub 的 openobserve/openobserve:latest别用 GHCR 源,拉镜像 403,我第一次就卡在这。

核心环境变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
environment:
ZO_META_STORE: postgres
ZO_META_POSTGRES_DSN: postgresql://user:pass@{postgres-host}:5432/openobserve
ZO_S3_PROVIDER: minio # 坑,见下文
ZO_S3_SERVER_URL: http://{s3-host}:9000/
ZO_S3_BUCKET_NAME: openobserve
ZO_S3_ACCESS_KEY: xxx
ZO_S3_SECRET_KEY: xxx
ZO_LOCAL_MODE_STORAGE: s3
ZO_HTTP_PORT: "5080"
ZO_DATA_DIR: /data
ZO_ROOT_USER_EMAIL: you@example.com
ZO_ROOT_USER_PASSWORD: "{password}" # 8-128位,大小写+数字+特殊符号
ZO_COMPACT_DATA_RETENTION_DAYS: "60" # 默认 3650,自用改小

ZO_ROOT_USER_EMAIL/PASSWORD 不配容器直接 panic,起不来。

踩坑记录

坑 1:S3 provider 填 s3 会卡住

ZO_S3_PROVIDER=s3 会触发 AWS SDK 去 probe 169.254.169.254(EC2 元数据服务),自建机房没有这东西,进程卡在探测上不动。改成 minio 再加一个 AWS_EC2_METADATA_DISABLED=true,立刻正常。

坑 2:parquet job 疑似不上传

日志进来 UI 能查到,但 S3 bucket 里半天没文件,看起来像 parquet 合并任务没跑。其实是误判–O2 的数据先写本地 WAL,parquet job 每 2 秒扫一次 WAL,攒够一批才落 S3。把日志级别开到 debug 就能看到 executing job:

1
RUST_LOG=info,openobserve_jobs::job::file::parquet=debug

坑 3:断电最多丢 600 秒日志

WAL 是异步批量的,正常 docker stop 数据 100% 保留(实测过),但 SIGKILL/断电场景最多丢 600 秒窗口内的数据。这是 O2 的设计取舍,不是 bug。介意的话把 ZO_MAX_FILE_RETENTION_TIME 调小,代价是刷盘更频繁。

坑 4:Dashboard API 的 schema 混着大小写

用 API 批量建 Dashboard 得踩一遍 v3 schema:大部分字段是 camelCase,但 PanelFields 里的 stream_type/aggregation_function 是 snake_case,promql_legendquery.config 里面不在顶层。UI 手点没这问题,写脚本才知道疼。

Vector 采 NPM 日志

O2 部署完只是个空壳,得有数据进来。第一件事:把 NPM(Nginx Proxy Manager)的访问日志接进去。

为什么用 Vector 不用 O2 自带 agent:NPM 日志在宿主机文件里,Vector 的 file source + transform 够用,还有个现成的 NPM 日志解析逻辑。整个采集链:

1
2
3
4
5
NPM 容器 /data/logs/*.log
↓ bind mount 共享给 Vector
Vector (file source → parse_nginx_log + 自定义 regex → http sink)
↓ POST JSON
O2 /api/{org}/{stream}/_json

部署形态

Vector 和 NPM 放同一个 Portainer stack,NPM 的数据目录 bind mount 给 Vector:

1
2
3
4
5
6
7
services:
vector-logs:
image: timberio/vector:0.41.0-debian
volumes:
- /vol1/1000/docker/npm/data:/var/log/npm:ro
- vector_config:/etc/vector
# 不要挂 docker.sock!file source 不需要 docker API

**别挂 /var/run/docker.sock**。网上教程清一色挂 sock,实际 file source 根本用不到 docker API,挂了纯属扩大攻击面。

vector.toml 核心配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
[sources.npm_access]
type = "file"
include = ["/var/log/npm/logs/proxy-host-*_access.log"]
ignore_older_secs = 86400

# 解析。NPM 的 access log 不是标准 combined 格式,得自定义 regex
[transforms.parse_access]
type = "remap"
inputs = ["npm_access"]
source = '''
parsed, err = parse_regex(.message, r'^(?<client_ip>[^ ]+) - (?<user>[^ ]+) \[(?<timestamp>[^\]]+)\] "(?<method>\S+) (?<uri>\S+) (?<protocol>[^"]+)" (?<status>\d{3}) (?<length>\d+) "[^"]*" "(?<user_agent>[^"]+)"')
if err == null {
. = merge(., parsed)
.timestamp = parse_timestamp(.timestamp, format: "%d/%b/%Y:%H:%M:%S %z")
}
. .agent_host = "npm-proxy"
'''

# 自我循环过滤:Vector 自己写 O2 的请求会落进 NPM 日志,形成套娃
[transforms.drop_selfloop]
type = "filter"
inputs = ["parse_access"]
condition = '''
.uri != "/api/{org_id}/v1/logs"
'''

[sinks.openobserve]
type = "http"
inputs = ["drop_selfloop"]
uri = "https://{yourdomain}:{https-port}/api/{org_id}/{stream}/_json"
method = "post"
payload.tag = "nginx_access"
request.headers.Authorization = "Basic {base64-user:token}"
encoding.codec = "json"
framing.method = "array" # O2 拒收单个 object,必须 array

几个关键点:

  • NPM 日志路径多一层/var/log/npm/logs/,不是 /var/log/npm/
  • framing 必须 array:O2 的 JSON ingest 端点拒收单个 JSON object,写成数组或者 content-type 用 application/json 才行
  • 自我循环过滤:Vector 往 O2 写数据的请求本身会经过 NPM 反代,落进 access log,又被 Vector 采集,无限套娃。过滤条件用 URI 精确匹配,别按 host 过滤–会把别的服务走 O2 的请求也误伤
  • 别覆盖解析出来的 host 字段:给 Vector 自己的身份标记用 .agent_host,直接写 .host 会把日志里真实的 host 冲掉

建组织 + Token

O2 按 org 隔离数据,一个应用一个 org 是比较干净的做法:

1
2
3
4
5
6
7
8
9
# 建 org(名字只能字母数字下划线,不许连字符)
curl -X POST https://{yourdomain}:{https-port}/api/organizations \
-u root@example.com:{password} \
-H "Content-Type: application/json" \
-d '{"name": "npm_logs"}'

# 建 ingest token
curl -X POST https://{yourdomain}:{https-port}/api/{org_id}/ingestion-tokens \
-u root@example.com:{password}

root 账号自动能访问所有 org,不用手动加成员。

效果

数据进来后建了个 Dashboard:请求 QPS、状态码分布、2xx/4xx/5xx 趋势、Top IP/URI/Host、错误趋势。以前”这个域名最近老 502”这种问题,现在是点两下的事。

NPM error log 也一并接了(同一套 file source + 正则,换个解析 regex),severity 字段天然适合做告警:severity = "error" AND msg LIKE "%upstream%",5 分钟出一条就推飞书。

两天从零到日志可查、Dashboard 可用。O2 这套对个人/小团队确实友好:部署简单、存储便宜、查询不卡。坑主要在 S3 provider、WAL 落盘机制和 Dashboard API 的 schema 这些角落里,踩一遍就知道怎么绕了。

日志只是第一步,指标(otelcol 采 PG/Redis)已经在路上,下一步把链路追踪也接进来。