Logstash 写 OpenSearch 被 WAF 拦截引发的端到端背压
目录
开发环境 qa 和 stg 两个命名空间的服务日志,从某个时刻起不再进 OpenSearch。每个 Pod 里的 filebeat 都停在同一个状态:内存队列积着 4097 条事件,被 Logstash 确认的是 0 条。Logstash 最近 3000 行日志里有 2997 行是同一句:
[2026-10-18T02:35:46,646][ERROR][logstash.outputs.opensearch][main][36ae13…] Encountered a retryable error (will retry with exponential backoff) {:code=>403, :url=>"http://opensearch-dev.example.com:80/_bulk", :content_length=>142026}
返回 403 的是挡在 OpenSearch 前面的 Cloudflare:它的 WAF 按内容拦下了其中两批日志。Logstash 的 OpenSearch 输出插件对非 200 的 _bulk 没有重试上限,被拦的批次原样重发、每次都被拦,后面的事件进不了 Logstash,背压沿着链路传回每一个 filebeat。第一条 403 的日志时间戳是 2026-10-16T21:44:08(UTC),发现时这条链路已经停了将近 29 小时。
文中的版本、配置和数字都来自这套开发环境:filebeat 8.6.1,Logstash 8.9.0(自带 logstash-output-opensearch 2.0.1),后端是巡检和存储审计记录的那台单节点 OpenSearch,Logstash 经 Cloudflare 访问它。域名换成了示意的 opensearch-dev.example.com,账号隐去。
日志从应用到 OpenSearch 经过的四段#
Logback 写 JSON 行"] VOL["emptyDir 卷
/tmp/logs"] FB["filebeat sidecar
内存队列 4096 条"] APP -->|"1. 追加写文件"| VOL VOL -->|"2. 按行读取"| FB end LS["Logstash 单副本
logging 命名空间
内存队列 2 × 125 条"] CF["Cloudflare
WAF 检查请求内容"] OS["单节点 OpenSearch
机房内网"] FB -->|"3. beats 协议,等 ACK"| LS LS -->|"4. HTTP _bulk,要 200"| CF CF -->|"放行的才转发"| OS
第一段在应用 Pod 里。应用用 Logback 的 LogstashEncoder 把每条日志写成一行 JSON 追加到 /tmp/logs/<应用名>.jsonl,单个文件到 500 MB 或跨天滚动,总量上限 17 GB、保留 30 天。/tmp/logs 是一个未设 sizeLimit 的 emptyDir,应用容器与 filebeat sidecar 共同挂载该卷。
第二段是同一个 Pod 里的 filebeat sidecar。配置采用 filestream 输入采集 /tmp/logs/* 并输出至 Logstash,未显式配置 queue,使用默认的 4096 容量内存队列。
第三段是 Logstash,部署在 logging 命名空间下的单副本 Deployment。共计 66 个 Deployment 的 filebeat 均连向其 5044 端口。logstash.yml 中配置工作线程与批次大小:
pipeline.workers: 2
pipeline.batch.size: 125
pipeline.batch.delay: 50
未配置 queue.type,采用默认内存队列,容量上限为 pipeline.workers × pipeline.batch.size(即 250 条)。输出端经由 HTTP 80 端口指向域名并按天写入索引:
output {
opensearch {
hosts => ["opensearch-dev.example.com:80"]
index => "%{service}-%{+YYYY.MM.dd}"
}
}
第四段是 OpenSearch。这台单节点 OpenSearch 位于机房内网,对外仅通过 Cloudflare 暴露:Logstash 经公网域名访问,请求首先经过 Cloudflare WAF 检查,放行后才转发至 OpenSearch。
四段之间都要等确认。filebeat 发出一批事件,等 Logstash 回 ACK 才算发出去;Logstash 的输出发一个 _bulk,等返回 200 才算写成。确认不回来,上一段就不再往下放新数据。
阻塞点:每个 _bulk 都返回 403#
filebeat 每 30 秒在自己的日志里打一条 Non-zero metrics,只列不为 0 的计数。qa 上一个服务的 Pod,最后一次上报里和队列有关的是这几项:
filebeat.events.active 4097
filebeat.harvester.open_files 0
filebeat.harvester.running 0
libbeat.output.events.active 14
libbeat.pipeline.events.active 4097
队列积压 4097 条,在途的 14 条持续未获 ACK,负责读取文件的 harvester 均处于停止状态。这个 Pod 启动后 17 次上报(约 8 分半钟)里,libbeat.output.events.acked 一次都没出现,也就是一条都没被确认。同一个命名空间里另外 20 个带 filebeat 的 Pod,最近 300 行日志里的上报同样没有确认:其中 8 个的队列停在 4098 到 4104 条,其余是日志量小的服务,队列没满,确认数也是 0。stg 的同一个服务积着 4102 条。
Logstash 这边,最近 3000 行日志里有 2997 行是开头那句 403。该容器于 2026-10-16 19:39(UTC)因 OOMKilled 重启,日志从 19:41 开始。在第一条 403 出现前,输出插件的日志记录如下:
2026-10-16T21:16:37 WARN Marking url as dead. Last error: [...HostUnreachableError] OpenSearch Unreachable ...
2026-10-16T21:16:37 WARN Restored connection to OpenSearch instance ...
2026-10-16T21:36:35 WARN Marking url as dead. Last error: [...HostUnreachableError] OpenSearch Unreachable ...
2026-10-16T21:36:37 WARN Restored connection to OpenSearch instance ...
2026-10-16T21:44:08 ERROR Encountered a retryable error (will retry with exponential backoff) {:code=>403, ...}
在 21:16 和 21:36 曾有两次一两秒的短时连接抖动;8 分钟后的 21:44:08 首次出现 403,此后每个 _bulk 均持续返回 403。
403 引发逐级背压的传导过程#
背压从输出端往回传了三段:输出插件卡在重试里,Logstash 的内存队列跟着满了,filebeat 的队列再跟着满。
输出插件对非 200 的 _bulk 没有重试上限#
logstash-output-opensearch 2.0.1 发 _bulk 的方法是 safe_bulk。响应码不是 200 时,HTTP 客户端抛 BadResponseCodeError,对应的 rescue 分支记一条日志,等一段时间,然后无条件 retry(common.rb#L249-L266):
rescue ::LogStash::Outputs::OpenSearch::HttpClient::Pool::BadResponseCodeError => e
@bulk_request_metrics.increment(:failures)
log_hash = {:code => e.response_code, :url => e.url.sanitized.to_s, :content_length => e.request_body.bytesize}
log_hash[:body] = e.response_body if @logger.debug? # Generally this is too verbose
message = "Encountered a retryable error (will retry with exponential backoff)"
# (中间按 429 和其他响应码分别记 debug、error 日志,这里省略)
sleep_interval = sleep_for_interval(sleep_interval)
retry
等待间隔从 retry_initial_interval(默认 2 秒)开始翻倍,封顶 retry_max_interval(默认 64 秒),没有次数上限。Elastic 自家 Elasticsearch 输出插件的文档写的是同一条规则:bulk 请求只认 200,「All other response codes are retried indefinitely」。
另外两个细节让这个 403 更难查。一是响应体只在 DEBUG 级别才记(上面第 4 行),ERROR 日志里只有响应码、地址和请求大小,看不到对方拒绝的理由。二是死信队列(Dead Letter Queue)无法生效:插件只把文档级别的 400 和 404 送进 DLQ(DOC_DLQ_CODES = [400, 404]),整个请求的 403 走的是上面的重试分支,单条文档返回 403 也会被放回重试队列。
Logstash 的内存队列满了,beats 输入停止接收#
pipeline 的两个 worker 都卡在 safe_bulk 的重试里,取走的批次处理不完,250 条的内存队列很快就满了。Logstash 文档对这种情况的说法是「When the queue is full, Logstash puts back pressure on the inputs to stall data flowing into Logstash」:beats 输入不再接收新事件,filebeat 发来的那一批等不到 ACK。
filebeat 的队列停在 4096 条,不再读文件#
filebeat 8.6 的内存队列默认放 4096 条事件。按文档的说法,队列满了以后「no new events can be inserted into the memory queue」,要等输出确认才腾出空间,读文件的 harvester 也就停了。上面指标里的 4097 条和 0 个 harvester 就是这个状态。filebeat 保证至少一次投递,没确认的事件会一直重发,因此链路恢复后,未销毁 Pod 内的文件仍可补发;一旦 Pod 被删除重建,积压日志便无法恢复。
403 来源确认:Cloudflare WAF 拦截特定批次内容#
Logstash 和 OpenSearch 之间隔着 Cloudflare,403 可能来自任何一层。先在 qa 的一个 Pod 里(和 Logstash 走同一个集群出口)不带账号发了三个请求:GET /、请求体为空的 POST /_bulk,以及一个 123 KB、塞满 Hibernate SQL 日志行的 POST /_bulk,大小和 Logstash 被拒的那个请求(142,026 字节)相近:
curl -s -D - -o /dev/null http://opensearch-dev.example.com/
curl -s -D - -o /dev/null -X POST -H 'Content-Type: application/x-ndjson' --data-binary '' http://opensearch-dev.example.com/_bulk
curl -s -D - -o /dev/null -X POST -H 'Content-Type: application/x-ndjson' --data-binary @probe.ndjson http://opensearch-dev.example.com/_bulk
三个请求返回的都是 OpenSearch 安全插件的 401:
HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=UTF-8
www-authenticate: Basic realm="OpenSearch Security"
Server: cloudflare
网络是通的,Cloudflare 放行了这三个请求。Logstash 自己也没有变化,线上的 pipeline 配置(连同账号那两行)和 GitOps 仓库里的一致,Deployment 最近一次发布在 2026-10-08。能够明确拒绝来源的,只有 403 的响应体。Cloudflare 的拦截页是 HTML,OpenSearch 返回的是带 type 的 JSON,权限不足是 security_exception,磁盘超过水位后的只读块是 cluster_block_exception。插件只在 DEBUG 级别记响应体,所以把输出插件的日志级别临时调到 DEBUG。日志器名就是日志里方括号中的 logstash.outputs.opensearch,用 Logstash 的 logging API 改,立即生效,重启后失效:
curl -XPUT 'localhost:9600/_node/logging?pretty' -H 'Content-Type: application/json' \
-d '{"logger.logstash.outputs.opensearch": "DEBUG"}'
# 验证完毕后恢复默认级别
curl -XPUT 'localhost:9600/_node/logging/reset?pretty'
下一次重试记下的响应体是 Cloudflare 的拦截页(节选,去掉了 HTML 标签):
{:code=>403, :url=>"http://opensearch-dev.example.com:80/_bulk", :content_length=>13810, :body=>"<!DOCTYPE html> …
Attention Required! | Cloudflare
Sorry, you have been blocked
This website is using a security service to protect itself from online attacks. The action you just performed triggered the security solution. …
Cloudflare Ray ID: a4c45706cf03c087
拒绝写入的是 Cloudflare 的 WAF,它按请求内容判断。三个探测请求能过,是因为内容里没有触发规则的东西,卡住的批次里有。两个 worker 各卡着一批(请求体分别是 142,026 和 13,810 字节),插件每次重发的都是同一批,所以每次都被拦,403 从第一条起就再没停过。首次拦截出现在 21:44:可能是此时首次产生了匹配规则的日志,或是 Cloudflare 此时更新了防护规则,两种情况在现象上相同。具体是哪条规则,可以在 Cloudflare 的 Security Events 里按 Ray ID 筛选,不过那里的数据是抽样的,不一定查得到。
该判断随后得到了印证。二次调整 DEBUG 级别约半分钟后,Logstash 因内存耗尽被 OOMKilled 重启(内存配置见下文),内存中阻塞的两个批次随之丢弃。重启后的一分钟内,后续 171 个 _bulk 请求全部返回 200,证实被拦截的仅为那两批特定内容。
故障暴露的架构风险#
这次停摆虽发生在开发环境,但整条链路的薄弱点在生产环境中同样存在:
一是故障隐蔽性高,业务无感且常规告警失效。应用只向本地文件追加写入,日志后端受阻不会反压业务线程;停摆期间服务正常响应,甚至完成了多次发布。而基于错误日志条数配置的告警在日志中断时计数恒为 0,反而被误判为「无错误」,导致整条链路停滞近 29 小时才被人工察觉。
二是 Pod 重建会导致未发送的日志永久丢失。emptyDir 的生命周期与 Pod 绑定,Pod 一旦销毁重建,卷内数据便彻底丢失。filebeat 的至少一次投递仅在本地文件尚存时成立。停摆期间 qa 上一次常规发布替换掉了旧 Pod,旧 Pod 运行期间产生的 10 余小时日志已无法追回;在节点排空或弹性缩容频繁的生产环境,数据丢失的风险会被进一步放大。
三是恢复时的补写尖峰与单点脆弱性。阻塞解除后,各 Pod 的 filebeat 会从积压位置并发补发,所有流量瞬间倾泻至单副本 Logstash。生产 OpenSearch 熔断那次,恢复后一条 Logstash 管道曾测得 +45% 的补写尖峰;若补写数据中再次包含触发 WAF 规则的内容,链路会立刻二次卡死。同时,单副本 Logstash 承载了 66 个 Deployment 的采集流,且容器 1Gi 限制与镜像默认的 -Xms1g -Xmx1g 堆配置存在冲突,多次因内存触顶被 OOMKilled,成为明显的全局单点。
故障恢复与链路加固#
处置过程分为应急与加固两阶段:先解除写入阻断,再补齐分钟级发现能力并降低丢日志风险。
在 Cloudflare 配置 WAF 跳过规则#
拦截既然由 Cloudflare 产生,最直接的解法是在 Cloudflare 端配置放行。添加一条 WAF 自定义规则,匹配 OpenSearch 那个域名的 /_bulk、来源是集群出口 IP 的请求,动作选 Skip,跳过托管规则(Managed Rules 在 Skip 可以跳过的范围里)。规则加上之前,重启 Logstash 能临时疏通,代价是卡住的批次(每个 worker 一批,最多 250 条)会丢,其余积压继续往下走,直到下一批触发规则的日志。更彻底的做法是日志写入不经过公网的 WAF,但这台 OpenSearch 在机房内网,集群和它之间没有直连的网络,要先打通内网路由。写入恢复以后,确认三件事:Logstash 日志里的 403 消失,filebeat 的 acked 重新增长,各个索引里最新文档的时间开始往前走。
基于最新日志写入延迟配置端到端告警#
组件各自都在运行,链路照样可以是停的:这次 filebeat 和 Logstash 都是 Running,OpenSearch 也在正常响应请求。最直接的信号是每类索引里最新一条文档的时间,超过阈值(按各服务平时的日志频率定,比如 15 分钟)没有新文档就告警。日志量小的服务可以每分钟打一条心跳日志,把「链路断了」和「这段时间没有事件」区分开。
链路中间的两个点也可以单独盯。filebeat 这边,libbeat.output.events.acked 连续几个周期不增长,或者 libbeat.pipeline.events.active 一直贴着队列容量。Logstash 这边,_node/stats/pipelines 里输出插件的 bulk_requests.failures 在涨,输出的事件数不再增长。
启用 Logstash 磁盘持久化队列#
把 queue.type 改成 persisted,队列从内存里的 250 条变成磁盘上的文件(queue.max_bytes 默认 1 GB),放在 PVC 上,Logstash 重启也不会丢。写入受阻时,Logstash 仍可在队列填满前持续接收事件,filebeat 照常投递以尽快将日志移出 Pod,缓冲时长取决于日志产生速率与 queue.max_bytes 配额。
若要彻底避免 Pod 重建导致日志丢失,必须在 Pod 外部建立具备足够保留周期的缓冲层。常见做法是在采集端和 Logstash 之间加一层 Kafka,按天保留:只要 Kafka 能写,日志就会及时离开 Pod;OpenSearch 停多久,日志就在 Kafka 里等多久,前提是不超过保留期。
收敛 JVM 参数并扩展为双副本#
容器保持 1Gi 内存限制不变,将 LS_JAVA_OPTS 显式设为 -Xms512m -Xmx512m -XX:MaxDirectMemorySize=256m -XX:+ExitOnOutOfMemoryError:堆分配 512m,直接内存 256m,最坏情况下总内存约 0.95 GiB,安全控制在配额内;若发生 OOM 则直接退出由 Kubernetes 重启。
同时将 Logstash 扩展为至少两个副本,单副本重启时 filebeat 可自动重连至另一副本;生产与非生产环境实行采集链路物理隔离,防止局部故障扩散。
小结#
从 2026-10-16 21:44(UTC)起,Cloudflare WAF 根据请求内容拦截了 Logstash 写入 OpenSearch 的两批 _bulk 并返回 403。logstash-output-opensearch 2.0.1 对非 200 响应无限重试同一批次,且仅在 DEBUG 级别记录响应体;两个 pipeline worker 均陷入重试,250 条容量的内存队列占满,beats 输入停止接收,导致 66 个 Deployment 的 filebeat 队列相继饱和。qa 与 stg 的服务日志滞留在各 Pod 的 emptyDir 中近 29 小时,期间因发布重建的 Pod 日志永久丢失。重启 Logstash 丢弃阻塞批次虽可临时恢复写入,但积压中若再次包含触发规则的内容仍会导致阻塞。彻底解决需在 Cloudflare 端配置 WAF 跳过规则,配套端到端新鲜度告警,并收敛 Logstash JVM 内存规格。
采集链路的每一跳都等下一跳确认,最末端拒绝写入,最前端就会停止读取。判断这条链路是否健康,看每类索引里最新一条日志是多久之前写进去的,比看每个组件是否 Running 可靠。
中间隔有 CDN 时,遇到 403 应当先根据响应体确认拦截来源,再排查后端服务。
参考资料#
- Filebeat 8.6:Configure the internal queue:
queue.mem.events默认 4096,队列满了以后不再接收新事件 - Filebeat 8.6:How Filebeat works:至少一次投递,没确认的事件会一直重发
- common.rb @ logstash-output-opensearch 2.0.1:
safe_bulk对BadResponseCodeError无条件重试;文档级别的状态码里只有 400 和 404 进 DLQ,其余失败放回重试队列 - api_configs.rb @ logstash-output-opensearch 2.0.1:
retry_initial_interval默认 2 秒,retry_max_interval默认 64 秒 - Logstash 8.9:Elasticsearch output plugin:Retry Policy 一节,bulk 请求只认 200,其余响应码无限重试
- Logstash 8.9:Memory queue:容量上限是
pipeline.workers乘pipeline.batch.size,队列满了对输入施加背压 - Logstash 8.9:Persistent queues:
queue.type: persisted,queue.max_bytes默认 1 GB,队列满时 beats 输入不再接收新连接 - Logstash 8.9:Logging:
_node/logging在运行时改日志级别,重启后失效 - Logstash 8.9:Beats input plugin:Memory usage 一节,直接内存默认和堆一样大,建议设成堆的一半
- Logstash 8.9:JVM settings:
Xms和Xmx设成一样,LS_JAVA_OPTS覆盖jvm.options里的同名选项 - Error Log: AuthorizationException: User lacks permissions:权限不足时 OpenSearch 返回 403 和
security_exception - Error Log: ClusterBlockException, the index read-only error:磁盘超过水位后的只读块返回
cluster_block_exception - Cloudflare:Configure a rule with the Skip action:自定义规则的 Skip 动作可以跳过 Managed Rules
- Cloudflare Ray ID:在 Security Events 里按 Ray ID 筛选,数据可能是抽样的
- Kubernetes:Volumes:Pod 从节点上移走时
emptyDir的数据永久删除,sizeLimit从节点临时存储里分配
相关文章#
- 开发环境单节点 OpenSearch 巡检:同一台 OpenSearch,Alerting 里没有基础设施监视器
- OpenSearch 日志索引的存储审计:这三个环境的日志量和索引族
- 按 _id 排序导致的 OpenSearch parent 断路器熔断:生产 OpenSearch 恢复后,Logstash 管道的补写尖峰
- Java 微服务在 K8s 上的运行时基线:文件加 sidecar 和 stdout 加 DaemonSet 两种采集方式