9、Prometheus存储进阶

Prometheus入门 / 2022-06-30

本文系朱政科《Prometheus云原生监控:运维与开发实战》学习笔记

Prometheus不仅是作为监控用的应用系统,还自带时序数据库。

Prometheus存储从借助第三方数据库LevelDB到自研时序数据库,经历过多个版本的迭代:V1.0(2012)、V2.0(2015)、V3.0(2017)。本章将主要围绕Prometheus 3.0版本存储的原理,即Prometheus 2.0+的Prometheus TSDB的本地存储,从存储文件的格式、存储的原理、chunk、索引、block、WAL日志、tombstones、Checkpoint等相关知识点展开讲解。

一、本地存储文件结构解析

以Linux系统为例,Prometheus 2.x版本的二进制包解压后结构如下所示:

sanxi@sanxi-PC:~/Downloads$ tree prometheus-2.33.3.linux-amd64
prometheus-2.33.3.linux-amd64
├── console_libraries
│   ├── menu.lib
│   └── prom.lib
├── consoles
│   ├── index.html.example
│   ├── node-cpu.html
│   ├── node-disk.html
│   ├── node.html
│   ├── node-overview.html
│   ├── prometheus.html
│   └── prometheus-overview.html
├── LICENSE
├── NOTICE
├── prometheus
├── prometheus.yml
└── promtool

2 directories, 14 files

1.1、数据存储格式

在运行Prometheus采集数据后,会产生一个data目录,来看看它的内容和结构:

实际内容过多,已删减,仅为展示结构用。

sanxi@sanxi-PC:/usr/local/prometheus$ tree data/
data/{
├── 01G3FDC323H0Y4GE3RRW0G2ZYR
│   ├── chunks
│   │   └── 000001
│   ├── index
│   ├── meta.json
│   └── tombstones
├── chunks_head
│   └── 000001
├── lock
├── queries.active
├── snapshots
│   └── 20220519T094301Z-163fc33399a4dcb3
│       ├── 01G2BP4KNB85XK6W8P5Y2GJRTA
│       │   ├── chunks
│       │   │   └── 000001
│       │   ├── index
│       │   ├── meta.json
│       │   └── tombstones
└── wal
    ├── 00000218
    ├── 00000219
    ├── 00000220
    └── checkpoint.00000217
        └── 00000000
1.1.1、block

在上述文件中,首先要介绍的是Prometheus TSDB的核心内容:block。Prometheus中以每2个小时为一个时间窗口,即将2小时内产生的数据存储在一个block中,监控数据会以时间段的形式被拆分成不同的block,因此这样的block会有很多。

block会压缩、合并成历史数据块、随着压缩合并会减少block的个数,这与LSM树的机制类似。压缩过程中主要完成3项工作,分别是定期执行压缩、合并小block到大block以及清理过期block。

block大小并不固定,一般按照设定的步长成倍数递增。。默认最小的block保存2小时的监控数据,如果步长为4,那么block对应的时间依次为:2h、8h、24h、72h,其格式如下:

│   └── 20220519T094301Z-163fc33399a4dcb3  # 时间作为目录名
│       ├── 01G2BP4KNB85XK6W8P5Y2GJRTA  # block块
│       │   ├── chunks  # 样本数据
│       │   │   └── 000001
│       │   ├── index  # 索引文件
│       │   ├── meta.json  # block元数据信息
│       │   └── tombstones  # 逻辑数据

每个block都拥有全局唯一的UUID,即全局字典IDUUID总长度为128位(16字节),前48位(6字节)是时间戳,后80位是(10字节)为随机数。Prometheus通过base32算法将16字节的UUID转换为26字节的可排序字符串,比如01G2BP4KNB85XK6W8P5Y2GJRTA前10字节就是由UUID的前6个字节的时间戳转换而来的。通过这样的命名方式,可以通过block文件名确认block的创建时间,这对连续block的排序、查询都有着非常重要的作用。

每个block都有自己单独的目录,里面包含该时间窗口内所有的chunk、Index、tombstones、meta.json。

  • chunks:会有一个或多个chunk,用于保存时序数据。每个chunk最大为512MB,超过部分则会被截断为多个chunk保存,通过数学编号来命名。
  • Index:索引文件,它是Prometheus TSDB实现高效查询的基础,它可以通过metrics name和labels查找s时序数据在chunk文件中的位置。索引文件会将指标名和标签索引到样本的时间序列中。
  • tombstones:用于对数据进行软删除。Prometheus TSDB采用了“标记删除”的策略来降低删除操作的成本:如果通过API删除时间序列,删除记录会保存在单独的逻辑文件tombstones中;读取时序数据时,也会根据tombstones文件中的删除记录来过滤已删除的部分。
  • meta.json:block的元数据信息,这些元数据的信息对block的合并、删除等非常有帮助,如下所示是一个meta.json的示例:
{		// 根据当前block的ULID算法生成的唯一名称
        "ulid": "01G3FDC323H0Y4GE3RRW0G2ZYR",
        "minTime": 1652770832825,  // block最小时间
        "maxTime": 1652781600000,  // bl01G2BP4KNB85XK6W8P5Y2GJRTAock最大时间,最小最大加起来就是时间范围
        "stats": {  // 状态信息
                "numSamples": 606438,  // 样本数
                "numSeries": 1664,  // 时序数
                "numChunks": 6978  // chunk个数
        },
        "compaction": {  // 压缩的配置信息
                "level": 1,  // 压缩级别,每压缩1次,level的值就会+1
                "sources": [  // 以下是压缩时参与的block唯一名称
                        "01G3FDC323H0Y4GE3RRW0G2ZYR"
                ],
                "parents": [
                        {
                                "ulid": "01G3B0AJWDMM4WGQWB3VV72B51",
                                "minTime": 1652770832825,
                                "maxTime": 1652781600000
                        }
                ]
        },
        "version": 1
}
1.1.2、WAL

WAL即write-ahead logging,即先写日志再写磁盘,这也是MySQL、HBASE等数据库中常用的技术。使用WAL技术可以方便地进行回滚、重试数据等操作,保证数据可靠性。

WAL被分割为默认大小为128MB的文件段,以下WAL结构示意截取自[1.1](# 1.1、数据存储格式)小节

└── wal
    ├── 00000218
    ├── 00000219
    ├── 00000220
    └── checkpoint.00000217
        └── 00000000

WAL文件还包括还没有被压缩的原始数据,所以比常规的block文件大得多。一般情况下,Prometheus会保留3个WAL文件,但如果有些高负载服务器需要保存2小时以上的原始数据,WAL文件的数量就会大于3个。

1.1.2.1、checkpoint

checkpoint位于WAL目录下,是个二进制文件,用于对WAL日志的数量进行控制。checkpoint文件同步写入磁盘,当发生系统崩溃时,先从checkpoint恢复数据。它用于日志定期压缩和清理,目的是减少宕机后恢复的时长,并降低磁盘占有率。

大多数存储系统都会使用checkpoint方式清理WAL日志,这种方式即仅保留服务器上checkpoint后的WAL日志,之前的会被清除。因为随着时序数据的不断写入,Prometheus TSDB中的WAL日志的数据量也会不断增加。

二、存储原理解析

介绍完文件结构,来学习一下Prometheus存储的原理。

在Prometheus中是按照时间的顺序生成多个block文件,其中第一个block文件称为head block,它存储于内存中且允许被修改;其它block文件则以只读的方式持久化在磁盘上。

采集数据时,Prometheus周期性地将监控数据以chunk形式先添加到head block(即内存)中,这些数据并不会马上写入磁盘,而是按照2小时一个block的速率进行存储。为防止在这期间程序崩溃而导致数据丢失,于是有了WAL机制,假设出现崩溃那么再次启动时会以WAL方式来实现重播,从而恢复数据。这些2小时的block会在后台压缩,数据会被压缩合并到更高级别的block文件后删除低级别的block文件,压缩过程中还会清理过期的block目录。这个和leveldb、rocksdb等LSM树的思路一致。

Prometheus删除文件时并不会真正即刻删除文件,这在1.1.1小节中有提到,而是将要删除的文件记录到tombstones中,定期作业会在后台进行删除操作。,读取数据时根据tombstones记录过滤已删除的部分。

三、存储配置

Prometheus将时间序列及其样本存储在磁盘上,鉴于磁盘空间是有限的资源,使用Prometheus时应该对此做一些限制。Prometheus提供了几个参数来修改本地存储的配置,最主要的参数如下所示:

启动参数 默认值 含义
–storage.tsdb.path data/ 数据存储路径,WAL日志也会保存在此。
–storage.tsdb.retention.time 15d 样本数据保留时间,超过就会被清除;支持单位y w d h m s ms
–storage.tsdb.retention.size 0 block大小上限,超过上限则从头开始覆盖,0代表不限制;支持单位有KB MB GB TB PB EB
–storage.tsdb.retention 标志指定了Prometheus将保持的可用的时间范围,2.7+开始被遗弃,使用storage.tsdb.retention.time替代

如上所示,--storage.tsdb.retention.size是2.7+版本引入的用于指定block使用的最大磁盘空间量的参数,但并未包括WAL和压缩所填充的block。为安全起见,最好为WAL和一个最大的block预留空间。

Prometheus还有一个关于chunk的参数:--storage.local.chunk-encoding-version,其有效值是0、1、2。版本0的编码一般在比较老的Prometheus上使用,新版本默认使用1,更多详情请自行查阅资料。

四、本地存储容量规划

上一节介绍了Prometheus存储的几个参数,本节将围绕--storage.tsdb.retention.size参数来谈谈如何规划存储容量。

4.1、官方推荐

在Prometheus官方文档上,推荐通过如下公式对本地存储容量进行粗略计算

# 所需空间 = 数据保留时长 * 每秒获取指标数量 * 样本数据大小
needed_disk_space = retention_time_seconds * ingested_samples_per_second * bytes_per_sample

查看当前每秒获取的样本数可以通过以下PromQL表达式得到:

prometheus_tsdb_head_samples_appended_total

一般情况下,Prometheus中一个样本大概为1-2字节,如想减少磁盘占用,有两种方法:

  • 减少时间序列的数量,这是最常用的做法,因为不是每个指标都有用。
  • 增加采集间隔,采集间隔变大后可能会影响灵敏度,需要根据实际情况评估。

4.2、作者推荐

Prometheus的核心开发者博客上,提出更为保守的估算公式(基于对块、索引、逻辑删除、元数据等一系列Prometheus存储文件的考量)

五、内存容量规划

一般来说,如果标签数量比较少,且无复杂的标签,在50%内存利用率的情况下,1GB内存可以支持20万个指标。如果样本数量超过200万,建议不要使用单实例,而是采用分片的方式。查询时也尽量避免大范围的查询;尽量避免关联查询,通过relabel的方式给原始数据多加几个标签进行优化。

RAM,random access memory,即随机存储器,俗称内存,它是与CPU直接交换数据的内存储器,又称主存(主内存)。它特点是可以随时读写、速度非常快,通常作为操作系统或者其它正在运行中的程序的临时数据存储媒介。当电源关闭时,RAM数据会丢失,如果需要保存数据,就必须将数据写入支持持久化存储的设置(比如硬盘)。

ROM,read-onlu memory,即只读存储器。ROM所存数据一般是装入装机前事先写好的,整机工作过程中只能读不能写。ROM所存数据稳定,不受断电影响。

RAM与ROM相比,两者最大区别是RAM在断电后无法保存数据,而ROM可以。

5.1、内存估算

Prometheus 2.x与1.x差别非常大,尤其是性能上2.x改进巨大。但是2.x没有内存控制选项,需要自行估算一个稳定的limit内存阈值。Prometheus核心开发者也在博客上做过分析,有兴趣可以自行查看。这里直接给出博客中的结论:

假设有100万个时间序列、采集间隔为15秒、50%内存开销、每个样本均为典型字节数及至少需要保存3h的数据(考虑head block压缩的工作原理)等,Prometheus 2.x大约需要2GB的RAM(内存)。

Prometheus内存估算

上图算法的估算公式如下所示:

5.1.1、总内存开销
# 基本内存(运行系统所需内存) + 采集内存(Prometheus所需内存)
total_memory = cardinality_memory + ingestion_memory
5.1.2、基本内存
cardinality_memory = $number_of_time_series * (732 + ($average_labels_per_time_series * 32 ) + ($average_labels_per_time_series * $average_labels_per_time_series * $average_bytes_per_label_pair * 2) + ($number_of_unique_label_pairs * 120)) * 放大乘数/1024/1024
5.1.3、采集内存
# 采集内存 = 指标数量 * 样本平均大小 / 采集间隔 * 3600 * 3 * 放大乘数 / 1024 / 1024
ingestion_memory = $number_of_time_series * $bytes_per_sample/$scrape_interval * 3600 * 3 * 放大乘数/1024/1024
5.1.4、参数说明

对于上述公式各个参数解释如下:

  • number_of_time_series:指标数量
  • average_labels_per_time_series:每个指标的平均标签数
  • average_bytes_per_label_pair:标签的平均长度
  • number_of_unique_label_pairs:标签的总数
  • bytes_per_sample:样本的平均大小,反映了指标本身的线性关系。如果指标随着时间的变化但它变化幅度较小,那么该值一般接近1;如果指标波动幅度大,该值接近3.Facebook论文的经验值是1.3,根据作者朱政科实际经验,K8S全量指标大约1.13。
  • scrape_interval:采集间隔
  • 放大乘数:因为上面的公式是根据使用中的空间Inuse_space反推的,而inuse_space只是实际RSS的一部分(Go每2分钟执行一次GC,如果某片段持续5分钟都没有被启用,回收器会将其释放),因此可以根据Go任务的特点得出任务inuse大概在50%左右,故放大乘数为2.,比如Prometheus汇聚端。但实际生产中,这个值在采集端的Prometheus会更大一些,可能在3-4之间,因为Prometheus在采集层会执行更多的操作,比如额外打标签。

Prometheus需要一定的cache来支撑I/O,当rss/rss+cache过高时,docker可能会出现频繁重启的情况,该值一般需要控制在75%左右。因此这个公式最后除以0.75就是基本内存的最高限制值。

5.2、内存分析

pprof是一款GO分析工具,可以方便地查看CPU、内存的使用情况。通过pprof,可以查到当前最大的“内存用户”等内存信息。关于对Prometheus进行内存分析的一些重要度量指标需要考虑,比如process_resident_memory_bytes是Prometheus进程从内核使用的内存量,而go_memstats_alloc_bytes是Go占用内核的内存量,这两者之间的巨大差异可能意味着内存碎片问题。

六、存储及时性与时序性分析

据作者朱政科所述,曾有技术员咨询他一个问题:为什么重启Prometheus时经常出现error on ingesting samples that are too old or are too far into the future错误?

这个问题基本是因为将指标放到Prometheus源码的结构体scrapecache中进行合法性检查时报错了。

Prometheus分析指标在存储前会进行合法性校验。在指标采集的源码scrapeManager中,scrapeLoop结构体包含scrapeCache,通过调用scrapeLoop.append方法处理指标存储。在方法append中,把每个指标放到结构体scrapeCache对应的方法中进行合法性验证、过滤和存储。

源码待展示

对于这种问题,把TSDB全部删除即可(可以使用HTTP api方式)。这是因为Prometheus的这段代码体现了Prometheus的及时采集的特性,即若自定义时间戳,Prometheus采集时会比较自定义的时间戳与当前时间,如两者之间差别过大(如大于1h),指标则不会被采集,且出现error on ingesting samples that are too old or are too far into the future错误。

在上述代码中,Prometheus还有时序性的采集特性,即若自定义时间戳,Prometheus采集指标时会将采集的时间戳和待插入时间序列中的时间戳进行比较,若采集的时间戳小于待插入的时间序列的当前时间戳(会判定为插入旧数据),则Prometheus报错error on ingesting out of order samples错误。

综上所述,如果本地存储由于某些原因失效了,最简单粗暴的方式就是停止Prometheus并删除data目录中的所有数据。当然也可以尝试删除那些失效的块目录,这就意味着用户只丢失该块中保存的2小时的监控数据。

世间微尘里 独爱茶酒中