跳转到内容
123xiao | 无名键客

《从源码到部署:用 Docker Compose 搭建并二次开发一套开源日志采集与分析平台实战》

字数: 0 阅读时长: 1 分钟

从源码到部署:用 Docker Compose 搭建并二次开发一套开源日志采集与分析平台实战

很多团队在日志这件事上,都会经历一个非常相似的阶段:

  • 一开始靠 tail -fgrep
  • 服务多了以后,把日志打到文件里,再靠脚本汇总
  • 真出问题时,日志分散在多台机器,排查像“考古”
  • 想做告警、聚合查询、错误统计时,发现原来的方式完全顶不住

这篇文章我不准备只讲“怎么跑起来”,而是带你从源码理解、Compose 部署、采集接入,到二次开发扩展字段与接口完整走一遍。为了让过程更贴近真实项目,我选用一套非常经典、适合自托管的开源组合:

  • OpenSearch:负责索引与检索
  • OpenSearch Dashboards:负责查询与可视化
  • Fluent Bit:负责日志采集与转发
  • 自定义 API 服务(Node.js):负责写入业务日志、扩展接入逻辑、演示二次开发

它不一定是“唯一正确答案”,但非常适合中级开发者理解一套日志平台从 0 到 1 是怎么搭起来的。


背景与问题

先说清楚我们要解决什么问题。

假设你现在有几个微服务:

  • app-api
  • order-service
  • user-service

这些服务都在容器里跑,日志输出到标准输出或本地文件。你面临的问题通常包括:

  1. 日志分散
    • 容器日志、应用日志、Nginx 日志各在各处
  2. 无法统一搜索
    • 想查某个 traceId,只能一台台机器翻
  3. 缺少结构化字段
    • 日志只有一大串文本,无法按 level/service/env 聚合
  4. 扩展能力弱
    • 想新增一个字段 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

逐步验证清单

这一节很重要。我做这类平台时,最怕的不是“起不来”,而是每层看起来都没报错,但日志就是没进去
所以建议按下面顺序验证:

  1. OpenSearch 能启动
  2. Dashboards 能访问
  3. 应用容器能正常输出日志
  4. Fluent Bit 能读到日志文件
  5. OpenSearch 中能查到索引
  6. 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"
        }
      }
    ]
  }
}

二次开发:从“能收日志”到“可用的平台”

很多教程到这里就结束了,但真实项目里,真正拉开差距的往往是二次开发能力。

这次我们从两个方向来扩展:

  1. 增加业务字段
  2. 增加一个查询接口,按 traceId 拉取日志

1. 扩展日志字段设计

假设你的平台要支持多租户排查,那么字段里最好显式保留这些内容:

  • tenantId
  • traceId
  • service
  • level
  • path
  • userId
  • orderId

如果你前面已经照着示例应用写过,会发现我已经把这些字段预埋在 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 里却搜不到

这也是特别常见的坑。

排查:

  1. 确认索引是否存在
curl http://localhost:9200/_cat/indices?v
  1. 打开 Dashboards,创建 index pattern,比如:
app-logs*
  1. 选择正确的时间字段:time

  2. 把查询时间范围拉大到最近 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. 控制字段基数

像下面这些字段高基数很危险:

  • requestBody
  • stack(超长)
  • 动态对象 key
  • 用户自由输入内容

OpenSearch 对高基数字段不友好,容易导致索引膨胀和聚合性能变差。

3. 做日志分层

经验上我会分成三类:

  • 访问日志:保留较长时间,用于行为分析
  • 错误日志:重点保留,便于排障
  • 调试日志:短期保留,按需开启

不要把所有日志都按一个策略处理,不然成本会很高。

4. 给索引做生命周期管理

即使是中小团队,也建议尽早规划:

  • 按天或按周滚动索引
  • 老索引冷存储或删除
  • 控制保留周期

否则最常见的结局就是:
“平台搭得很顺,但三个月后磁盘炸了”。


一套更适合生产的演进路线

如果你已经把本文方案跑通,下一步可以按下面路线升级:

  1. 单机 Compose 演示环境
  2. 增加索引模板与字段规范
  3. 加入告警规则
  4. 接入多服务、多租户字段
  5. 迁移到 Kubernetes 或专用日志架构
  6. 接入对象存储/冷热分层

也就是说,Compose 很适合:

  • 本地学习
  • 中小团队 PoC
  • 单机或轻量部署

但当你日志量明显增长、需要高可用时,就该考虑更正式的部署方案了。


我实际会怎么落地

如果让我在一个中小团队里推这件事,我一般不会一步到位搞得很重,而是这么做:

第一步:统一日志格式

先要求所有服务输出 JSON,至少带上:

  • time
  • level
  • service
  • traceId
  • message

第二步:打通采集和检索

先让大家能在一个界面查日志。
这一步价值最大,阻力也最小。

第三步:围绕排障场景补字段

比如投诉排查,就补:

  • tenantId
  • userId
  • orderId

第四步:做薄二开接口

把常见查询封成 API,给内部平台接入。
这样日志平台才会真正“被用起来”。


总结

这篇文章我们做了几件事:

  • Docker Compose 搭起了一套开源日志采集与分析平台
  • 理清了 应用 -> Fluent Bit -> OpenSearch -> Dashboards 的核心链路
  • 写了一套可运行代码
  • 演示了如何做二次开发:补业务字段、增加按 traceId 查询接口
  • 梳理了常见排查方法以及安全、性能实践

如果你现在只是想快速验证方案,本文这一套已经足够。
如果你准备往生产推进,我给的建议是:

  1. 先规范日志格式,再谈平台能力
  2. 先解决检索效率,再扩展分析能力
  3. 二次开发优先围绕真实排障场景,不要先造“大平台”
  4. 生产环境一定补上认证、TLS、索引模板和生命周期管理

最后说一句很实在的话:
日志平台并不是“装几个组件”就完了,它真正的价值,来自字段设计、接入规范和使用习惯
把这三件事做好,平台才不会变成一个摆设。


分享到:

上一篇
《Web3 中级实战:基于 EIP-4337 实现智能账户与 Gas 代付的钱包接入方案》
下一篇
《Java 中基于 CompletableFuture 的异步编排实战:提升接口聚合性能与可维护性-172》