Loki被限流了,Limits_Config到底限了个啥?

road-690347_1920.jpg

Loki中拥有这众多的limit策略,有的已经开放到配置文件中,还有的配置代码中已经实现但还没开放出来。大部分情况下开发者给了出一些默认参数足够优秀,不过有的时候我们也不免需要微调。那么小白这次先简单捡几个比较重要的策略来说明下Limits_Config中到底限制了什么。

1. 限流器

小白前段时间无意间重启升级了下Loki的服务,由于过程持续了一段时间,当服务恢复时客户端在push日志时总是会收到如下的报错,

image.png
429 Too Many Requests Ingestion rate limit exceeded (limit 10485760 bytes/sec)

这直接导致我们部分服务器上的日志采集器缓冲区一直处于拥堵,引起日志采集的延迟。

看起来这个是触发了Loki的限流策略了,查了下官方文档,发现在limits_config中两个参数控制Loki Distributor的日志接收的速率:

limits_config:
  #令牌桶注入token的速率
  ingestion_rate_mb: | default = 4]
 
  #令牌桶的容量
  ingestion_burst_size_mb: | default = 6]

经过摸索发现Loki的Distributor中关于限速的方法也是采用Golang标准库的time/rate限流器来实现的。它的大致逻辑如下:

首先distributor处理日志push请求时声明了protobuf的编码,其中可以包括多个日志流,以及每个流里面的label信息和Entry中的日志时间戳和条目信息。


type PushRequest struct {
    Streams []Stream `protobuf:"bytes,1,rep,name=streams,proto3,customtype=Stream" json:"streams"`
}

type Stream struct {
    Labels  string  `protobuf:"bytes,1,opt,name=labels,proto3" json:"labels"`
    Entries []Entry `protobuf:"bytes,2,rep,name=entries,proto3,customtype=EntryAdapter" json:"entries"`
}

type Entry struct {
    Timestamp time.Time `protobuf:"bytes,1,opt,name=timestamp,proto3,stdtime" json:"ts"`
    Line      string    `protobuf:"bytes,2,opt,name=line,proto3" json:"line"`
}

从日志流的Entry中取出日志条目并计算长度得到validatedSamplesSize,之后将本次日志的长度传给限流器

func (d *Distributor) Push(ctx context.Context, req *logproto.PushRequest) (*logproto.PushResponse, error) {

    for _, stream := range req.Streams {
        if err := d.validator.ValidateLabels(userID, stream); err != nil {
            validationErr = err
            continue
        }

        entries := make([]logproto.Entry, 0, len(stream.Entries))
        for _, entry := range stream.Entries {
            if err := d.validator.ValidateEntry(userID, stream.Labels, entry); err != nil {
                validationErr = err
                continue
            }
            entries = append(entries, entry)
            validatedSamplesSize += len(entry.Line)
            validatedSamplesCount++
        }

获取当前时间,以及将租户ID和validatedSamplesSize传给ingestionRateLimiter.AllowN限流器处理,如果限流成功则忽略这次提交,同时打印上述截图中的报错,

now := time.Now()
if !d.ingestionRateLimiter.AllowN(now, userID, validatedSamplesSize) {
    validation.DiscardedSamples.WithLabelValues(validation.RateLimited, userID).Add(float64(validatedSamplesCount))
    validation.DiscardedBytes.WithLabelValues(validation.RateLimited, userID).Add(float64(validatedSamplesSize))
    //抛出429 Too Many Requests异常
    return nil, httpgrpc.Errorf(http.StatusTooManyRequests, validation.RateLimitedErrorMsg(int(d.ingestionRateLimiter.Limit(now, userID)), validatedSamplesCount, validatedSamplesSize))
    }

接下来就是具体的限流器内的处理,我们看到Distributor主要通过RateLimiter.AllowN的方式获取令牌。也就是说截止当前时间,判断限流器桶中令牌数与日志条目容量数N(validatedSamplesSize),满足则返回true,同时从桶中消费N个令牌,反正则返回失败。这部分我们可以在这里面找到

https://github.com/cortexproject/cortex/blob/master/pkg/util/limiter/rate_limiter.go

到这里,我们需要改变的就是令牌桶的大小和速率即可,也就是一开始limits_config中的两个参数。我们将其适当调大就可以立马得到效果:

limits_config:
  ingestion_rate_mb: 32
  ingestion_burst_size_mb: 64
image.png

可以看到在调整了rate和burst之后,绿色框框内的客户端的日志缓冲区很快的就被消费清空了。

扩展

除此之外,Loki的限流器还有一个全局的动态配置

limits_config:
  ingestion_rate_strategy: | default = "local"

默认情况下为local,如果你的Loki是一个分布式系统话,local会将上述的限流器的令牌桶作用在每个distributor中,这会极大提高Loki日志收取的吞吐量。如果配置的为global的话,distributor则会用
ingestion_rate_mb / ring.HealthyInstancesCount得到全局的每个distributor的租户限制速率。这部分代码在ingestion_rate_strategy.go得以实现

type globalStrategy struct {
    limits *validation.Overrides
    ring   ReadLifecycler
}

func newGlobalIngestionRateStrategy(limits *validation.Overrides, ring ReadLifecycler) limiter.RateLimiterStrategy {
    return &globalStrategy{
        limits: limits,
        ring:   ring,
    }
}

func (s *globalStrategy) Limit(userID string) float64 {
    numDistributors := s.ring.HealthyInstancesCount()

    if numDistributors == 0 {
        return s.limits.IngestionRateBytes(userID)
    }

    return s.limits.IngestionRateBytes(userID) / float64(numDistributors)
}

根据租户开启全局的日志限速策略,目前应该还没有人用于实践吧

总结:

限流器是后台服务中非常重要的一个组件,它可以通过限制请求数或流量的方式来保护后台服务避免过载,令牌桶是一个常见的实现方法。Loki显然在这里是利用令牌桶的方式来对日志流的容量进行限制。

更多关于Golang标准库中time/rate的使用,可以查考https://godoc.org/golang.org/x/time/rate

2. 标签长度限制

Loki的Limit_Config关于长度的限制包括对label键值对的大小限制等,其主要包含以下几个:

limits_config:
  # label的key最大长度
  max_label_name_length:   | default = 1024
 
  # label的value最大长度
  max_label_value_length:  | default = 2048
 
  # 每个流中的最大label个数
  max_label_names_per_series: | default = 30

标签限制的这部分代码在distributor的validator.go中,比如在这里计算labels内的键值对个数,超过配置最大数则放弃同时返回BadRequest

ls, err := util.ToClientLabels(stream.Labels)
numLabelNames := len(ls)
if numLabelNames > v.MaxLabelNamesPerSeries(userID) {
   validation.DiscardedSamples.WithLabelValues(validation.MaxLabelNamesPerSeries, userID).Inc()
   bytes := 0
   for _, e := range stream.Entries {
       bytes += len(e.Line)
   }
   validation.DiscardedBytes.WithLabelValues(validation.MaxLabelNamesPerSeries, userID).Add(float64(bytes))
   return httpgrpc.Errorf(http.StatusBadRequest, validation.MaxLabelNamesPerSeriesErrorMsg(cortex_client.FromLabelAdaptersToMetric(ls).String(), numLabelNames, v.MaxLabelNamesPerSeries(userID)))
   }

比如下面这里distributor会取出labels中的name和value的长度分别和配置中的最大长度进行对比,以及对label中的name进行比较,防止重名。

maxLabelNameLength := v.MaxLabelNameLength(userID)
maxLabelValueLength := v.MaxLabelValueLength(userID)
lastLabelName := ""
for _, l := range ls {
   if len(l.Name) > maxLabelNameLength {
       updateMetrics(validation.LabelNameTooLong, userID, stream)
       return httpgrpc.Errorf(http.StatusBadRequest, validation.LabelNameTooLongErrorMsg(stream.Labels, l.Name))       } else if len(l.Value) > maxLabelValueLength {
       updateMetrics(validation.LabelValueTooLong, userID, stream)
       return httpgrpc.Errorf(http.StatusBadRequest, validation.LabelValueTooLongErrorMsg(stream.Labels, l.Value))
   } else if cmp := strings.Compare(lastLabelName, l.Name); cmp == 0 {
       updateMetrics(validation.DuplicateLabelNames, userID, stream)
       return httpgrpc.Errorf(http.StatusBadRequest, validation.DuplicateLabelNamesErrorMsg(stream.Labels, l.Name))
   }
   lastLabelName = l.Name
}

总结:

loki中对于日志标签的长度限制并没有特别的强调,不过秉着无界的label会拖垮Loki性能的准则上,我们不宜将max_label_names_per_series配置得过大

3. 租户限制

默认情况下我们部署的Loki都只有一个租户,它使用的是一个叫fake的租户id。那么在Loki的限制策略中,涉及到租户的包含如下几个参数:

limits_config:
  # 每个租户的最大日志流个数
  max_streams_per_user: | default = 10000
  
  #启用全局的租户最大日志流个数,默认0关闭
  #一旦配置,每个租户日志流将有ingester注册到hash环上同时状态为
  #HEALTH的个数动态计算得出,任何ingester的数量变化都会动态生效到这个值
  max_global_streams_per_user:  default = 0

在这里我们可以看到max_streams_per_user的local和global之间的计算方法,算法如下

(max_global_streams_per_user / max_streams_per_user) * replication_factor

复制因子的配置来至ingest_config.replication_factor

以下是limiter.go中对于全局最大日志流数的代码实现:

func (l *Limiter) convertGlobalToLocalLimit(globalLimit int) int {
    if globalLimit == 0 {
        return 0
    }
    //从环中得到健康的ingester数
    numIngesters := l.ring.HealthyInstancesCount()
    if numIngesters > 0 {
    
    //全局的最大值除以健康的ingester个数,再乘个复制因子
        return int((float64(globalLimit) / float64(numIngesters)) * float64(l.replicationFactor))
    }
    return 0
}

在AssertMaxStreamsPerUser函数中简单处理local和global的限制数据后,对日志流个数判断,低于计算后的limit则返回空,否则抛出超过租户最大限制数的异常

func (l *Limiter) AssertMaxStreamsPerUser(userID string, streams int) error {
    localLimit := l.limits.MaxLocalStreamsPerUser(userID)

    globalLimit := l.limits.MaxGlobalStreamsPerUser(userID)
    //根据上述算法计算出全局的限制
    adjustedGlobalLimit := l.convertGlobalToLocalLimit(globalLimit)

   //当adjustedGlobalLimit不为0且localLimit大于adjustedGlobalLimit时
   //calculatedLimit就为动态生成的limit,反之则为localLimit的限制数
    calculatedLimit := l.minNonZero(localLimit, adjustedGlobalLimit)
    
    if calculatedLimit == 0 {
        calculatedLimit = math.MaxInt32
    }

   //如果日志流小于calculatedLimit则返回空,否则返回超过租户日志流个数异常
    if streams < calculatedLimit {
        return nil
    }
    return fmt.Errorf(errMaxStreamsPerUserLimitExceeded, userID, streams, calculatedLimit, localLimit, globalLimit, adjustedGlobalLimit)
}

func (l *Limiter) minNonZero(first, second int) int {
    if first == 0 || (second != 0 && first > second) {
        return second
    }
    return first
}

在ingester的instance.go中,loki对调用上述AssertMaxStreamsPerUser方法来判断租户流的限制,如果返回不为空,则放弃本次处理,并抛出TooManyRequests异常

err = i.limiter.AssertMaxStreamsPerUser(i.instanceID, len(i.streams))
if err != nil {
    validation.DiscardedSamples.WithLabelValues(validation.StreamLimit, i.instanceID).Add(float64(len(pushReqStream.Entries)))
    bytes := 0
    for _, e := range pushReqStream.Entries {
        bytes += len(e.Line)
    }
    validation.DiscardedBytes.WithLabelValues(validation.StreamLimit, i.instanceID).Add(float64(bytes))
    return nil, httpgrpc.Errorf(http.StatusTooManyRequests, validation.StreamLimitErrorMsg())
    }

总结:

可以看到Loki对于全局的动态配置计算还是比较简单,如果启用max_global_streams_per_user的话,应要合理的配置复制因子的个数。如果复制因子的个数 > 健康的ingester个数,则计算出来的租户限制会比配置文件中定义要大许多。

4. 日志条目大小限制

在Loki中,对于客户端push到distributor中产生的每条日志流是可以对其做条目的大小限制的,这个在配置里面默认是不限制,也就是说每行的日志大小可以是无限😄,当然大部分情况下我们都不会去限制这个,如果有的同学环境特殊,可以考虑开启对每行日志的大小限制。

limits_config:
# 日志条目的大小限制,默认不限制
  max_line_size:  | default = none

这部分的实现也比较简单,在distributor的validator.go中,ValidateEntry函数会对获取日志流中的条目长度进行比较,当MaxLineSize不为0且日志流中的条目长度大于MaxLineSize则放弃本次处理,并抛出LineTooLong异常

func (v Validator) ValidateEntry(userID string, labels string, entry logproto.Entry) error {

    if maxSize := v.MaxLineSize(userID); maxSize != 0 && len(entry.Line) > maxSize {
       validation.DiscardedSamples.WithLabelValues(validation.LineTooLong, userID).Inc()
       validation.DiscardedBytes.WithLabelValues(validation.LineTooLong, userID).Add(float64(len(entry.Line)))
       return httpgrpc.Errorf(http.StatusBadRequest, validation.LineTooLongErrorMsg(maxSize, len(entry.Line), labels))
    }
}

5. 查询限制

在Loki的查询中有一个限制日志返回行数的限制,它直接控制了你在grafana或者其他平台里面能够从loki里面拿到的日志行数,大部分情况下5000行可以满足需求,如果你觉得不够可以将其调大或者设置成0

limits_config:
  # 查询返回条目的限制
  max_entries_limit_per_query:  | default = 5000 

除此之外,对于单次查询的限制还有的chunk、stream和series的,不过大部分场景我们不会去对此做调整,小白这里就不在详细分析。

limits_config:
  # 单个查询最多匹配的chunk个数
  max_chunks_per_query: | default = 2000000
  
  # 限制查询是匹配到的chunk大小,默认0为不限制
  max_query_length:  | default = 0
  
  # 单词查询最多匹配到的日志流个数
  max_streams_matchers_per_query: | default = 1000
  
  # 限制查询时最大的日志度量个数
  max_query_series: | default = 500
  
  # 查询的并发数
  max_query_parallelism  | default = 14
  
  # 允许租户缓存结果的有效时间
  max_cache_freshness_per_query   |default = 1m.

总结

事实上Loki中关于limits的部分大都不需要进行额外的调整,除非我们对Loki的日志存储有明确的场景定位,这才有机会去调整这些参数。

比如你的Loki是一个内部的日志,不涉及到多租户,那你的limit策略值大都配置的比较高,如果你的loki是一个多租户场景下日志,每个租户都有自己明确的日志,这个时候你可能关注的就是合理限制单个租户的日志接受和查询,避免单个租户的行为影响集群。不过目前Loki似乎还没有针对租户身份信息来做shard服务,或许后面还有更好的方式来应对多租户的场景


关注公众号「云原生小白」,获取更多精彩内容

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,254评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,875评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,682评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,896评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,015评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,152评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,208评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,962评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,388评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,700评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,867评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,551评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,186评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,901评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,689评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,757评论 2 351