基于Docker Compose搭建ELK日志平台:SpringBoot与Nginx日志统一采集实践
2026/9/7 20:55:52 网站建设 项目流程

最近半年我一直在跟一套日志系统较劲。项目本身不算复杂:SpringBoot2 提供接口,Vue3 做前端,整套东西用 Docker Compose 部署在 Rockylinux 9.6 上。问题在于,一旦线上出故障,日常排查还停留在docker logs加肉眼翻文件的阶段,后端日志在前端 Nginx 容器里翻一翻,效率低不说,还容易漏线索。这篇文章要分享的是我在这套环境下,用 ELK 7.17.10 把 SpringBoot2 后端、Vue3 前端 Nginx 日志全部收进 Elasticsearch,再通过 Kibana 统一检索的完整过程。版本、Docker Compose 编排、Filebeat 采集、Logstash 解析、Kibana 检索配置,以及部署过程中踩到的坑,都会写出来。如果你正在衡量要不要在单机或少量服务器上搭 ELK,可以参考我这条已经跑通的链路。

1. 为什么锁定 ELK 7.17.10 这个组合

1.1 先确定需求,再定选型

我最初的诉求很简单:把后端应用的业务日志、前端 Nginx 的访问日志和错误日志集中存到一个地方,能按时间、按关键字、按日志级别快速过滤。团队之前也讨论过 Loki 或 ClickHouse 的方案,但最终选 ELK,有几个现实原因:团队成员对 Kibana 的检索界面已经很熟,不需要额外学习;ELK 生态里的 Logstash 能做复杂的 grok 解析和字段清洗;Elasticsearch 的全文检索能力对“不知道异常在哪,先搜一搜”的场景非常合适。

1.2 7.17.x 在 ELK 版本线里的特殊位置

版本锁定在 7.17.10,而不是直接上 8.x,这一点我斟酌过。7.17.x 是 Elasticsearch 7.x 系列的最后一个大版本分支,官方对它的兼容性维护是最晚结束的,所以它既有 7.x 稳定的配置风格,又补上了早期 7.x 版本的一堆 bug 修复。8.x 引入了新的安全配置方式、新的 API 组织方式,和很多老项目里现成脚本、既有技能栈的兼容成本更高。对于只需要一个内部日志平台的场景来说,7.17.10 足够稳,资料也多,遇到问题随便搜一下就能有答案。

1.3 SpringBoot2 生态对 7.17 的兼容更省心

标题里专门写了 SpringBoot2,这里多说一句。我们现在的后端还是 SpringBoot 2.x,Spring Data Elasticsearch 4.x 那一套对 ES 7.17 的支持匹配度很高。如果换成 8.x,虽然日志采集链路本身不直接依赖 Spring Data Elasticsearch,但后续如果要做业务侧对 ES 的读写操作,2.x 项目里引入的客户端版本需要专门适配 8.x,容易引入兼容性问题。既然是搭日志平台,我倾向于把未来的变更面控制得小一点。

1.4 组件版本统一的好处

ELK 全家桶里我选的版本非常统一:elasticsearch、logstash、kibana、filebeat 全部用7.17.10。配套镜像也直接从官方 Elastic 镜像仓库拉。Filebeat 和 Logstash 同版本、Logstash 和 Elasticsearch 同版本,能够最大程度避开跨版本带来的 metadata 兼容问题。这里特别提醒一下:如果你要混搭版本,至少保证 Beats 的版本不高于 Elasticsearch 的主版本,否则可能出现字段映射异常。

综合下来,7.17.10 是这套场景里最均衡的选择:不算最新,但足够安全,资料丰富,配置方式也成熟。

2. 部署前的宿主机准备与目录规划

2.1 确认基础环境:Rockylinux 9.6 + Docker Compose v2

我这边宿主机是 Rockylinux 9.6,内核自带的 Docker CE 环境和 Compose v2 插件。先做环境确认,避免后续浪费排查时间:

cat /etc/rocky-release docker --version docker compose version

之前有同事问过我docker composedocker-compose的区别。简单说:docker-compose是 Python 写的旧版独立命令,现在很多新环境直接推荐用 Docker CLI 插件版的docker compose,中间是空格,不是短横线。Compose v2 的配置语法和对 docker compose 命令的兼容性都更好。我整篇的操作都用docker compose(空格)来说明。

2.2 一个容易被忽略的内核参数

Elasticsearch 底层依赖 mmap,对虚拟内存映射区域有数量限制。如果不开这个内核参数,ES 容器会直接启动失败,报错信息类似:

max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]

这不是容器配置能绕过去的,必须在宿主机内核层修改:

sudo sysctl -w vm.max_map_count=262144 echo 'vm.max_map_count=262144' | sudo tee -a /etc/sysctl.conf sudo sysctl -p

改完之后最好重新登录会话,或者至少用sysctl vm.max_map_count确认一下新值已经生效。我在第一次部署时就是没改这个参数,ES 容器反复重启,白白浪费了十几分钟。

2.3 内存和磁盘的规划

单机跑一套 ELK 加业务容器,内存分配要提前算好。我的服务器可用内存是 8G,分配大致如下:

组件JVM/常驻内存说明
Elasticsearch1G 堆 + 约 0.5G 系统开销堆内存不要超过物理内存一半
Logstash512M 堆 + 约 0.3G 系统开销解析压力大时再往上加
Kibana约 0.3G 常驻空闲时占用不高
Filebeat约 0.1G 常驻很轻量
业务容器按实际情况预留SpringBoot / Nginx / 数据库等

这里有个经验:Elasticsearch 的 JVM 堆不是越大越好,超过物理内存的一半反而会增加 GC 压力,甚至因为容器 cgroup 限制被系统 OOM 杀掉。另外,如果凑巧 Compose 里看到deploy.resources这种配置,要注意——deploy配置在非 Swarm 模式下默认是不生效的,单机直接 Compose 跑,资源限制用mem_limit更可靠。

2.4 目录规划:统一在 /opt/elk 下管理

日志采集路径很关键,必须在一开始就定好。我最终采用的目录结构如下:

/opt/elk/ ├── es-data/ # Elasticsearch 数据目录 ├── es-plugins/ # Elasticsearch 插件目录 ├── logstash/ │ └── pipeline/ │ └── logstash.conf # Logstash 主配置 ├── filebeat/ │ ├── config/ │ │ └── filebeat.yml # Filebeat 配置 │ └── data/ # Filebeat 内部状态目录,持久化 registry ├── kibana/ │ └── config/ ├── logdata/ # 统一日志挂载层 │ ├── springboot/ # SpringBoot 应用日志落盘目录 │ └── nginx/ # Nginx access/error 日志目录

ES 的数据目录必须持久化,否则容器一删数据就没了;Filebeat 的data目录也要持久化,里面保存了类似“上次读到哪个位置”的 registry 状态。如果不挂载这个目录,Filebeat 每次重启都可能把历史日志重新读一遍,Kibana 里会凭空多出一堆重复记录。

另外,ES 容器内部是以 uid 1000 的用户运行的,不是 root。所以宿主机上给es-dataes-plugins授权时要注意:

sudo mkdir -p /opt/elk/es-data /opt/elk/es-plugins sudo chown -R 1000:1000 /opt/elk/es-data /opt/elk/es-plugins

否则 ES 容器启动后写不了数据目录,直接报权限错误。

3. 用 Docker Compose 把 ELK 四件套编排起来

3.1 docker-compose.yml 完整内容

把规划落到 Compose 文件,我在/opt/elk下创建了docker-compose.yml。完整内容如下:

version: "3.8" services: elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:7.17.10 container_name: es restart: always environment: - node.name=es-node - cluster.name=my-es-cluster - discovery.type=single-node - bootstrap.memory_lock=true - "ES_JAVA_OPTS=-Xms1g -Xmx1g" - xpack.security.enabled=false ulimits: memlock: soft: -1 hard: -1 nofile: soft: 65536 hard: 65536 volumes: - /opt/elk/es-data:/usr/share/elasticsearch/data - /opt/elk/es-plugins:/usr/share/elasticsearch/plugins ports: - "9200:9200" networks: - elk-net logstash: image: docker.elastic.co/logstash/logstash:7.17.10 container_name: logstash restart: always environment: - "LS_JAVA_OPTS=-Xms512m -Xmx512m" volumes: - /opt/elk/logstash/pipeline:/usr/share/logstash/pipeline ports: - "5044:5044" depends_on: - elasticsearch networks: - elk-net kibana: image: docker.elastic.co/kibana/kibana:7.17.10 container_name: kibana restart: always environment: - ELASTICSEARCH_HOSTS=http://es:9200 - I18N_LOCALE=zh-CN ports: - "5601:5601" depends_on: - elasticsearch networks: - elk-net filebeat: image: docker.elastic.co/beats/filebeat:7.17.10 container_name: filebeat restart: always user: root volumes: - /opt/elk/filebeat/config/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro - /opt/elk/filebeat/data:/usr/share/filebeat/data - /opt/elk/logdata:/logs:ro depends_on: - logstash networks: - elk-net networks: elk-net: driver: bridge

几个需要留意的点:

  • discovery.type=single-node表示单节点部署,省去集群发现配置。如果你后续要扩容成多节点,这里要改。
  • bootstrap.memory_lock=true配合ulimits的 memlock 配置,让 ES 锁定内存,避免内存交换导致的性能抖动。-1表示不限制。
  • ES_JAVA_OPTS-Xms-Xmx要设成一样,避免 JVM 动态扩容导致性能波动。
  • Filebeat 用user: root,因为采集的日志文件在宿主机挂载目录里,普通用户可能没有读取权限。
  • Filebeat 挂载/opt/elk/logdata:/logs:ro,只读挂载就够,因为 Filebeat 只是读取,不会写业务日志目录。

3.2 启动与首次验证

/opt/elk下执行:

docker compose up -d docker compose ps

第一次启动建议看 ES 的启动日志,确认它顺利过了 bootstrap 检查:

docker logs -f es

看到类似下面的日志就说明 ES 起来了:

[INFO ][o.e.n.Node] [es-node] started

再验证 HTTP 接口:

curl -s http://localhost:9200

正常会返回类似"cluster_name" : "my-es-cluster"的 JSON。ES 起来了,Kibana 和 Logstash 也会陆续起来。Kibana 第一次启动需要等几十秒,因为要往 ES 里初始化自己的索引。

这里还一个常见困惑:docker compose up -d里的-d是什么含义?它是--detach的缩写,意思是后台运行,不带-d的话,Compose 会卡在前台不断输出日志。另外,-f参数用来指定 Compose 文件路径,-p参数用来指定 project 名。比如docker compose -f /opt/elk/docker-compose.yml -p elk up -d。project 名会影响容器的命名前缀和默认网络名,多环境部署时特别有用。

3.3 日志保留策略和容器清理

ELK 组件多,日志量大,如果不控制,宿主机/var/lib/docker/containers会越涨越大。我在每个业务服务的 Compose 配置里都加上了日志轮转:

logging: driver: json-file options: max-size: "100m" max-file: "3"

包括 ELK 组件本身,也建议加上这个限制。ES 的日志文件如果放任不管,一天几个 GB 很常见。

4. SpringBoot2 与 Vue3 日志采集的关键配置

4.1 日志链路的设计思路

在整套架构里,日志的流动路径是一条直线:

SpringBoot 应用日志 -> 宿主机挂载目录 -> Filebeat 读取 -> Logstash 解析 -> Elasticsearch 索引 -> Kibana 检索

Vue3 前端本身不产生服务端日志,能采集的是 Nginx 的访问日志和错误日志。Nginx 容器日志同样挂载到宿主机,再交给 Filebeat。这个过程里最需要动脑的是两块:日志如何落盘、落盘后如何被 Filebeat 识别并解析成结构化字段。

4.2 SpringBoot 侧:logback 输出、滚动与挂载

SpringBoot 2.x 自带的日志框架是 Logback。默认的日志格式大致长这样:

2024-12-08T10:15:30.123+08:00 INFO 12345 --- [http-nio-8080-exec-1] com.example.demo.HelloController : hello

我把应用日志写到容器内/app/logs,再挂载到宿主机的/opt/elk/logdata/springboot。在application.yml里可以配置:

logging: file: name: /app/logs/app.log logback: rollingpolicy: max-file-size: 100MB max-history: 7

这样日志会在 100MB 时滚动,保留最近 7 个历史文件。对应的 Compose 服务大致如下:

services: springboot-app: image: my-app:1.0.0 container_name: springboot-app volumes: - /opt/elk/logdata/springboot:/app/logs logging: driver: json-file options: max-size: "100m" max-file: "3"

4.3 Nginx 侧:Vue3 前端项目日志挂载

Vue3 构建出的静态资源最终由 Nginx 提供服务。Nginx 容器的标准配置会把访问日志写到/var/log/nginx/access.log,错误日志写到/var/log/nginx/error.log。把整个日志目录挂载出来即可:

services: vue3-app: image: nginx:1.26-alpine container_name: vue3-app volumes: - /opt/elk/logdata/nginx:/var/log/nginx - ./dist:/usr/share/nginx/html:ro - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro

这里有一个容易混淆的地方:Vue3 前端项目在浏览器端抛出的 JS 异常,服务端是收不到的。ELK 这套链路采集的是 Nginx 访问日志,能看到的只有“谁在什么时间请求了什么接口、返回了什么状态码”。如果想把 Vue3 里的 JS 错误也纳入日志平台,需要在前端代码里埋点上报,比如用 fetch 把错误信息 POST 到一个日志接口,Logstash 的httpinput 可以直接接收这种 JSON 上报。作为扩展方向,这篇文章不展开写,但值得知道。

如果 Vue3 项目使用 history 路由模式,Nginx 需要额外处理前端路由的 fallback,否则用户刷新页面时会出现 404:

location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; }

这个配置和日志采集本身没有直接关系,但经常和 Nginx 日志一起被排查到,顺便提一下。

4.4 Filebeat 配置:多来源采集、字段标识与多行合并

Filebeat 是整个采集链路的入口。我创建的/opt/elk/filebeat/config/filebeat.yml完整内容如下:

filebeat.inputs: - type: log enabled: true paths: - /logs/springboot/*.log fields: log_type: springboot fields_under_root: true multiline.pattern: '^\d{4}-\d{2}-\d{2}' multiline.negate: true multiline.match: after multiline.max_lines: 50 - type: log enabled: true paths: - /logs/nginx/access.log fields: log_type: nginx_access fields_under_root: true - type: log enabled: true paths: - /logs/nginx/error.log fields: log_type: nginx_error fields_under_root: true output.logstash: hosts: ["logstash:5044"] setup.template.enabled: false path.data: /usr/share/filebeat/data logging.level: info

这里面multiline配置是最容易被忽略的。SpringBoot 应用抛出异常时,堆栈信息往往是多行文本。如果不做合并,Filebeat 会按行拆成好几条独立事件,Logstash 的 grok 解析也会因为“这一行不是标准日志格式”而失败。multiline.pattern表示“以 ISO 时间格式开头的行是新的日志事件”,negate: truematch: after组合的意思是:不匹配该模式的行,都合并到前一条日志后面。max_lines防止单条日志过长拖垮解析性能。

fields_under_root: true的作用,是把log_type这个字段放在文档根级别,后面 Logstash 可以直接用[fields][log_type]来分流处理不同来源。

4.5 Logstash 配置:grok 解析、时间归一化与索引分类

Logstash 的核心配置文件放在/opt/elk/logstash/pipeline/logstash.conf。我按日志来源把处理逻辑分成了三段:

input { beats { port => 5044 } } filter { if [fields][log_type] == "springboot" { grok { match => { "message" => "%{TIMESTAMP_ISO8601:log_time} %{LOGLEVEL:log_level} %{NUMBER:pid} --- \\[%{NOTSPACE:thread}\\] %{NOTSPACE:logger} : %{GREEDYDATA:msg}" } overwrite => [ "msg" ] } date { match => [ "log_time", "ISO8601" ] target => "@timestamp" } mutate { remove_field => [ "message" ] } } if [fields][log_type] == "nginx_access" { grok { match => { "message" => "%{IPORHOST:clientip} - %{NOTSPACE:remote_user} \\[%{HTTPDATE:nginx_time}\\] \"%{WORD:method} %{URIPATHPARM:request_uri} HTTP/%{NUMBER:http_version}\" %{NUMBER:status} %{NUMBER:bytes} \"%{NOTSPACE:referrer}\" \"%{NOTSPACE:user_agent}\"" } } date { match => [ "nginx_time", "dd/MMM/yyyy:HH:mm:ss Z" ] target => "@timestamp" } mutate { remove_field => [ "message" ] } } } output { if [fields][log_type] == "springboot" { elasticsearch { hosts => [ "http://elasticsearch:9200" ] index => "springboot-app-%{+yyyy.MM.dd}" } } else if [fields][log_type] == "nginx_access" { elasticsearch { hosts => [ "http://elasticsearch:9200" ] index => "nginx-access-%{+yyyy.MM.dd}" } } else { elasticsearch { hosts => [ "http://elasticsearch:9200" ] index => "logging-default-%{+yyyy.MM.dd}" } } }

几个关键细节:

grok 表达式里,SpringBoot 默认日志格式中间有个双空格,那个不能省。我第一次配的时候漏了,导致一整天日志全部解析失败,进 ES 的字段都是原始 message。建议写完之后,到 Kibana 的 Dev Tools 里用 Grok Debugger 调试,拿一条真实日志行测试,能省很多时间。

date插件会覆盖@timestamp,让 ES 里的时间字段以日志本身的时间为准,而不是以 Filebeat 接收时间为准。后面 Kibana 的时间排序依赖的就是这个字段。

Logstash 解析完成后,mutate.remove_field把原始message字段移除,减少索引体积。如果你需要保留原始日志文本,可以不要这一步。

4.6 为什么 Filebeat 不直接输出到 Elasticsearch

很多人会问,Filebeat 本身就支持把数据直接写进 ES,为什么还要中间绕一层 Logstash?我的理由很简单:Logstash 过滤阶段能做的事情比 Filebeat 多得多,grok 解析、JSON 解析、日期归一化、字段拆分、脱敏,这些用 Logstash 处理更顺手,改了配置也方便热加载。如果在 Filebeat 里做,每次调整都要重新读取、重新发送,调试成本更高。对于当前场景,Filebeat 只负责采集,Logstash 负责解析,职责边界很清楚。

5. Kibana 索引配置与日常检索

5.1 创建索引模式:通配符、时间字段与格式化

数据进入 ES 后,Kibana 里要先建索引模式(Index Pattern)才能检索。打开 Management -> Stack Management -> 索引模式,点创建索引模式,索引名称填springboot-app-*,再选择时间字段@timestamp。同理再建一个nginx-access-*

注意:如果没建索引模式,Discover 页面会提示找不到数据。另外,索引模式是支持通配符的,*可以匹配后面的日期后缀。比如写

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询