系统运行起来就是一个黑盒,需要有个"黑匣子"来记录整个系统的运行状态。现代企业生成的日志数据量又在呈指数级增长,如何存储和管理这个黑匣子成为了现在的挑战。Elasticsearch(ES)作为一个强大的分布式搜索和分析引擎,提供了高效的日志存储解决方案。通过将日志数据存储在 Elasticsearch 中,企业能够实时处理和分析大量的日志信息,从而快速识别问题、优化性能并增强安全性。
什么是日志?
日志是系统在运行过程中生成的记录,包含了系统的运行状态、用户行为、错误信息等重要数据。它们不仅是故障排查的依据,也是安全审计的重要工具。
日志可以分为多种类型,最常见的包括:
- 访问日志:记录用户访问系统的情况,通常用于分析用户行为。
- 错误日志:记录系统运行中的错误信息,帮助开发人员快速定位问题。
- 审计日志:记录系统的安全事件,确保合规性和安全性。
环境准备
在实验之前,需要先安装好ES 以及通过 Kibana 查看日志(或者创建索引等等)。
安装 ES
sh
docker run -d --name elasticsearch \
-e "discovery.type=single-node" \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
-p 9200:9200 \
docker.elastic.co/elasticsearch/elasticsearch:7.17.0
安装 Kibana
bash
docker run -d --name kibana \
--link elasticsearch:elasticsearch \
-e "ELASTICSEARCH_HOSTS=http://elasticsearch:9200" \
-p 5601:5601 \
docker.elastic.co/kibana/kibana:7.17.0
日志如何存储
搭建好 ES 后,我们就可以向 ES 中写入日志了,我们面临的第一个问题是:写到哪里?
索引
ES 就是一个图书馆,你在图书馆中查找书籍。图书馆的书籍被组织在不同的书架上,这个书架就是"索引"(也就是说我们将日志写到 ES 里其实是写到 ES 的某个索引里)。那么我们如何命名这个书架才更好查日志呢?
最常用的方法论是: 功能 + 时间
, 比如我们想要记录系统的一些敏感操作用于审计,可以命名为 audit_logs_20241214
(如果当天创建索引)。audit_logs
明确表示是审计日志相关,后面跟上日期能方便按时间范围进行数据管理与查询。这样在后续需要查看特定日期的审计信息时,可以直接定位到相应索引,这样的索引小,查询性能也高。
但并不是所有的索引名都需要带上时间,一些稳定的数据比如配置数据或者商品数据,或者在你的场景中你觉得没什么实际好处时也可以直接命名比如 product_catalog
。
索引配置
常见索引配置: 字段 Mapping
索引建立后,我们需要对索引进行一些配置,最常见的就是 Mapping 字段,假设我们的日志上报时有个 age
字段,那么这个字段默认是被当做字符串处理的。我们在日志分析时,想查询系统中年龄大于35的人 age > 35
就会得到错误的结果,因此我们需要将这个 age
映射为 "long" 类型(假设年龄以整数存储),这样 ES 就能够正确地处理这个字段的查询,包括数字范围查询、排序等操作。
再举一个 ES 做搜索的例子(虽然本篇日志场景中用不到), 我们有一个产品叫做 智能手机-高清屏幕 , 但是用户输入 高清屏幕手机 进行搜索,在传统数据库那肯定是搜不到的,但是在 ES 中,我们对产品名做一个文本字段映射,并且设置合适的 Analyzer 分析器,用户通过 高清屏幕手机 就能搜到我们这个产品。
其他索引配置
接下来的索引配置比较抽象和业务无关,比如索引的存储问题,如果日志太多了,多到1台机器都放不下怎么办呢? ES 中提供了分片 Shards 的概念:
- 解决数据量大的问题:索引被切割成不同片,可以存储在不同机器上
- 解决性能问题:分片可以并行处理查询请求。当查询一个索引时,多个分片可以同时进行搜索操作,然后将结果汇总。
ES 中 Replicas 也是类似的概念,可以将索引做个备份,解决高可用的问题,当然也可以做负载均衡,主分片和副本分片一起处理查询请求。
索引模版
上面我写起了一个索引名为 audit_logs_20241214
, 那么在实际系统运行过程中,每天我们都会新建一个索引像这样: audit_logs_20241215, audit_logs_20241216, audit_logs_20241217 , 这就产生了管理问题,我们难道每天都要去给这个索引做配置吗?难道某个字段映射要调整,我们需要调整几百几千天产生的索引吗?大可不必!
索引模板允许提前定义好索引的通用配置。可以为日志索引创建一个模板,在模板中定义日志索引的通用映射(如将日志时间字段映射为日期类型、消息字段映射为文本类型)和设置(如分片数为 3,副本数为 1)。这样,当新的日志索引根据日期自动创建时(如 "logs_20241214"),会自动应用模板中的配置,大大提高了管理效率。
除了自动化管理外,这里在举一个生产中的常见例子:假设一个电商系统包含用户服务、订单服务和产品服务三个主要微服务。每个微服务都有自己的日志写到 ES 各自的索引中,比如 Service A
, Service B
, Service C
。
当我们做数据分析时,如果有跨索引查询需求:
查询在 2024 年 10 月 1 日至 2024 年 10 月 31 日期间注册的用户,所下订单中包含的产品在注册时间之后上线的相关信息,包括用户姓名、订单编号、产品名称、产品上线时间和用户注册时间。
那么这就对每个索引中的数据格式或者字段类型有一致性要求,比如在索引模版中可以统一设置他们时间字段,金额字段的规范化。再比如同系统中不同的命名统一,在跨团队时极其有用。
索引的自动化管理
假设我们有一个这样的需求:我们前180天的索引要进行归档,也就是将 "mobile-office-2024.01", "mobile - office - 2024.02", "mobile - office - 2024.03", "mobile - office - 2024.04", "mobile - office - 2024.05", "mobile - office - 2024.06" 要归档为 "mobile-office-2024.上半年"。
这看起来是要:新建一个索引,然后调用 reindex API 将以前索引迁移过来。这手动了,除了索引模板可以自动化管理索引之外,ES还提供了 Index Lifecycle Management ILM 机制:创建到删除的整个生命周期的自动化。
我们刚才的索引是动态的,是程序计算出来的, 比如按天:
js
const getCurrentDateIndex = () => {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); // 月份从0开始,所以加1
const day = String(date.getDate()).padStart(2, '0');
return `mobile-office-${year}.${month}.${day}`; // 生成索引名称
};
使用ILM Rollover机制后我们不需要程序中根据日期动态生成索引名称了,程序中就写死一个别名 就好了,下面让我们一步一步实现。 先建一个索引模板 mobile-office-*
, 并且建第一个索引 mobile-office-01
成为下列结构:
sh
Before:
Application -> 具体的 Index 动态按日期生成
After:
Application -> 别名 -> mobile-office-01
这样我们的程序中就可以写死索引名为别名了比如 mobile-office
。
接下来我们要做的就是设置一个 ILM 策略,让 Rollover 机制能自动的生成 00002 以及什么时候生成 00003。
sh
Application -> 别名 -> mobile-office-00002
Applicaiton -> 别名 -> mobile-office-00003
随着时间推移,有些文件很新经常要用(热数据),有些不那么常用但偶尔也需要(温数据),还有些完全没用可以扔掉(冷数据或要删除的数据)。ILM 就是一套规则,用来决定这些索引(文件仓库)在不同阶段该怎么处理,让你的数据存储和查询都更高效。
ILM 规则介绍:
-
热(Hot)阶段 这是索引最活跃时段,新数据持续写入且常被查询,像电商处理中订单索引。最佳实践:
- 分片与副本:依写入和查询负载设分片数,写入查询大时可适当增加,副本设 1 - 2 个。如电商大并发订单写入,分片 5,副本 2。
- 刷新间隔:设 1 - 5 秒让新数据较快可查,但要权衡系统开销。
- 重点!滚动策略:达一定大小(如 50GB)或时间(如 7 天)自动滚动新索引,如日志索引达 10GB 就滚动。
-
温(Warm)阶段 活跃度下降,偶尔才被用,类似电商已完成订单历史索引,最佳实践:
- 副本调整:从热阶段的 2 个减为 1 个,节省空间。
- 合并段:设条件将段数合并到 1 - 5 个,加快查询速度。
- 存储迁移:若有不同存储层,移到更便宜介质(如 SSD 转 HDD)。
-
冷(Cold)阶段 很少被访问,多因合规或极偶尔分析才用,如电商多年前订单数据。最佳实践:
- 副本再减:减到 0 或 1 个。
- 存储介质:选低成本的,像磁带或大容量低速硬盘。
- 压缩策略:选合适算法(如 ZSTD)平衡空间与解压速度。
-
删除(Delete)阶段 毫无价值时就删除,如电商订单超法定保存期。最佳实践:
- 删除时间:依据业务或者合规性要求,如电商订单 7 年后删。
- 备份策略:按需备份,用于合规或历史研究。
下面让我们创建一个 ILM 策略:
json
PUT /_ilm/policy/mobile-office-ilm-policy
{
"policy": {
"phases": {
"hot": {
"min_age": "0ms",
"actions": {
"set_priority": {
"priority": 100
},
"rollover": {
"max_age": "1d"
}
}
},
"warm": {
"min_age": "7d",
"actions": {
"set_priority": {
"priority": 50
},
"forcemerge": {
"max_num_segments": 1
},
"shrink": {
"number_of_shards": 1
}
}
},
"cold": {
"min_age": "31d",
"actions": {
"set_priority": {
"priority": 0
},
"freeze": {}
}
},
"delete": {
"min_age": "180d",
"actions": {
"delete": {}
}
}
}
}
}
- Hot 阶段(活跃期):从数据创建开始
min_age:0ms
以最高优先级分配资源,当数据经过1天后自动滚动到下一个阶段。 - Warm 阶段(近归档期):在索引创建1天后进入,降低资源分配优先级。这个阶段持续 7天。
- Cold 阶段(归档期):优先级降为0, 这时候冻结数据变为只读,占用更少资源。
- Delete 阶段:180天前的日志索引会被删除。
将这个策略关联到索引模版
json
PUT _template/my_template
{
"index_patterns": ["mobile-office-*"],
"template": {
"settings": {
"index.lifecycle.name": "mobile-office-ilm-policy", // ILM策略名称
"index.lifecycle.rollover_alias": "mobile-office-logs" // Rollover别名
},
"mappings": {
"properties": {
"message": {
"type": "text"
},
"timestamp": {
"type": "date"
}
}
}
}
}
Line 7 的rollover_alias
是一个重要的概念。
举例说明:
假设你有一个日志索引,命名为 logs-000001
,并且你想要每个月进行一次 rollover。你可以设置一个别名,比如 current-logs
,指向 logs-000001
。
初始状态:
logs-000001
(实际索引)
current-logs
(指向 logs-000001
的别名) 当一个月结束时,你执行 rollover 操作,这时会创建一个新的索引 logs-000002
,并将别名 current-logs
更新为指向新的索引。
rollover 后的状态:
logs-000001
(旧索引,仍然存在) logs-000002
(新创建的索引) current-logs
(现在指向 logs-000002) 这样,任何写入到 current-logs
的数据都会被自动写入到最新的索引 logs-000002
,而不需要修改任何应用程序代码。
如何记录日志
下面我们以 node.js 举例如何将程序的日志送到 ES 里
sh
npm install pino pino-elasticsearch
pino是一个日志库,pino-elasticsearch 是一个 transporter,可以将日志输入到 ES 里
js
const pino = require('pino');
const pinoElastic = require('pino-elasticsearch');
const streamToElastic = pinoElastic({
node: 'http://localhost:9200',
index: 'mobile-office-2024.12.1' # 这里需要写个函数动态生成 YYYYMMDD
});
const logger = pino({}, streamToElastic);
logger.info('This is a sample log message');
假设我们的目的是为了更好的观测我们程序运行状态,帮助我们定位线上出现的问题,那我们就需要打 2 类核心的日志:
- 错误日志
- 业务逻辑日志
首先先要对全局错误进行兜底(比如 nest.js 的@Catch()
) , 第一个参数为 error
详细的 error 对象,第二个参数为自定义的 error message。
js
// 全局错误处理
app.use((err, req, res, next) => {
logger.error({ err }, 'A global unexpected error occurred');
res.status(500).send('Internal Server Error');
});
局部错误上报
js
app.get('/endpoint', (req, res) => {
try {
// 请求第三方接口出错
// 数据类型处理错误导致的 runtime 异常
// 自定义异常 比如用户已经存在
} catch (err) {
logger.error({ err }, 'An error occurred while processing the request');
res.status(500).send('Caught an error');
}
});
另一类是业务逻辑日志,我们主要用于观测,用于追踪系统中的一些关键流程,比如用户信息从无到有,比如用户状态在某个业务中的流转等等。
订单处理流程追踪:
js
logger.info({ orderId: order.id, amount: order.amount }, 'Processing new order')
// 处理订单的逻辑
logger.info({ orderId: order.id }, 'Order processed successfully')
关键指标日志分析(也可以用 Metric 方案上报监控)
js
logger.info({ duration, rowCount: result.rows.length }, 'Database query completed')
用户登陆日志(敏感信息注意脱敏):
php
logger.info({
event: 'user_login',
userId: user.id,
username: user.username,
loginTime: new Date().toISOString(),
userAgent: user.userAgent,
ipAddress: user.ipAddress
}, 'User logged in')
预告:日志采集
前面我们讲的方式是通过程序直接将日志写入ES,但是这样的方式不能够规模化,对程序有一些入侵性。在大规模生产环境中我们通常会通过 "采集器" 来完成程序与ES的解耦。
总结
ES 以它高可用的结构以及查询效率在日志监控场景中有着非常大的竞争力。而且,使用 ES 还意味着你可以更好地管理日志。通过设置合适的索引策略,你可以控制日志的存储时间和大小,避免存储成本飙升。更重要的是,ES 的分布式架构可以轻松应对不断增长的日志量,让你的系统始终保持高效运行。🚀
如果本文对你有帮助,点个👍🏻吧!