Prometheus告警规则管理

存储 存储软件
Prometheus支持用户自定义Rule规则。Rule分为两类,一类是Recording Rule,另一类是Alerting Rule。Recording Rule的主要目的是通过PromQL可以实时对Prometheus中采集到的样本数据进行查询,聚合以及其它各种运算操作。

[[419847]]

什么是Rule

Prometheus支持用户自定义Rule规则。Rule分为两类,一类是Recording Rule,另一类是Alerting Rule。Recording Rule的主要目的是通过PromQL可以实时对Prometheus中采集到的样本数据进行查询,聚合以及其它各种运算操作。而在某些PromQL较为复杂且计算量较大时,直接使用PromQL可能会导致Prometheus响应超时的情况。这时需要一种能够类似于后台批处理的机制能够在后台完成这些复杂运算的计算,对于使用者而言只需要查询这些运算结果即可。Prometheus通过Recoding Rule规则支持这种后台计算的方式,可以实现对复杂查询的性能优化,提高查询效率。

今天主要带来告警规则的分析。Prometheus中的告警规则允许你基于PromQL表达式定义告警触发条件,Prometheus后端对这些触发规则进行周期性计算,当满足触发条件后则会触发告警通知。

什么是告警Rule

告警是prometheus的一个重要功能,接下来从源码的角度来分析下告警的执行流程。

怎么定义告警Rule

一条典型的告警规则如下所示:

  1. groups: 
  2. name: example 
  3.   rules: 
  4.   - alert: HighErrorRate 
  5.     #指标需要在触发告警之前的10分钟内大于0.5。 
  6.     expr: job:request_latency_seconds:mean5m{job="myjob"} > 0.5 
  7.     for: 10m 
  8.     labels: 
  9.       severity: page 
  10.     annotations: 
  11.       summary: High request latency 
  12.       description: description info 

在告警规则文件中,我们可以将一组相关的规则设置定义在一个group下。在每一个group中我们可以定义多个告警规则(rule)。一条告警规则主要由以下几部分组成:

  • alert:告警规则的名称。
  • expr:基于PromQL表达式告警触发条件,用于计算是否有时间序列满足该条件。
  • for:评估等待时间,可选参数。用于表示只有当触发条件持续一段时间后才发送告警。在等待期间新产生告警的状态为pending。
  • labels:自定义标签,允许用户指定要附加到告警上的一组附加标签。
  • annotations:用于指定一组附加信息,比如用于描述告警详细信息的文字等,annotations的内容在告警产生时会一同作为参数发送到Alertmanager。

Rule管理器

规则管理器会根据配置的规则,基于规则PromQL表达式告警的触发条件,用于计算是否有时间序列满足该条件。在满足该条件时,将告警信息发送给告警服务。

  1. type Manager struct { 
  2.  opts     *ManagerOptions //外部的依赖 
  3.  groups   map[string]*Group //当前的规则组 
  4.  mtx      sync.RWMutex //规则管理器读写锁 
  5.  block    chan struct{}  
  6.  done     chan struct{}  
  7.  restored bool  
  8.  
  9.  logger log.Logger  
  • opts(*ManagerOptions类型):记录了Manager实例使用到的其他模块,例如storage模块、notify模块等。
  • groups(map[string]*Group类型):记录了所有的rules.Group实例,其中key由rules.Group的名称及其所在的配置文件构成。
  • mtx(sync.RWMutex类型):在读写groups字段时都需要获取该锁进行同步。

读取Rule组配置

在Prometheus Server启动的过程中,首先会调用Manager.Update()方法加载Rule配置文件并进行解析,其大致流程如下。

  • 调用Manager.LoadGroups()方法加载并解析Rule配置文件,最终得到rules.Group实例集合。
  • 停止原有的rules.Group实例,启动新的rules.Group实例。其中会为每个rules.Group实例启动一个goroutine,它会关联rules.Group实例下的全部PromQL查询。
  1. func (m *Manager) Update(interval time.Duration, files []string, externalLabels labels.Labels, externalURL string) error { 
  2.  m.mtx.Lock() 
  3.  defer m.mtx.Unlock() 
  4.     // 从当前文件中加载规则 
  5.  groups, errs := m.LoadGroups(interval, externalLabels, externalURL, files...) 
  6.  if errs != nil { 
  7.   for _, e := range errs { 
  8.    level.Error(m.logger).Log("msg""loading groups failed""err", e) 
  9.   } 
  10.   return errors.New("error loading rules, previous rule set restored"
  11.  } 
  12.  m.restored = true 
  13.  
  14.  var wg sync.WaitGroup 
  15.    //循环遍历规则组 
  16.  for _, newg := range groups { 
  17.   // If there is an old group with the same identifier, 
  18.   // check if new group equals with the old group, if yes then skip it. 
  19.   // If not equals, stop it and wait for it to finish the current iteration. 
  20.   // Then copy it into the new group
  21.   //根据新的rules.Group的信息获取规则组名 
  22.   gn := GroupKey(newg.file, newg.name
  23.    //根据规则组名获取到老的规则组并删除原有的rules.Group实例 
  24.   oldg, ok := m.groups[gn] 
  25.   delete(m.groups, gn) 
  26.  
  27.   if ok && oldg.Equals(newg) { 
  28.    groups[gn] = oldg 
  29.    continue 
  30.   } 
  31.  
  32.   wg.Add(1) 
  33.     //为每一个rules.Group实例启动一个goroutine 
  34.   go func(newg *Group) { 
  35.    if ok { 
  36.     oldg.stop() 
  37.      //将老的规则组中的状态信息复制到新的规则组 
  38.     newg.CopyState(oldg) 
  39.    } 
  40.    wg.Done() 
  41.    // Wait with starting evaluation until the rule manager 
  42.    // is told to run. This is necessary to avoid running 
  43.    // queries against a bootstrapping storage. 
  44.    <-m.block 
  45.      //调用rules.Group.run()方法,开始周期性的执行PromQl语句 
  46.    newg.run(m.opts.Context) 
  47.   }(newg) 
  48.  } 
  49.  
  50.  // Stop remaining old groups. 
  51.  //停止所有老规则组的服务 
  52.  wg.Add(len(m.groups)) 
  53.  for n, oldg := range m.groups { 
  54.   go func(n string, g *Group) { 
  55.    g.markStale = true 
  56.    g.stop() 
  57.    if m := g.metrics; m != nil { 
  58.     m.IterationsMissed.DeleteLabelValues(n) 
  59.     m.IterationsScheduled.DeleteLabelValues(n) 
  60.     m.EvalTotal.DeleteLabelValues(n) 
  61.     m.EvalFailures.DeleteLabelValues(n) 
  62.     m.GroupInterval.DeleteLabelValues(n) 
  63.     m.GroupLastEvalTime.DeleteLabelValues(n) 
  64.     m.GroupLastDuration.DeleteLabelValues(n) 
  65.     m.GroupRules.DeleteLabelValues(n) 
  66.     m.GroupSamples.DeleteLabelValues((n)) 
  67.    } 
  68.    wg.Done() 
  69.   }(n, oldg) 
  70.  } 
  71.  
  72.  wg.Wait() 
  73.     //更新规则管理器中的规则组 
  74.  m.groups = groups  
  75.  
  76.  return nil 

运行Rule组调度方法

规则组启动流程(Group.run):进入Group.run方法后先进行初始化等待,以使规则的运算时间在同一时刻,周期为g.interval;然后定义规则运算调度方法:iter,调度周期为g.interval;在iter方法中调用g.Eval方法执行下一层次的规则运算调度。

规则运算的调度周期g.interval,由prometheus.yml配置文件中global中的 [ evaluation_interval:| default = 1m ]指定。实现如下:

  1. func (g *Group) run(ctx context.Context) { 
  2.  defer close(g.terminated) 
  3.  
  4.  // Wait an initial amount to have consistently slotted intervals. 
  5.  evalTimestamp := g.EvalTimestamp(time.Now().UnixNano()).Add(g.interval) 
  6.  select { 
  7.  case <-time.After(time.Until(evalTimestamp))://初始化等待 
  8.  case <-g.done: 
  9.   return 
  10.  } 
  11.  
  12.  ctx = promql.NewOriginContext(ctx, map[string]interface{}{ 
  13.   "ruleGroup": map[string]string{ 
  14.    "file": g.File(), 
  15.    "name": g.Name(), 
  16.   }, 
  17.  }) 
  18.     //定义规则组规则运算调度算法 
  19.  iter := func() { 
  20.   g.metrics.IterationsScheduled.WithLabelValues(GroupKey(g.file, g.name)).Inc() 
  21.  
  22.   start := time.Now() 
  23.     //规则运算的入口 
  24.   g.Eval(ctx, evalTimestamp) 
  25.   timeSinceStart := time.Since(start) 
  26.  
  27.   g.metrics.IterationDuration.Observe(timeSinceStart.Seconds()) 
  28.   g.setEvaluationTime(timeSinceStart) 
  29.   g.setLastEvaluation(start) 
  30.  } 
  31.  
  32.  // The assumption here is that since the ticker was started after having 
  33.  // waited for `evalTimestamp` to pass, the ticks will trigger soon 
  34.  // after each `evalTimestamp + N * g.interval` occurrence. 
  35.  tick := time.NewTicker(g.interval) //设置规则运算定时器 
  36.  defer tick.Stop() 
  37.  
  38.  defer func() { 
  39.   if !g.markStale { 
  40.    return 
  41.   } 
  42.   go func(now time.Time) { 
  43.    for _, rule := range g.seriesInPreviousEval { 
  44.     for _, r := range rule { 
  45.      g.staleSeries = append(g.staleSeries, r) 
  46.     } 
  47.    } 
  48.    // That can be garbage collected at this point. 
  49.    g.seriesInPreviousEval = nil 
  50.    // Wait for 2 intervals to give the opportunity to renamed rules 
  51.    // to insert new series in the tsdb. At this point if there is a 
  52.    // renamed rule, it should already be started. 
  53.    select { 
  54.    case <-g.managerDone: 
  55.    case <-time.After(2 * g.interval): 
  56.     g.cleanupStaleSeries(ctx, now) 
  57.    } 
  58.   }(time.Now()) 
  59.  }() 
  60.     //调用规则组规则运算的调度方法 
  61.  iter() 
  62.  if g.shouldRestore { 
  63.   // If we have to restore, we wait for another Eval to finish. 
  64.   // The reason behind this is, during first eval (or before it) 
  65.   // we might not have enough data scraped, and recording rules would not 
  66.   // have updated the latest valueson which some alerts might depend. 
  67.   select { 
  68.   case <-g.done: 
  69.    return 
  70.   case <-tick.C: 
  71.    missed := (time.Since(evalTimestamp) / g.interval) - 1 
  72.    if missed > 0 { 
  73.     g.metrics.IterationsMissed.WithLabelValues(GroupKey(g.file, g.name)).Add(float64(missed)) 
  74.     g.metrics.IterationsScheduled.WithLabelValues(GroupKey(g.file, g.name)).Add(float64(missed)) 
  75.    } 
  76.    evalTimestamp = evalTimestamp.Add((missed + 1) * g.interval) 
  77.    iter() 
  78.   } 
  79.  
  80.   g.RestoreForState(time.Now()) 
  81.   g.shouldRestore = false 
  82.  } 
  83.  
  84.  for { 
  85.   select { 
  86.   case <-g.done: 
  87.    return 
  88.   default
  89.    select { 
  90.    case <-g.done: 
  91.     return 
  92.    case <-tick.C: 
  93.     missed := (time.Since(evalTimestamp) / g.interval) - 1 
  94.     if missed > 0 { 
  95.      g.metrics.IterationsMissed.WithLabelValues(GroupKey(g.file, g.name)).Add(float64(missed)) 
  96.      g.metrics.IterationsScheduled.WithLabelValues(GroupKey(g.file, g.name)).Add(float64(missed)) 
  97.     } 
  98.     evalTimestamp = evalTimestamp.Add((missed + 1) * g.interval) 
  99.      //调用规则组规则运算的调度方法 
  100.     iter() 
  101.    } 
  102.   } 
  103.  } 

运行Rule调度方法

规则组对具体规则的调度在Group.Eval中实现,在Group.Eval方法中会将规则组下的每条规则通过QueryFunc将(promQL)放到查询引擎(queryEngine)中执行,如果被执行的是AlertingRule类型,那么执行结果指标会被NotifyFunc组件发送给告警服务;如果是RecordingRule类型,最后将改结果指标存储到Prometheus的储存管理器中,并对过期指标进行存储标记处理。

  1. // Eval runs a single evaluation cycle in which all rules are evaluated sequentially. 
  2. func (g *Group) Eval(ctx context.Context, ts time.Time) { 
  3.  var samplesTotal float64 
  4.     遍历当前规则组下的所有规则 
  5.  for i, rule := range g.rules { 
  6.   select { 
  7.   case <-g.done: 
  8.    return 
  9.   default
  10.   } 
  11.  
  12.   func(i intrule Rule) { 
  13.    sp, ctx := opentracing.StartSpanFromContext(ctx, "rule"
  14.    sp.SetTag("name"rule.Name()) 
  15.    defer func(t time.Time) { 
  16.     sp.Finish() 
  17.       //更新服务指标-规则的执行时间 
  18.     since := time.Since(t) 
  19.     g.metrics.EvalDuration.Observe(since.Seconds()) 
  20.     rule.SetEvaluationDuration(since) 
  21.       //记录本次规则执行的耗时 
  22.     rule.SetEvaluationTimestamp(t) 
  23.    }(time.Now()) 
  24.      //记录规则运算的次数 
  25.    g.metrics.EvalTotal.WithLabelValues(GroupKey(g.File(), g.Name())).Inc() 
  26.      //运算规则 
  27.    vector, err := rule.Eval(ctx, ts, g.opts.QueryFunc, g.opts.ExternalURL) 
  28.    if err != nil { 
  29.       //规则出现错误后,终止查询 
  30.     rule.SetHealth(HealthBad) 
  31.     rule.SetLastError(err) 
  32.      //记录查询失败的次数 
  33.     g.metrics.EvalFailures.WithLabelValues(GroupKey(g.File(), g.Name())).Inc() 
  34.  
  35.     // Canceled queries are intentional termination of queries. This normally 
  36.     // happens on shutdown and thus we skip logging of any errors here. 
  37.     if _, ok := err.(promql.ErrQueryCanceled); !ok { 
  38.      level.Warn(g.logger).Log("msg""Evaluating rule failed""rule"rule"err", err) 
  39.     } 
  40.     return 
  41.    } 
  42.    samplesTotal += float64(len(vector)) 
  43.             //判断是否是告警类型规则 
  44.    if ar, ok := rule.(*AlertingRule); ok { 
  45.                 发送告警 
  46.     ar.sendAlerts(ctx, ts, g.opts.ResendDelay, g.interval, g.opts.NotifyFunc) 
  47.    } 
  48.    var ( 
  49.     numOutOfOrder = 0 
  50.     numDuplicates = 0 
  51.    ) 
  52.     //此处为Recording获取存储器指标 
  53.    app := g.opts.Appendable.Appender(ctx) 
  54.    seriesReturned := make(map[string]labels.Labels, len(g.seriesInPreviousEval[i])) 
  55.    defer func() { 
  56.     if err := app.Commit(); err != nil { 
  57.      rule.SetHealth(HealthBad) 
  58.      rule.SetLastError(err) 
  59.      g.metrics.EvalFailures.WithLabelValues(GroupKey(g.File(), g.Name())).Inc() 
  60.  
  61.      level.Warn(g.logger).Log("msg""Rule sample appending failed""err", err) 
  62.      return 
  63.     } 
  64.     g.seriesInPreviousEval[i] = seriesReturned 
  65.    }() 
  66.  
  67.    for _, s := range vector { 
  68.     if _, err := app.Append(0, s.Metric, s.T, s.V); err != nil { 
  69.      rule.SetHealth(HealthBad) 
  70.      rule.SetLastError(err) 
  71.  
  72.      switch errors.Cause(err) { 
  73.                         储存指标返回的各种错误码处理 
  74.      case storage.ErrOutOfOrderSample: 
  75.       numOutOfOrder++ 
  76.       level.Debug(g.logger).Log("msg""Rule evaluation result discarded""err", err, "sample", s) 
  77.      case storage.ErrDuplicateSampleForTimestamp: 
  78.       numDuplicates++ 
  79.       level.Debug(g.logger).Log("msg""Rule evaluation result discarded""err", err, "sample", s) 
  80.      default
  81.       level.Warn(g.logger).Log("msg""Rule evaluation result discarded""err", err, "sample", s) 
  82.      } 
  83.     } else { 
  84.       //缓存规则运算后的结果指标 
  85.      seriesReturned[s.Metric.String()] = s.Metric 
  86.     } 
  87.    } 
  88.    if numOutOfOrder > 0 { 
  89.     level.Warn(g.logger).Log("msg""Error on ingesting out-of-order result from rule evaluation""numDropped", numOutOfOrder) 
  90.    } 
  91.    if numDuplicates > 0 { 
  92.     level.Warn(g.logger).Log("msg""Error on ingesting results from rule evaluation with different value but same timestamp""numDropped", numDuplicates) 
  93.    } 
  94.  
  95.    for metric, lset := range g.seriesInPreviousEval[i] { 
  96.     if _, ok := seriesReturned[metric]; !ok { 
  97.       //设置过期指标的指标值 
  98.      // Series no longer exposed, mark it stale. 
  99.      _, err = app.Append(0, lset, timestamp.FromTime(ts), math.Float64frombits(value.StaleNaN)) 
  100.      switch errors.Cause(err) { 
  101.      case nil: 
  102.      case storage.ErrOutOfOrderSample, storage.ErrDuplicateSampleForTimestamp: 
  103.       // Do not count these in logging, as this is expected if series 
  104.       // is exposed from a different rule
  105.      default
  106.       level.Warn(g.logger).Log("msg""Adding stale sample failed""sample", metric, "err", err) 
  107.      } 
  108.     } 
  109.    } 
  110.   }(i, rule
  111.  } 
  112.  if g.metrics != nil { 
  113.   g.metrics.GroupSamples.WithLabelValues(GroupKey(g.File(), g.Name())).Set(samplesTotal) 
  114.  } 
  115.  g.cleanupStaleSeries(ctx, ts) 

然后就是规则的具体执行了,我们这里先只看AlertingRule的流程。首先看下AlertingRule的结构:

  1. // An AlertingRule generates alerts from its vector expression. 
  2. type AlertingRule struct { 
  3.     // The name of the alert. 
  4.     name string 
  5.     // The vector expression from which to generate alerts. 
  6.     vector parser.Expr 
  7.     // The duration for which a labelset needs to persist in the expression 
  8.     // output vector before an alert transitions from Pending to Firing state. 
  9.     holdDuration time.Duration 
  10.     // Extra labels to attach to the resulting alert sample vectors. 
  11.     labels labels.Labels 
  12.     // Non-identifying key/value pairs. 
  13.     annotations labels.Labels 
  14.     // External labels from the global config. 
  15.     externalLabels map[string]string 
  16.     // true if old state has been restored. We start persisting samples for ALERT_FOR_STATE 
  17.     // only after the restoration. 
  18.     restored bool 
  19.     // Protects the below. 
  20.     mtx sync.Mutex 
  21.     // Time in seconds taken to evaluate rule
  22.     evaluationDuration time.Duration 
  23.     // Timestamp of last evaluation of rule
  24.     evaluationTimestamp time.Time 
  25.     // The health of the alerting rule
  26.     health RuleHealth 
  27.     // The last error seen by the alerting rule
  28.     lastError error 
  29.     // A map of alerts which are currently active (Pending or Firing), keyed by 
  30.     // the fingerprint of the labelset they correspond to
  31.     active map[uint64]*Alert 
  32.     logger log.Logger 

这里比较重要的就是active字段了,它保存了执行规则后需要进行告警的资源,具体是否告警还要执行一系列的逻辑来判断是否满足告警条件。具体执行的逻辑如下:

  1. func (r *AlertingRule) Eval(ctx context.Context, ts time.Time, query QueryFunc, externalURL *url.URL) (promql.Vector, error) { 
  2.     res, err := query(ctx, r.vector.String(), ts) 
  3.     if err != nil { 
  4.         r.SetHealth(HealthBad) 
  5.         r.SetLastError(err) 
  6.         return nil, err 
  7.     } 
  8.     // ...... 

这一步通过创建Manager时传入的QueryFunc函数执行规则配置中的expr表达式,然后得到返回的结果,这里的结果是满足表达式的指标的集合。比如配置的规则为:

  1. cpu_usage > 90 

那么查出来的结果可能是

  1. cpu_usage{instance="192.168.0.11"} 91 
  2. cpu_usage{instance="192.168.0.12"} 92 

然后遍历查询到的结果,根据指标的标签生成一个hash值,然后判断这个hash值是否之前已经存在(即之前是否已经有相同的指标数据返回),如果是,则更新上次的value及annotations,如果不是,则创建一个新的alert并保存至该规则下的active alert列表中。然后遍历规则的active alert列表,根据规则的持续时长配置、alert的上次触发时间、alert的当前状态、本次查询alert是否依然存在等信息来修改alert的状态。具体规则如下:

如果alert之前存在,但本次执行时不存在

  • 状态是StatePending或者本次检查时间距离上次触发时间超过15分钟(15分钟为写死的常量),则将该alert从active列表中删除
  • 状态不为StateInactive的alert修改为StateInactive

如果alert之前存在并且本次执行仍然存在

  • alert的状态是StatePending并且本次检查距离上次触发时间超过配置的for持续时长,那么状态修改为StateFiring

其余情况修改alert的状态为StatePending

上面那一步只是修改了alert的状态,但是并没有真正执行发送告警操作。下面才是真正要执行告警操作:

  1. // 判断规则是否是alert规则,如果是则发送告警信息(具体是否真正发送由ar.sendAlerts中的逻辑判断) 
  2. if ar, ok := rule.(*AlertingRule); ok { 
  3.     ar.sendAlerts(ctx, ts, g.opts.ResendDelay, g.interval, g.opts.NotifyFunc) 
  4. // ....... 
  5. func (r *AlertingRule) sendAlerts(ctx context.Context, ts time.Time, resendDelay time.Duration, interval time.Duration, notifyFunc NotifyFunc) { 
  6.     alerts := []*Alert{} 
  7.     r.ForEachActiveAlert(func(alert *Alert) { 
  8.         if alert.needsSending(ts, resendDelay) { 
  9.             alert.LastSentAt = ts 
  10.             // Allow for two Eval or Alertmanager send failures. 
  11.             delta := resendDelay 
  12.             if interval > resendDelay { 
  13.                 delta = interval 
  14.             } 
  15.             alert.ValidUntil = ts.Add(4 * delta) 
  16.             anew := *alert 
  17.             alerts = append(alerts, &anew) 
  18.         } 
  19.     }) 
  20.     notifyFunc(ctx, r.vector.String(), alerts...) 
  21. func (a *Alert) needsSending(ts time.Time, resendDelay time.Duration) bool { 
  22.     if a.State == StatePending { 
  23.         return false 
  24.     } 
  25.     // if an alert has been resolved since the last send, resend it 
  26.     if a.ResolvedAt.After(a.LastSentAt) { 
  27.         return true 
  28.     } 
  29.     return a.LastSentAt.Add(resendDelay).Before(ts) 

概括一下以上逻辑就是:

  1. 如果alert的状态是StatePending,则不发送告警
  2. 如果alert的已经被解决,那么再次发送告警标记该条信息已经被解决
  3. 如果当前时间距离上次发送告警的时间大于配置的重新发送延时时间(ResendDelay),则发送告警,否则不发送

以上就是prometheus的告警流程。学习这个流程主要是问了能够对prometheus的rules相关的做二次开发。我们可以修改LoadGroups()方法,让其可以动态侧加载定义在mysql中定义的规则,动态实现告警规则更新。

参考: 

《深入浅出prometheus原理、应用、源码与拓展详解》

 

责任编辑:武晓燕 来源: 运维开发故事
相关推荐

2023-03-26 08:41:37

2021-03-31 08:02:34

Prometheus 监控运维

2023-09-12 07:11:33

Prometheus聚合告警GPT

2021-02-18 15:36:13

PrometheusAlertmanageGrafana

2022-07-29 21:23:54

Grafana微服务

2014-06-16 11:17:12

入侵检测OSSEC日志分析

2023-11-24 16:57:53

2022-09-04 17:53:20

Prometheus开源

2020-12-30 05:34:25

监控PrometheusGrafana

2023-04-20 07:12:33

夜莺监控夜莺

2022-08-30 13:03:39

prometheusAlert

2021-08-26 11:30:54

AlertManage阿里云

2023-11-13 08:15:36

2020-12-17 09:25:46

运维Prometheus监控

2011-07-18 17:14:16

Objective-C 内存 Cocoa

2010-04-20 13:59:30

Oracle管理规则

2011-08-15 16:28:06

Cocoa内存管理

2023-11-21 08:57:16

2010-12-28 10:48:09

信息系统项目管理师

2020-10-14 08:33:23

Prometheus监控体系
点赞
收藏

51CTO技术栈公众号