ELK日志链路六关卡排查:从Filebeat到Kibana的可观测性闭环

发布时间:2026/10/21 18:29:59
ELK日志链路六关卡排查:从Filebeat到Kibana的可观测性闭环
1. 项目概述这不是“点开就能看”的ELK而是一套需要亲手调通的可观测性闭环“ELK 查看”这四个字在运维、开发、测试甚至产品同学的日常沟通里出现频率高得有点反常。它不像“重启服务”那样有明确动作也不像“查数据库”那样指向清晰路径——它更像一句暗号背后藏着一整套数据采集、传输、存储、检索、可视化的完整链路。我第一次被拉进一个线上事故复盘会听到某位同事说“快去ELK查看下凌晨三点的日志”当场愣了三秒ELK不是个软件怎么“查看”还需要专门强调后来才明白这短短四个字实际是在问“日志有没有成功打到LogstashElasticsearch集群健康状态是否GreenKibana仪表板的筛选条件有没有误配成UTC时区索引模板里的date字段映射类型是不是被写成了text”——每一个环节卡住都会让“查看”变成“查不到”。这个标题没有提版本、没说场景、也没限定角色恰恰说明它已下沉为基础设施级的通用能力。它适合三类人刚接手线上系统的新人需要快速定位报错堆栈、负责稳定性建设的SRE要验证日志链路SLA、以及做用户行为分析的产品/运营依赖Kibana做漏斗转化。但现实是90%的人第一次配置完ELK后打开Kibana看到“No results found”第一反应是“是不是没日志”却很少想到可能是Filebeat采集路径写错了斜杠方向或是Elasticsearch的index pattern里时间字段名拼写少了个下划线。这篇内容不讲抽象概念不列官方文档目录只聚焦一件事当你在浏览器地址栏输入Kibana地址、点开Discover页、输入查询语句后为什么能看到或看不到那条关键日志我把整个链路拆成可触摸的齿轮每个齿轮的咬合松紧度、转速偏差、润滑状态都给你标清楚。2. 内容整体设计与思路拆解为什么必须从“日志落地”开始逆向排查很多人习惯正向搭建先装Elasticsearch再配Logstash最后上Kibana。结果是Kibana界面一切正常但搜索任何关键词都返回空。这种“表面通畅、内里断流”的问题根源在于ELK不是单体应用而是由四个独立进程协同工作的分布式系统Filebeat或Logstash agent在应用服务器上采集日志Logstash可选做过滤转换Elasticsearch集群存储并提供检索Kibana作为前端展示。它们之间靠网络、协议、配置三重纽带连接任一环节松动数据就掉链子。我见过最典型的误判案例某次支付失败开发说“日志里没看到错误码”运维查Kibana发现对应时间段无数据于是双方互相质疑“你们代码没打日志”“你们Kibana没配对”。三天后才发现Filebeat的harvester配置里close_inactive: 5m参数导致大文件被提前关闭而支付日志恰好写在单个超大滚动文件里Filebeat只读了前半部分就停了。所以我的排查逻辑永远是逆向的从Kibana的搜索结果倒推——如果没数据先确认Elasticsearch里索引是否存在、文档数是否为0如果是0再查Logstash输出是否成功写入ES如果Logstash日志显示“connection refused”才去检查ES集群端口和防火墙。这种逆向思维省去了80%的无效操作因为Kibana只是“镜子”它照不出问题只能告诉你“镜子里没有东西”而东西在哪丢的得顺着数据流向往回找。这套思路的核心在于区分“功能可用”和“数据可达”。Kibana能打开、ES能curl通、Logstash进程在跑——这些只是功能可用而“数据可达”意味着应用日志文件被Filebeat正确识别→Filebeat将日志行按行发送→Logstash接收到并完成grok解析→ES成功创建文档并建立倒排索引→Kibana的index pattern能匹配到该索引→时间过滤器范围覆盖日志时间戳。这六个环节缺一不可。我在某次跨机房部署中就因ES集群的discovery.seed_hosts配置漏写了新节点IP导致Logstash写入时随机路由到未加入集群的节点报错NoNodeAvailableException但Kibana依然能访问旧索引——这就是典型的功能可用但数据不可达。所以本篇所有实操步骤都围绕这六个环节的验证点展开而不是教你怎么点鼠标。3. 核心细节解析与实操要点从Filebeat到Kibana的六道关卡详解3.1 第一道关卡Filebeat是否真正“看见”了你的日志文件Filebeat不是简单地tail -f它通过registry文件记录每个日志文件的读取位置inode offset避免重复采集或遗漏。但这个机制在某些场景下会失效。比如应用使用logrotate滚动日志时若配置为copytruncate先拷贝再清空原文件原文件inode不变但内容被清空Filebeat会继续从上次offset读结果读到一堆空行若配置为create新建文件新文件inode变化Filebeat能识别但旧文件若未被及时清理registry里仍存着它的记录可能造成磁盘空间缓慢增长。实操验证方法很简单登录应用服务器执行filebeat test config检查配置语法再运行filebeat test output确认能连通Logstash或ES注意此命令只测网络连通性不验证数据传输。但最关键的一步是看Filebeat自己的日志——默认在/var/log/filebeat/filebeat搜索Harvester started它后面会跟上具体文件路径。如果没看到你期望的日志文件路径说明Filebeat根本没扫描到它。常见原因有三个一是paths配置用了相对路径而Filebeat工作目录不是你预期的二是权限问题Filebeat用户默认是filebeat对日志目录没有read权限三是exclude_files正则误杀了目标文件。我曾遇到过exclude_files: [\.gz$]把app.log.2023-10-01.gz排除了但开发又配置了app.log.*结果所有压缩日志全被跳过。提示用filebeat ps命令可查看当前正在采集的文件列表比翻日志更直观。如果列表为空立刻检查filebeat.yml中的filebeat.inputs配置块确认type: filestream新版推荐或type: log旧版是否启用且enabled: true。3.2 第二道关卡Logstash的管道是否“消化”了传来的数据Logstash的核心是input → filter → output三段式管道。很多人的配置里filter部分空着以为“原始日志直接入库就行”结果在Kibana里搜索error时发现所有日志都混在message字段里无法按level、service_name等字段筛选。这是因为未经grok解析的日志ES只会把它当纯文本塞进message而message字段默认是analyzed类型分词后搜索error会匹配到errors、warning甚至terror——完全不可控。一个生产环境可用的最小化filter示例filter { if [message] ~ /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) \[(\w)\] (.)$/ { grok { match { message ^%{TIMESTAMP_ISO8601:timestamp} \[%{LOGLEVEL:level}\] %{GREEDYDATA:content}$ } remove_field [message] } } else { mutate { add_field { level UNKNOWN } add_field { content %{message} } remove_field [message] } } }这段代码做了三件事用正则匹配标准日志头如2023-10-01 14:22:33,456 [ERROR] ...用grok提取时间、级别、内容三部分对不匹配的日志兜底处理避免整条丢弃。注意remove_field [message]很重要否则ES里会同时存在message和content两个字段既浪费存储又干扰搜索。注意Logstash启动后务必检查其日志中是否有Pipeline started字样以及output插件是否报告Successfully sent x events。如果看到大量Could not index event大概率是ES的mapping冲突——比如某个字段第一次被grok解析为string第二次又被其他日志写成numberES会拒绝写入。此时需删除对应索引curl -X DELETE http://es:9200/logstash-*并重建或在ES模板里预设字段类型。3.3 第三道关卡Elasticsearch索引是否按预期“接住”了数据ES不是被动接收数据的“桶”它会根据索引名、文档结构、mapping规则主动决定如何存储。新手最容易踩的坑是索引名配置错误。Filebeat默认输出到filebeat-%{yyyy.MM.dd}Logstash默认是logstash-%{yyyy.MM.dd}而Kibana的index pattern如果配成logs-*自然搜不到。更隐蔽的是时间戳问题Logstash的timestamp字段默认是Logstash收到事件的时间而非日志里的时间。如果应用日志写的是2023-10-01 02:00:00而服务器时区是UTC8Logstash又没做时区转换那么这条日志在ES里会被存到logstash-2023.10.01索引但Kibana的时间过滤器默认用浏览器本地时区比如UTC8搜索2023-10-01 02:00:00时ES实际查的是2023-09-30 18:00:00 UTC结果当然是空。验证索引状态的命令链非常实用# 查看所有索引及文档数 curl http://localhost:9200/_cat/indices?v # 查看某个索引的mapping确认字段类型 curl http://localhost:9200/logstash-2023.10.01/_mapping?pretty # 随机取一条文档看原始结构 curl http://localhost:9200/logstash-2023.10.01/_search?size1pretty如果_cat/indices里文档数为0但Logstash日志显示发送成功就要怀疑ES的index设置比如action.auto_create_index是否为false禁止自动建索引或者index_patterns是否限制了索引名格式。我在线上环境就因ES安全插件启用了indices:admin/create权限控制导致Logstash无权创建新索引所有数据静默丢失。3.4 第四道关卡Kibana的index pattern是否“认出”了索引Kibana的index pattern不是简单的通配符匹配它包含两层逻辑一是字符串匹配如logstash-*匹配logstash-2023.10.01二是时间字段绑定必须指定哪个字段代表时间如timestamp。如果时间字段选错Kibana的时间过滤器就失效。比如你把log_time字段设为时间字段但实际日志里这个字段是字符串格式2023-10-01 02:00:00而ES mapping里它又是text类型Kibana就无法解析时间滑块会变灰。创建index pattern的正确姿势是在Kibana Management → Stack Management → Index Patterns → Create index pattern输入pattern名如logstash-*点击Next step然后在“Time field”下拉框里选择一个date类型的字段。如果下拉框为空说明ES里没有date类型字段——要么Logstash没解析出时间要么ES mapping没设对。此时不要强行创建先回ES查mapping。另外index pattern创建后Kibana会自动加载该索引下的字段列表如果列表里全是keyword、text没有date基本可以断定时间字段没生效。提示Kibana的Discover页右上角有个“Refresh fields”按钮当ES里新增了字段比如Logstash新加了一个service_id字段但Kibana没显示时点它即可重新加载无需重启Kibana。3.5 第五道关卡Discover页的搜索语法是否“精准命中”了字段Kibana的搜索框支持Lucene语法但新手常把它当SQL用。比如想查level为ERROR的日志输入level: ERROR是对的但输入SELECT * FROM logs WHERE level ERROR就完全无效。更常见的是字段名大小写问题ES默认字段名小写但有些Logstash配置里用了mutate { add_field { Level %{level} } }结果生成了大写的Level字段而你在Kibana里搜level: ERROR自然找不到。Lucene语法的几个关键点必须记牢字段搜索field_name: value冒号前后不能有空格多条件level: ERROR AND service_name: paymentAND必须大写模糊搜索message: timeout*星号通配短语搜索message: Connection refused加英文双引号范围搜索timestamp: [2023-10-01T00:00:00Z TO 2023-10-01T01:00:00Z]如果搜索无结果先点搜索框右边的“Inspect”按钮它会显示Kibana实际发给ES的查询DSL。复制这个JSON用curl直接调ES接口对比返回结果。如果curl有数据而Kibana没有问题一定在Kibana前端比如时间范围没选对如果curl也为空说明ES里真没数据或者查询条件写错了。3.6 第六道关卡浏览器与服务器的时区是否“严丝合缝”这是最让人抓狂的隐形杀手。ES内部所有时间都以UTC存储Kibana前端显示时会根据浏览器时区自动转换。但Logstash的timestamp如果没做时区处理就会产生错位。举个真实案例某应用服务器在东京UTC9日志写2023-10-01 09:00:00Logstash默认用服务器本地时间生成timestamp即2023-10-01T09:00:000900存入ES后转为UTC2023-10-01T00:00:00Z。而Kibana用户在北京UTC8浏览器时区是0800Kibana显示时间为2023-10-01 08:00:00比实际日志时间早1小时。用户搜索2023-10-01 09:00:00Kibana会查UTC2023-10-01T01:00:00Z自然找不到。解决方案只有两个一是在Logstash里统一转为UTC用datefilterfilter { date { match [ log_time, yyyy-MM-dd HH:mm:ss.SSS ] timezone Asia/Shanghai target timestamp } }二是Kibana全局设置时区Management → Stack Management → Kibana → Advanced Settings →dateFormat:tz设为Asia/Shanghai。但后者只影响显示不影响查询范围所以强烈推荐前者——让数据源头就标准化。4. 实操过程与核心环节实现一次完整的“从零到可见”全流程演示4.1 环境准备用Docker Compose快速搭建最小可用集群为了不被环境差异干扰我推荐用Docker Compose一键拉起ELK 8.x当前最新稳定版。以下docker-compose.yml经过生产环境简化去掉了X-Pack安全认证如需开启后续可单独配置version: 3.7 services: elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:8.11.3 container_name: elasticsearch environment: - discovery.typesingle-node - ES_JAVA_OPTS-Xms1g -Xmx1g - xpack.security.enabledfalse - xpack.monitoring.collection.enabledtrue ports: - 9200:9200 - 9300:9300 networks: - elk logstash: image: docker.elastic.co/logstash/logstash:8.11.3 container_name: logstash volumes: - ./logstash/pipeline:/usr/share/logstash/pipeline - ./logstash/config:/usr/share/logstash/config environment: - xpack.security.enabledfalse depends_on: - elasticsearch ports: - 5044:5044 networks: - elk kibana: image: docker.elastic.co/kibana/kibana:8.11.3 container_name: kibana environment: - ELASTICSEARCH_HOSTShttp://elasticsearch:9200 - xpack.security.enabledfalse depends_on: - elasticsearch ports: - 5601:5601 networks: - elk networks: elk: driver: bridge启动命令只需一行docker-compose up -d。等待约1分钟执行curl http://localhost:9200/_cat/health?v看到green状态即表示ES集群就绪。注意ELK 8.x默认启用TLS和安全认证这里通过xpack.security.enabledfalse关闭是为了降低初学者门槛。生产环境必须开启并配置证书。4.2 日志源模拟用Python脚本生成带时间戳的标准日志与其依赖真实应用不如自己造数据。以下脚本每秒生成一条模拟日志格式严格匹配Logstash的grok解析规则import time import random import logging from datetime import datetime # 配置日志格式2023-10-01 14:22:33,456 [INFO] User login success logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, datefmt%Y-%m-%d %H:%M:%S,%f[:-3], # 去掉微秒后三位保留毫秒 handlers[logging.FileHandler(/tmp/app.log, encodingutf-8)] ) levels [INFO, WARN, ERROR] messages [ User login success, Database connection timeout, Cache miss for product id 123, Payment processed successfully ] while True: level random.choice(levels) msg random.choice(messages) if level ERROR: logging.error(msg) elif level WARN: logging.warning(msg) else: logging.info(msg) time.sleep(1)将此脚本保存为gen_log.py在宿主机运行python gen_log.py。它会在/tmp/app.log持续写入日志。注意Docker容器内路径与宿主机不同Filebeat需挂载该目录。4.3 Filebeat配置采集/tmp/app.log并直连Logstash创建filebeat.yml关键配置如下filebeat.inputs: - type: filestream enabled: true paths: - /tmp/app.log fields: service_name: demo-app fields_under_root: true output.logstash: hosts: [localhost:5044] ssl.enabled: false setup.template.settings: index.number_of_shards: 1这里fields_under_root: true很重要它让service_name字段直接成为文档顶层字段而非嵌套在fields对象里方便Kibana直接使用。启动Filebeat./filebeat -e -c filebeat.yml。观察其日志应看到Harvester started for file: /tmp/app.log。4.4 Logstash管道配置解析日志并注入ES在./logstash/pipeline/目录下创建main.confinput { beats { port 5044 } } filter { # 解析标准日志时间戳 grok { match { message ^%{TIMESTAMP_ISO8601:log_time} \[%{LOGLEVEL:level}\] %{GREEDYDATA:content}$ } } # 将log_time字符串转为date类型 date { match [ log_time, yyyy-MM-dd HH:mm:ss,SSS ] target timestamp } # 移除冗余字段 mutate { remove_field [log_time, message, host, agent] } } output { elasticsearch { hosts [http://elasticsearch:9200] index logstash-%{YYYY.MM.dd} } }Logstash会自动加载pipeline/目录下的所有.conf文件。启动Logstash后检查其日志是否有Pipeline started和Successfully sent。4.5 Kibana配置创建index pattern并验证搜索打开http://localhost:5601首次进入会引导创建index pattern。输入logstash-*下一步选择timestamp为时间字段完成创建。进入Discover页顶部时间范围选“Last 15 minutes”搜索框输入level: ERROR应立即看到红色错误日志。点击某条日志右侧的箭头展开全部字段确认service_name、level、content等字段均存在且值正确。实操心得如果搜索level: ERROR无结果但message: ERROR有结果说明grok解析失败level字段没生成。此时回到Logstash日志搜索Grok parsing failed定位哪条日志格式异常调整grok正则。我常用grokconstructor.app在线工具调试正则粘贴日志样本实时看匹配结果。5. 常见问题与排查技巧实录那些让我熬夜到凌晨三点的坑5.1 “Kibana显示No results found但ES里明明有数据”这个问题我遇到过至少七次每次原因都不同。最经典的案例是Kibana的index pattern时间字段选错了。有一次开发在Logstash里新加了一个event_time字段类型是date但忘记在Kibana里更新index pattern仍用旧的timestamp。结果Kibana的时间过滤器查的是timestamp范围而新日志的timestamp是Logstash接收时间精确到秒event_time才是业务时间精确到毫秒两者相差几秒导致搜索窗口错位。排查步骤在Kibana Discover页点击右上角“Inspect” → “View in Console”复制DSL查询用curl调EScurl -X POST http://localhost:9200/logstash-*/_search -H Content-Type: application/json -d {query:{...}}对比返回结果如果curl有数据而Kibana没有说明Kibana前端问题如果curl也为空检查DSL里的range时间范围是否合理。独家技巧在Kibana Console里直接执行GET /logstash-*/_search?qlevel:ERROR这是最简查询绕过所有前端逻辑能快速定位是数据问题还是界面问题。5.2 “Filebeat采集了Logstash也收到了但ES索引文档数为0”这种情况90%是ES的mapping conflict。比如Logstash第一次解析出response_time为数字ES自动创建mapping为long类型第二次某条日志里response_time是字符串N/AES拒绝写入报错illegal_argument_exception。Logstash日志里会显示Could not index event to Elasticsearch但默认不打印具体错误。解决方法查Logstash日志找到Could not index event行向上翻看是否有Caused by堆栈如果没看到临时修改Logstash配置在output里加stdout { codec rubydebug }重启后看控制台输出的完整错误确认是mapping conflict后删除问题索引curl -X DELETE http://localhost:9200/logstash-2023.10.01在Logstash filter里加兜底if ![response_time] or [response_time] N/A { mutate { add_field { response_time 0 } } }。5.3 “搜索关键词能匹配但高亮显示不全或错位”Lucene的高亮highlight功能依赖字段的analyzer。如果message字段用的是standardanalyzer默认它会把user-login-success分词为user、login、success搜索login时高亮login没问题但如果字段是keyword类型不分词搜索login就完全匹配不上。更麻烦的是如果日志里有HTML标签如diverror/divstandardanalyzer会把error提取出来但高亮时会把div也包进去导致页面显示乱码。解决方案是自定义analyzerPUT /logstash-2023.10.01 { mappings: { properties: { content: { type: text, analyzer: my_analyzer } } }, settings: { analysis: { analyzer: { my_analyzer: { tokenizer: standard, filter: [lowercase, stop] } } } } }然后在Logstash output里指定template确保新索引用此mapping。5.4 “Kibana仪表板图表数据为空但Discover页能搜到”这通常是因为仪表板使用的index pattern与Discover页不同。比如Discover页用的是logstash-*而仪表板用的是metrics-*。或者仪表板里某个可视化组件设置了错误的time field比如选了timestamp但数据里这个字段是null。排查方法进入该可视化组件编辑页点击右上角“Inspect” → “Visualize request”看请求DSL里的index_pattern和time_field对比Discover页的index pattern设置确保一致在Discover页用相同时间范围搜索确认数据存在。注意事项Kibana 8.x的“Saved Objects”导出功能有时会漏掉index pattern关联导致导入到新环境后仪表板无法加载。导出前务必勾选“Include dependent objects”。5.5 “Filebeat重启后历史日志被重复采集”这是Filebeat registry机制的经典问题。registry文件记录了每个文件的读取位置如果Filebeat配置变更如paths路径改了或registry文件被手动删除Filebeat会重新扫描所有文件导致重复。更隐蔽的是当Filebeat升级大版本如6.x到7.xregistry格式不兼容也会触发全量重采。预防措施不要手动删除/var/lib/filebeat/registry文件升级Filebeat前备份registrycp /var/lib/filebeat/registry /tmp/registry.bak如果已发生重复用ES的delete_by_queryAPI清理POST /logstash-*/_delete_by_query { query: { range: { timestamp: { gte: 2023-10-01T00:00:00Z, lt: 2023-10-01T01:00:00Z } } } }注意此操作不可逆执行前务必确认时间范围。6. 工具链与效率提升让“ELK查看”从耗时操作变成肌肉记忆6.1 必备命令行工具脱离GUI的快速诊断图形界面很友好但故障时刻往往连Kibana都打不开。掌握这几个命令能让你在SSH里5分钟定位90%的问题curl -s http://localhost:9200/_cat/health?vES集群健康状态green/yellow/redcurl -s http://localhost:9200/_cat/indices?vsdocs.count:desc按文档数排序索引一眼看出哪个索引暴涨curl -s http://localhost:9200/_nodes/stats?pretty | grep -A 5 jvm.memory查看ES JVM内存使用判断是否OOMjournalctl -u filebeat -n 50 --no-pager查看Filebeat最近50行日志systemd环境docker logs -f logstash | grep -i error\|exception实时跟踪Logstash错误我习惯把这些命令写成别名放在~/.bashrc里alias eshealthcurl -s http://localhost:9200/_cat/health?v alias esindicescurl -s http://localhost:9200/_cat/indices?vsdocs.count:desc alias lserrordocker logs -f logstash | grep -i error\|exception每天早上上班第一件事就是敲eshealth esindices就像医生查血压一样自然。6.2 日志采样与流量控制避免ELK被日志洪峰冲垮生产环境最怕“日志雪崩”某个bug导致应用每秒打10万行DEBUG日志Filebeat全量采集Logstash CPU飙到100%ES写入延迟激增最终整个链路瘫痪。我在某次大促前压测就遇到过支付服务日志量突增20倍Kibana响应时间从200ms涨到15秒。解决方案是分层限流Filebeat层用max_bytes和multiline.pattern控制单条日志大小避免超长堆栈占满bufferLogstash层用throttlefilter限制每秒处理事件数filter { throttle { before_count 1000 after_count 10000 period 60 max_age 3600 add_tag [throttled] } }ES层设置index.refresh_interval: 30s默认1s降低实时性换写入性能用index.translog.durability: async异步刷盘。实操心得在非核心服务如后台任务的日志采集里直接用Filebeat的drop_event_if丢弃DEBUG日志if [level] DEBUG { drop {} }。这比Logstash过滤更前置节省网络和CPU。6.3 自动化巡检脚本把经验固化为代码人工检查太慢我写了一个Python脚本每天凌晨自动运行生成ELK健康报告import requests import json from datetime import datetime, timedelta def check_es_health(): r requests.get(http://localhost:9200/_cat/health?v) return green in r.text def check_logstash_output(): # 检查Logstash最近1分钟是否有成功发送 r requests.get(http://localhost:9600/_node/stats/pipelines?pretty) stats r.json() for pipeline in stats[pipelines].values(): if pipeline[events][out] 0: return True return False def generate_report(): report { timestamp: datetime.now().isoformat(), es_healthy: check_es_health(), logstash_active: check_logstash_output(), kibana_accessible: requests.get(http://localhost:5601/api/status).status_code 200 } with open(f/var/log/elk-report-{datetime.now().strftime(%Y%m%d)}.json, w) as f: json.dump(report, f, indent2) if __name__ __main__: generate_report()配合crontab每天执行报告存档后故障复盘时直接看历史记录比翻日志快十倍。7. 性能调优与容量规划当“查看”变成高频刚需时的必修课7.1 索引生命周期管理ILM告别手动删索引日志数据天然有时效性但很多人还用curl -X DELETE手动删旧索引既容易误操作又无法自动化。ES 7.x引入的ILM是标准解法。以Logstash索引为例创建策略PUT /_ilm/policy/logstash_policy { policy: { phases: { hot: { min_age: 0ms, actions: { rollover: { max_size: 50gb, max_age: 30d } } }, delete: { min_age: 90d, actions: { delete: {} } } }