线上服务一旦拆到多台机器,日志查询就会变成一件很烦的事。我维护的几个后端服务分布在四台服务器上,平时排查问题基本靠ssh登上去,再tail -f或者grep。单机还好,一旦某个请求跨了多个服务,或者要对比几台机器同一时间段的报错,就得开好几个终端来回切,效率低到让人怀疑人生。
之前也考虑过 ELK,但 Elasticsearch 对内存要求比较高,我的机器上还要跑业务进程,不太想为了查日志再吃掉几个 G 内存。后来选了 Loki 这套方案,它的思路和 ES 不一样:只对标签建立索引,日志正文压缩后直接存对象存储或本地磁盘,资源占用小很多。配合 Promtail 采集、Grafana 查询,整套跑起来内存占用比我预期低。
这篇文章记录一下我从零搭这套平台的过程,包括配置、标签设计、查询语法,以及跑起来之后真实遇到过的问题。
为什么是 Loki 而不是 ELK
先说选型。这不是说 Loki 比 ELK 好,而是场景不同。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| ELK(Elasticsearch + Logstash + Kibana) | 全文检索强,聚合分析能力完整,生态成熟 | 资源占用高,ES 对内存和磁盘 IO 要求大,运维成本高 | 日志量大、需要复杂全文检索和聚合分析的团队 |
| Loki + Promtail + Grafana | 资源占用低,和 Prometheus/Grafana 体系天然契合,部署简单 | 不支持全文索引,查询依赖标签过滤,聚合能力弱于 ES | 中小规模、已有 Grafana、主要靠标签定位问题的场景 |
| 直接 ssh + grep | 零部署成本 | 多机、跨服务、历史回溯场景基本不可用 | 单机、临时排查 |
我这边的情况是:服务数量不算多,日志量每天大概几个 G,查询诉求主要是"某台机器某个服务在某段时间的报错",而不是"全文搜索某个关键词出现在哪些日志里"。这种以标签定位为主的查询,正好是 Loki 的强项。
【关键结论】如果你的查询主要是按服务、主机、时间范围过滤,Loki 性价比很高;如果经常要做全文关键词检索和复杂聚合,ES 更合适。
组件分工
三个组件各管一段:
- Promtail:部署在每台被采集的机器上,负责读本地日志文件,打上标签,推送到 Loki。
- Loki:接收日志,按标签建索引,把日志内容压缩存储。
- Grafana:连到 Loki 作为数据源,提供查询界面和仪表盘。
数据流向大致是:日志文件 → Promtail(采集+打标签)→ Loki(索引+存储)→ Grafana(查询展示)。
部署方式
我用的是 docker-compose,把 Loki 和 Grafana 放在一台机器上(下面叫它日志服务器),Promtail 单独部署在每台业务机上。这样业务机只需要跑一个很轻的 Promtail 容器。
先看 Loki 和 Grafana 这一侧。我用的版本是 Loki 2.9.x 和 Grafana 10.x,这两个版本当前比较稳定,配置写法也是现在主流的。
# docker-compose.yml —— 部署在日志服务器version:"3.8"services:loki:image:grafana/loki:2.9.6container_name:lokiports:-"3100:3100"volumes:-./loki-config.yml:/etc/loki/local-config.yaml-./loki-data:/lokicommand:-config.file=/etc/loki/local-config.yamlrestart:unless-stoppedgrafana:image:grafana/grafana:10.4.2container_name:grafanaports:-"3000:3000"volumes:-./grafana-data:/var/lib/grafanaenvironment:-GF_SECURITY_ADMIN_PASSWORD=change_medepends_on:-lokirestart:unless-stoppedLoki 的配置文件我用了接近单机默认的写法,主要确认存储路径和监听端口:
# loki-config.ymlauth_enabled:falseserver:http_listen_port:3100common:path_prefix:/lokistorage:filesystem:chunks_directory:/loki/chunksrules_directory:/loki/rulesreplication_factor:1ring:kvstore:store:inmemoryschema_config:configs:-from:2024-01-01store:tsdbobject_store:filesystemschema:v13index:prefix:index_period:24hlimits_config:reject_old_samples:truereject_old_samples_max_age:168h这里有几个点值得说一下。auth_enabled: false是关掉多租户,单机自己用没必要开。schema: v13是当前 Loki 推荐的 schema 版本,和 tsdb 存储配合。reject_old_samples_max_age: 168h是拒绝超过 7 天的旧日志,防止客户端时钟不对导致乱序写入。
【注意】schema_config里的from日期一旦定下来,后续如果要改 schema,需要新增一条配置项而不是改这一条,Loki 是按时间段选择 schema 的。这一点官方文档有说明,改错了会导致旧数据读不出来。
filesystem存储适合单机或者挂载了共享存储的场景。如果日志量再大、想上对象存储(比如 S3、MinIO),把object_store换成对应类型即可,这个我没实际配过,就不展开了。
Promtail 采集配置
业务机上的 Promtail 配置是重点,标签怎么打直接决定了后面查询好不好用。
# promtail-config.yml —— 部署在每台业务机server:http_listen_port:9080grpc_listen_port:0positions:filename:/tmp/positions.yamlclients:-url:http://<日志服务器IP>:3100/loki/api/v1/pushscrape_configs:-job_name:app-logsstatic_configs:-targets:-localhostlabels:job:apphost:server-01__path__:/var/log/myapp/*.logpositions.yaml记录每个文件读到哪一行了,Promtail 重启后从断点继续,不会重复推送。这个文件要保证可写。
clients指向 Loki 的 push 接口。
scrape_configs里,__path__是匹配日志文件的路径,labels里定义的标签会附加到这批日志上。上面这个配置的意思是:采集/var/log/myapp/下所有.log文件,给它们打上job=app、host=server-01两个标签。
每台机器上的配置只有host不一样,其他都一样。实际部署时我用了一个模板,部署脚本里替换 host 值。
标签设计的坑
这里是我踩得比较实的一个问题。一开始我想当然地把一些动态字段也做成了标签,比如把日志里的request_id、user_id提取出来当标签。结果 Loki 很快就报警说标签基数太高,查询也变得很慢。
原因是 Loki 的索引是基于标签组合的。标签的唯一组合数量(基数)一旦爆炸,索引就撑不住了。request_id这种几乎每条都不一样的值,做成标签等于给每条日志建一个索引,完全违背了 Loki 的设计初衷。
【踩坑提醒】标签只放基数低、可枚举的维度,比如host、job、env、level。像request_id、user_id、trace_id这种高基数字段,要么放在日志正文里用查询语法过滤,要么用 Loki 的 structured metadata(需要较新版本,且行为有差异,我没有深入验证)。
调整之后,我的标签就固定在host、job、env这几个上:
labels:job:apphost:server-01env:prod这样标签组合数量就是"机器数 × 服务数 × 环境数",非常可控。
日志格式建议
Loki 查询时如果日志是结构化 JSON,过滤会方便很多。我这边后端服务用的是 Python,日志输出改成了 JSON 格式,方便后续按字段过滤。
# logging_config.pyimportjsonimportloggingimportsysclassJsonFormatter(logging.Formatter):defformat(self,record:logging.LogRecord)->str:payload={"ts":self.formatTime(record,"%Y-%m-%dT%H:%M:%S%z"),"level":record.levelname,"logger":record.name,"message":record.getMessage(),}# 如果有额外字段(比如 request_id),一并带上ifhasattr(record,"request_id"):payload["request_id"]=record.request_idifrecord.exc_info:payload["exc"]=self.formatException(record.exc_info)returnjson.dumps(payload,ensure_ascii=False)defget_logger(name:str)->logging.Logger:logger=logging.getLogger(name)logger.setLevel(logging.INFO)handler=logging.StreamHandler(sys.stdout)handler.setFormatter(JsonFormatter())logger.addHandler(handler)returnlogger这样每条日志就是一行 JSON。注意request_id是放在正文里的,不是标签,查询时用 Loki 的 JSON 解析语法过滤。
在 Grafana 里查日志
Grafana 加 Loki 数据源很简单:Configuration → Data Sources → Add data source → 选 Loki,URL 填http://loki:3100(如果 Grafana 和 Loki 在同一个 compose 网络里)或者日志服务器 IP。
配好之后,在 Explore 页面就能查了。Loki 的查询分两部分:标签选择器和过滤器。
标签选择器必须至少有一个非空的匹配条件,比如:
{host="server-01", job="app"}这会查出这台机器上 app 服务的所有日志。然后可以加过滤器:
{host="server-01", job="app"} |= "ERROR"|=是包含某个字符串,!=是不包含,|~是正则匹配。这些都是对日志正文做过滤,配合标签先缩小范围,效率会好很多。
如果日志是 JSON,可以用| json解析后按字段过滤:
{host="server-01", job="app"} | json | level="ERROR"还可以按 request_id 查一条请求的完整链路(即使它跨了多个服务):
{job="app"} | json | request_id="abc-123"这比挨个 ssh 上去 grep 快太多了。
常用查询整理
几个我平时用得比较多的写法:
- 查某台机器最近的报错:
{host="server-01", job="app"} |= "ERROR" - 查某个服务所有机器上的 500 错误:
{job="app"} | json | status="500" - 统计每分钟错误日志条数(用于做告警):
sum(count_over_time({job="app"} |= "ERROR" [1m])) - 排除健康检查这类噪音:
{job="app"} != "healthcheck"
第 3 条这种是 metric 查询,可以直接配成 Grafana 的告警规则。
实际遇到的问题
标签基数报警
前面提过,一开始把 request_id 做成标签,Loki 日志里出现max label cardinality类似的告警,查询也明显变慢。把高基数字段从标签里去掉之后恢复正常。这是 Loki 使用中最容易犯的错,没有之一。
时间范围查询慢
Loki 查询性能和时间范围强相关。查最近 15 分钟很快,查最近 7 天就会慢,因为它要扫过整个时间段的数据。我的做法是:日常排查控制在几小时内,需要长期回溯的场景单独处理,而不是一上来就查一周。
limits_config里的max_query_length可以限制单次查询的最大时间跨度,我设了一个相对保守的值,防止有人误查超大范围把 Loki 拖垮。这个参数具体取值要结合自己的数据量和机器配置,我没有一个通用数字。
磁盘增长
日志是持续写入的,磁盘会一直涨。Loki 本身有 retention 配置,但我用的是 filesystem 存储,实际清理行为需要结合compactor相关配置一起看,这块我配得比较简单(靠reject_old_samples_max_age控制写入,定期手动清理旧 chunks)。如果对保留策略有严格要求,建议用对象存储配合 retention 配置,或者接一个定时清理脚本。这一点我没有做完整的自动化验证,属于待完善的部分。
是否值得搭
回到最开始的问题。这套方案适合什么样的场景?
- 服务分布在多台机器,ssh 逐台查已经明显影响效率;
- 查询以标签+时间范围为主,不是重度全文检索;
- 机器资源有限,不想为日志系统投入太多内存;
- 已经在用 Grafana,希望日志和指标在同一个界面看。
如果这几点都符合,Loki 这套组合的性价比很高。如果日志量特别大、或者需要 ES 那种全文检索和复杂聚合,那就老老实实上 ELK,别硬套。
我搭完之后,最直观的变化是排查一个跨服务问题的时间从"开四个终端来回切"变成了"在 Grafana 里一条查询搞定"。尤其是按 request_id 追链路,之前基本靠手动拼,现在直接查。
后续我打算再补两块:一是把错误日志的 metric 查询配上 Grafana 告警,出问题主动通知而不是等人来查;二是把 retention 策略做成自动化的,省得手动清磁盘。这两块落地之后,这套平台才算真正完整。