从源码到部署:用 Docker Compose 搭建并二次开发一套开源日志采集与分析平台实战
很多团队在日志这件事上,都会经历一个非常相似的阶段:
- 一开始靠
tail -f和grep - 服务多了以后,把日志打到文件里,再靠脚本汇总
- 真出问题时,日志分散在多台机器,排查像“考古”
- 想做告警、聚合查询、错误统计时,发现原来的方式完全顶不住
这篇文章我不准备只讲“怎么跑起来”,而是带你从源码理解、Compose 部署、采集接入,到二次开发扩展字段与接口完整走一遍。为了让过程更贴近真实项目,我选用一套非常经典、适合自托管的开源组合:
- OpenSearch:负责索引与检索
- OpenSearch Dashboards:负责查询与可视化
- Fluent Bit:负责日志采集与转发
- 自定义 API 服务(Node.js):负责写入业务日志、扩展接入逻辑、演示二次开发
它不一定是“唯一正确答案”,但非常适合中级开发者理解一套日志平台从 0 到 1 是怎么搭起来的。
背景与问题
先说清楚我们要解决什么问题。
假设你现在有几个微服务:
app-apiorder-serviceuser-service
这些服务都在容器里跑,日志输出到标准输出或本地文件。你面临的问题通常包括:
- 日志分散
- 容器日志、应用日志、Nginx 日志各在各处
- 无法统一搜索
- 想查某个
traceId,只能一台台机器翻
- 想查某个
- 缺少结构化字段
- 日志只有一大串文本,无法按
level/service/env聚合
- 日志只有一大串文本,无法按
- 扩展能力弱
- 想新增一个字段
tenantId或接入内部告警逻辑,改动很痛苦
- 想新增一个字段
所以我们需要的,不只是一个“日志收集器”,而是一条完整链路:
- 应用输出结构化日志
- 采集器统一抓取
- 存储引擎索引字段
- 查询界面可视化检索
- 必要时支持源码级二次开发
前置知识与环境准备
建议你具备这些基础:
- 会看 Docker Compose
- 知道容器网络、卷挂载、环境变量
- 对 JSON 日志、HTTP 接口有基本了解
- 会一点 Node.js 或 Python,便于扩展服务
本文环境
- Docker Engine >= 20.x
- Docker Compose Plugin >= 2.x
- 至少 4 GB 内存
- Linux / macOS 均可,Windows 建议用 WSL2
项目目录结构如下:
log-platform/
├── docker-compose.yml
├── fluent-bit/
│ ├── fluent-bit.conf
│ └── parsers.conf
├── app/
│ ├── Dockerfile
│ ├── package.json
│ └── server.js
└── logs/
核心原理
先别急着敲 Compose,先搞清楚这套平台的“数据怎么流”。
flowchart LR
A[业务服务 stdout/文件日志] --> B[Fluent Bit 采集]
B --> C[日志清洗/字段解析]
C --> D[OpenSearch 索引存储]
D --> E[OpenSearch Dashboards 查询分析]
这里面有几个关键点:
1. 应用层:尽量输出结构化日志
如果应用输出的是 JSON,比如:
{
"time": "2021-10-22T19:58:30.000Z",
"level": "error",
"service": "app-api",
"traceId": "t-123",
"message": "database timeout"
}
那么后续采集、索引、聚合都更顺滑。
如果输出的是纯文本,也不是不能收,但需要额外写 parser,维护成本会高。
2. 采集层:Fluent Bit 负责“搬运 + 初步加工”
Fluent Bit 的角色不是数据库,而是轻量采集代理。它通常做这些事:
- 监听文件或容器日志
- 解析 JSON / 正则日志
- 补充标签字段
- 转发到 OpenSearch / Kafka / HTTP 等下游
3. 存储层:OpenSearch 负责索引与查询
OpenSearch 本质是搜索引擎。日志进来以后:
- 每条日志会写入索引
- 可按时间、字段、关键词检索
- 可做聚合统计,比如错误数、慢请求 TopN
4. 展示层:Dashboards 提供检索与分析入口
它主要解决两个问题:
- 人能快速查日志
- 运营/研发能做图表和保存搜索
架构图:从开发到运行时
sequenceDiagram
participant Dev as 开发者
participant App as 业务服务
participant FB as Fluent Bit
participant OS as OpenSearch
participant UI as Dashboards
Dev->>App: 修改源码,增加结构化字段
App->>FB: 输出 JSON 日志
FB->>FB: 解析/补充 tag
FB->>OS: 批量写入日志
UI->>OS: 查询索引
Dev->>UI: 检索 traceId / level / service
逐步验证清单
这一节很重要。我做这类平台时,最怕的不是“起不来”,而是每层看起来都没报错,但日志就是没进去。
所以建议按下面顺序验证:
- OpenSearch 能启动
- Dashboards 能访问
- 应用容器能正常输出日志
- Fluent Bit 能读到日志文件
- OpenSearch 中能查到索引
- Dashboards 里能创建索引模式并检索
实战代码(可运行)
下面直接上完整示例。你可以把这套文件拷到本地后直接启动。
1. 编写 docker-compose.yml
version: "3.8"
services:
opensearch:
image: opensearchproject/opensearch:1.2.4
container_name: opensearch
environment:
- discovery.type=single-node
- plugins.security.disabled=true
- bootstrap.memory_lock=true
- OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- opensearch-data:/usr/share/opensearch/data
ports:
- "9200:9200"
- "9600:9600"
networks:
- lognet
dashboards:
image: opensearchproject/opensearch-dashboards:1.2.0
container_name: dashboards
environment:
- OPENSEARCH_HOSTS=["http://opensearch:9200"]
- DISABLE_SECURITY_DASHBOARDS_PLUGIN=true
ports:
- "5601:5601"
depends_on:
- opensearch
networks:
- lognet
fluent-bit:
image: fluent/fluent-bit:1.9
container_name: fluent-bit
volumes:
- ./fluent-bit/fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf:ro
- ./fluent-bit/parsers.conf:/fluent-bit/etc/parsers.conf:ro
- ./logs:/logs
depends_on:
- opensearch
networks:
- lognet
app:
build:
context: ./app
container_name: demo-app
environment:
- LOG_FILE=/logs/app.log
- SERVICE_NAME=app-api
volumes:
- ./logs:/logs
ports:
- "3000:3000"
networks:
- lognet
volumes:
opensearch-data:
networks:
lognet:
driver: bridge
2. 编写 Fluent Bit 配置
fluent-bit/fluent-bit.conf
[SERVICE]
Flush 1
Daemon Off
Log_Level info
Parsers_File /fluent-bit/etc/parsers.conf
[INPUT]
Name tail
Path /logs/app.log
Parser json
Tag app.logs
Refresh_Interval 2
Read_from_Head true
[FILTER]
Name modify
Match app.logs
Add env dev
Add platform compose-demo
[OUTPUT]
Name opensearch
Match app.logs
Host opensearch
Port 9200
Index app-logs
Type _doc
Suppress_Type_Name On
Logstash_Format On
Logstash_Prefix app-logs
Retry_Limit False
fluent-bit/parsers.conf
[PARSER]
Name json
Format json
Time_Key time
Time_Format %Y-%m-%dT%H:%M:%S.%LZ
3. 编写示例应用
app/package.json
{
"name": "demo-log-app",
"version": "1.0.0",
"description": "demo log platform app",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"uuid": "^8.3.2"
}
}
app/Dockerfile
FROM node:16-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY server.js .
EXPOSE 3000
CMD ["npm", "start"]
app/server.js
const express = require("express");
const fs = require("fs");
const { v4: uuidv4 } = require("uuid");
const app = express();
const port = 3000;
const logFile = process.env.LOG_FILE || "/logs/app.log";
const serviceName = process.env.SERVICE_NAME || "app-api";
app.use(express.json());
function writeLog(level, message, extra = {}) {
const log = {
time: new Date().toISOString(),
level,
service: serviceName,
traceId: extra.traceId || uuidv4(),
message,
...extra
};
fs.appendFileSync(logFile, JSON.stringify(log) + "\n");
console.log(JSON.stringify(log));
}
app.get("/ping", (req, res) => {
writeLog("info", "ping success", {
path: "/ping",
method: "GET"
});
res.json({ ok: true });
});
app.get("/error", (req, res) => {
const traceId = uuidv4();
writeLog("error", "simulated error", {
traceId,
path: "/error",
method: "GET",
status: 500,
tenantId: "tenant-a"
});
res.status(500).json({ ok: false, traceId });
});
app.post("/biz", (req, res) => {
const traceId = uuidv4();
writeLog("info", "business event", {
traceId,
path: "/biz",
method: "POST",
tenantId: req.body.tenantId || "unknown",
orderId: req.body.orderId || "",
userId: req.body.userId || ""
});
res.json({ ok: true, traceId });
});
app.listen(port, () => {
writeLog("info", "server started", { port });
console.log(`server listening on ${port}`);
});
4. 启动服务
在项目根目录执行:
docker compose up -d --build
查看容器状态:
docker compose ps
查看应用日志:
docker compose logs -f app
触发几条测试请求:
curl http://localhost:3000/ping
curl http://localhost:3000/error
curl -X POST http://localhost:3000/biz \
-H "Content-Type: application/json" \
-d '{"tenantId":"tenant-b","orderId":"O1001","userId":"U9001"}'
5. 验证 OpenSearch 是否收到日志
查看索引:
curl http://localhost:9200/_cat/indices?v
查询文档:
curl http://localhost:9200/app-logs*/_search?pretty
如果一切正常,你会看到类似这样的结果:
{
"hits": {
"hits": [
{
"_source": {
"time": "2021-10-22T19:58:30.000Z",
"level": "info",
"service": "app-api",
"traceId": "xxx-xxx",
"message": "ping success",
"path": "/ping",
"method": "GET",
"env": "dev",
"platform": "compose-demo"
}
}
]
}
}
二次开发:从“能收日志”到“可用的平台”
很多教程到这里就结束了,但真实项目里,真正拉开差距的往往是二次开发能力。
这次我们从两个方向来扩展:
- 增加业务字段
- 增加一个查询接口,按 traceId 拉取日志
1. 扩展日志字段设计
假设你的平台要支持多租户排查,那么字段里最好显式保留这些内容:
tenantIdtraceIdservicelevelpathuserIdorderId
如果你前面已经照着示例应用写过,会发现我已经把这些字段预埋在 server.js 里了。
这样做的好处很直接:
- 查询错误日志时,可以直接筛
tenantId - 排查用户投诉时,可以筛
userId - 查询订单链路时,可以筛
orderId
这类字段如果后补,成本会非常高。所以经验上我会建议:日志字段设计尽量和排查场景一起做,而不是事后补洞。
2. 增加一个查询 API
我们加一个简单的日志查询服务接口,直接从 OpenSearch 按 traceId 查询,便于业务后台或排障工具集成。
把 app/server.js 改成下面这个版本:
const express = require("express");
const fs = require("fs");
const { v4: uuidv4 } = require("uuid");
const http = require("http");
const app = express();
const port = 3000;
const logFile = process.env.LOG_FILE || "/logs/app.log";
const serviceName = process.env.SERVICE_NAME || "app-api";
app.use(express.json());
function writeLog(level, message, extra = {}) {
const log = {
time: new Date().toISOString(),
level,
service: serviceName,
traceId: extra.traceId || uuidv4(),
message,
...extra
};
fs.appendFileSync(logFile, JSON.stringify(log) + "\n");
console.log(JSON.stringify(log));
}
function searchByTraceId(traceId) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
query: {
term: {
traceId: traceId
}
},
sort: [{ time: { order: "asc" } }]
});
const req = http.request(
{
hostname: "opensearch",
port: 9200,
path: "/app-logs*/_search",
method: "GET",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(e);
}
});
}
);
req.on("error", reject);
req.write(body);
req.end();
});
}
app.get("/ping", (req, res) => {
writeLog("info", "ping success", {
path: "/ping",
method: "GET"
});
res.json({ ok: true });
});
app.get("/error", (req, res) => {
const traceId = uuidv4();
writeLog("error", "simulated error", {
traceId,
path: "/error",
method: "GET",
status: 500,
tenantId: "tenant-a"
});
res.status(500).json({ ok: false, traceId });
});
app.get("/logs/:traceId", async (req, res) => {
try {
const result = await searchByTraceId(req.params.traceId);
res.json(result);
} catch (err) {
writeLog("error", "search log failed", {
path: "/logs/:traceId",
method: "GET",
error: err.message
});
res.status(500).json({ ok: false, error: err.message });
}
});
app.listen(port, () => {
writeLog("info", "server started", { port });
console.log(`server listening on ${port}`);
});
重新构建并启动:
docker compose up -d --build app
然后执行:
curl http://localhost:3000/error
拿到返回里的 traceId 后,继续查:
curl http://localhost:3000/logs/这里替换成traceId
这样你就完成了一个很常见的“日志平台二开入口”:
业务系统不用让用户直接进 Dashboards,也能通过内部接口拿到链路日志。
二次开发中的设计取舍
很多人会问:既然 Dashboards 已经能查,为啥还要自己封 API?
我的经验是,二者面向的用户不同:
- Dashboards 面向研发、运维、SRE
- 自定义 API 面向业务后台、客服系统、自动化工具
比如客服系统里,输入一个订单号就想看异常日志;
这时候让客服去学 Dashboards,显然不现实。
所以平台型能力常常要通过“薄封装接口”暴露出来。
日志处理状态图
stateDiagram-v2
[*] --> 产生日志
产生日志 --> 本地落盘
本地落盘 --> FluentBit采集
FluentBit采集 --> 解析成功
FluentBit采集 --> 解析失败
解析成功 --> 写入OpenSearch
解析失败 --> 错误日志/原文保留
写入OpenSearch --> Dashboards查询
Dashboards查询 --> [*]
常见坑与排查
这一部分我建议你认真看,很多时间都耗在这里。
坑 1:OpenSearch 启动失败,提示内存或权限问题
现象:
- 容器频繁重启
docker compose logs opensearch里出现内存锁定、JVM 或 bootstrap 检查错误
排查:
docker compose logs -f opensearch
常见原因:
- 分配内存不足
memlock配置不兼容- 机器太小,JVM 起不来
处理建议:
- 本地开发先把 JVM 降到
-Xms512m -Xmx512m - Docker Desktop 里给够内存
- 纯学习环境可先单节点、关闭安全插件
坑 2:Fluent Bit 没有采到日志
现象:
- 应用服务正常
- OpenSearch 正常
- 但索引为空
排查顺序:
先看日志文件是否真的存在
ls -lah ./logs
cat ./logs/app.log
再看 Fluent Bit 日志
docker compose logs -f fluent-bit
如果文件路径不对、没有权限、Parser 不匹配,日志里一般会有提示。
常见原因:
./logs:/logs没挂载成功- 应用写入的是另一个路径
- JSON 格式不合法,一行不是一个完整对象
坑 3:OpenSearch 有数据,Dashboards 里却搜不到
这也是特别常见的坑。
排查:
- 确认索引是否存在
curl http://localhost:9200/_cat/indices?v
- 打开 Dashboards,创建 index pattern,比如:
app-logs*
-
选择正确的时间字段:
time -
把查询时间范围拉大到最近 15 分钟 / 24 小时
很多时候不是“没数据”,而是时间字段没识别或时间范围没选对。
坑 4:按 traceId 查不到结果
如果你用的是 term 查询,字段类型很关键。
如果 traceId 被映射成 text 而不是 keyword,精确匹配可能失效。
生产里建议显式建索引模板,把常用过滤字段映射成 keyword。
例如:
{
"index_patterns": ["app-logs*"],
"template": {
"mappings": {
"properties": {
"traceId": { "type": "keyword" },
"tenantId": { "type": "keyword" },
"service": { "type": "keyword" },
"level": { "type": "keyword" },
"message": { "type": "text" },
"time": { "type": "date" }
}
}
}
}
创建模板命令:
curl -X PUT "http://localhost:9200/_index_template/app-logs-template" \
-H "Content-Type: application/json" \
-d '{
"index_patterns": ["app-logs*"],
"template": {
"mappings": {
"properties": {
"traceId": { "type": "keyword" },
"tenantId": { "type": "keyword" },
"service": { "type": "keyword" },
"level": { "type": "keyword" },
"message": { "type": "text" },
"time": { "type": "date" }
}
}
}
}'
坑 5:日志量一大,OpenSearch 写入变慢
症状:
- 查询也开始变卡
- 容器 CPU 飙高
- 磁盘占用增长很快
常见原因:
- 索引切分不合理
- 副本数在单节点环境下无意义
- 动态字段太多,mapping 爆炸
- 写入过于频繁,没有批量优化
建议:
- 开发环境设置副本数为 0
- 控制字段数量,避免把整段大对象直接塞进去
- 高频变化字段单独处理
- 把日志分级,不要所有 debug 都长期开启
安全/性能最佳实践
这一节我尽量讲得务实一点,不追求“大而全”。
安全实践
1. 不要在生产环境关闭安全插件
本文为了本地快速演示,禁用了 OpenSearch 安全相关配置。
但生产环境至少要做:
- 开启认证
- 启用 TLS
- 限制 Dashboards 访问来源
- 按角色控制索引权限
2. 敏感信息脱敏
日志里最容易被忽略的是:
- token
- 手机号
- 身份证号
- 银行卡号
- 用户隐私内容
我的建议是:在应用输出前就脱敏,不要指望后置补救。
因为一旦进了日志平台,副本、备份、导出链路都可能扩散。
3. 采集器与存储分网络域隔离
最理想的做法是:
- 业务容器在业务网段
- 日志采集/存储在内部网段
- Dashboards 只对堡垒机或 VPN 开放
这样即使某个业务容器被打穿,也不至于直接拿到日志平台管理面。
性能实践
1. 优先结构化,而不是正则硬解析
结构化 JSON 日志的处理成本通常远低于大量正则。
如果你能改业务代码,优先改代码,而不是堆 parser。
2. 控制字段基数
像下面这些字段高基数很危险:
requestBodystack(超长)- 动态对象 key
- 用户自由输入内容
OpenSearch 对高基数字段不友好,容易导致索引膨胀和聚合性能变差。
3. 做日志分层
经验上我会分成三类:
- 访问日志:保留较长时间,用于行为分析
- 错误日志:重点保留,便于排障
- 调试日志:短期保留,按需开启
不要把所有日志都按一个策略处理,不然成本会很高。
4. 给索引做生命周期管理
即使是中小团队,也建议尽早规划:
- 按天或按周滚动索引
- 老索引冷存储或删除
- 控制保留周期
否则最常见的结局就是:
“平台搭得很顺,但三个月后磁盘炸了”。
一套更适合生产的演进路线
如果你已经把本文方案跑通,下一步可以按下面路线升级:
- 单机 Compose 演示环境
- 增加索引模板与字段规范
- 加入告警规则
- 接入多服务、多租户字段
- 迁移到 Kubernetes 或专用日志架构
- 接入对象存储/冷热分层
也就是说,Compose 很适合:
- 本地学习
- 中小团队 PoC
- 单机或轻量部署
但当你日志量明显增长、需要高可用时,就该考虑更正式的部署方案了。
我实际会怎么落地
如果让我在一个中小团队里推这件事,我一般不会一步到位搞得很重,而是这么做:
第一步:统一日志格式
先要求所有服务输出 JSON,至少带上:
timelevelservicetraceIdmessage
第二步:打通采集和检索
先让大家能在一个界面查日志。
这一步价值最大,阻力也最小。
第三步:围绕排障场景补字段
比如投诉排查,就补:
tenantIduserIdorderId
第四步:做薄二开接口
把常见查询封成 API,给内部平台接入。
这样日志平台才会真正“被用起来”。
总结
这篇文章我们做了几件事:
- 用 Docker Compose 搭起了一套开源日志采集与分析平台
- 理清了 应用 -> Fluent Bit -> OpenSearch -> Dashboards 的核心链路
- 写了一套可运行代码
- 演示了如何做二次开发:补业务字段、增加按
traceId查询接口 - 梳理了常见排查方法以及安全、性能实践
如果你现在只是想快速验证方案,本文这一套已经足够。
如果你准备往生产推进,我给的建议是:
- 先规范日志格式,再谈平台能力
- 先解决检索效率,再扩展分析能力
- 二次开发优先围绕真实排障场景,不要先造“大平台”
- 生产环境一定补上认证、TLS、索引模板和生命周期管理
最后说一句很实在的话:
日志平台并不是“装几个组件”就完了,它真正的价值,来自字段设计、接入规范和使用习惯。
把这三件事做好,平台才不会变成一个摆设。