且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

Envoy源码分析之Stats基础

更新时间:2022-09-30 14:00:08

简介

Envoy官方文档中提到One of the primary goals of Envoy is to make the network understandable,让网络变的可理解,为了实现这个目标Envoy中内置了stats用于统计各类网络相关的指标,Envoy没有选择使用PrometheusSDK,而是选择自己实现了stats目的是为了适配Envoy的线程模型以及Envoy自身的一些需求。Envoy中的stats主要用于统计三类指标信息(每一个指标又被称为Metric):

  • Downstream: 统计进来的连接和请求,指标来源于ListenersHTTP connection managerTCP proxy filter等。
  • Upstream: 统计出去的连接和请求,指标来源于connection poolsrouter filterTCP proxy filter等。
  • Server: 统计Envoy实例自身的一些状态信息,指标来源于启动时间、分配的内存等。

这些指标有的是持续递增的(比如传输的字节总数、接收的请求数等),有的则会增长,也会递减(比如活跃的连接数),有的则需要统计分布情况(比如RT的分布情况)等,为了满足这些需求Envoy使用了三种类型的stats来表示。

  • Counter: 64位的无符号整型,只递增。
  • Gauge: 64位的无符号整型,可以递增也可以递减。
  • Histogram: 一组表示范围的值,统计的数据会映射到这组值中,随着统计的数据增多,这组表示范围的值会自动调整。典型的比如统计请求的耗时,那么这组表示范围的值可以是(0~5ms, 0~10ms, 0~20ms....)等。

为了让所有的stats信息在热重启的时候可以传递给新启动的进程,stats的数据最初默认是存放在共享内存中的,这样在热重启的时候就可以通过共享内存在两个进程之间传递stats。但是基于共享内存的这种方式存在诸多限制,比如共享内存的大小是预先分配的,固定大小,没办法动态增长。而这在大规模的集群场景下将会变得更加糟糕会导致耗费大量内存(每一个stats都分配固定大小的内存,因为需要用来计算要申请的共享内存大小,但是实际上很多stats并没有使用这么多内存)。为此最新的Envoy其stats是存储在堆上进行动态分配,然后通过RPC协议在新老进程中传递。关于这部分的讨论可以关注这个stats: Consider communicating stats across hot-restart via RPC rather than shared memory

基本概念

stats的目的是为了统计各类指标,每一个指标被称为Metric,在Envoy中所有类型的指标都继承自Metric基类,Metric有名称(实际被称为extraced metric name,不包含tag的名称)、值、还有附加的一些tag(就是key/value对),Metric还有类型,总共有三种,就是上文中提到的CountersGaugeHistogram等。我们在Envoy中看到的Metric名称通常指的就是Metric的完整名称(包含了tag信息)。为了从完整的名称中提取extraced metric name名称和tag就有了TagProducer的东西出来了,核心就一个方法produceTags,传入一个完整的指标名称,返回一系列的tag和extraced metric name。那么Envoy是按照什么样的规则来提取Tag呢?Envoy中会通过TagExactor来提取Tag,它包含了Tag name和一个正则用于正则匹配来提取对应的Tag value

Metric

Envoy源码分析之Stats基础

MetricImpl是一个模版类,其核心就是存储了extracted的指标名称和对应的tag,CounterImplGaugeImpl等都是继承MetricImpl

template <class BaseClass> class MetricImpl : public BaseClass {
public:
    .......
private:
  // 核心数据成员
  MetricHelper helper_;
};

class MetricHelper {
public:
    .......
private:
  StatNameList stat_names_;
};

通过上面的代码我们可以知道核心的类成员是StatNameList,extracted的指标名称和对应的Tag就是存储在StatNameList中,这个数据结构会在下一篇文章中介绍。

TagExactor

这个类是用来存放Tag Name以及如何从完整指标提取出Tag Value的正则,Envoy默认已经提供了一系列的提取Tag Value的正则well_known_names,此外还可以通过配置文件的方式来自定义的Tag Name和正则来提取Tag Value。核心就是extractTag方法,用于从一个完整的指标名称中提取出Tag value。

class TagExtractorImpl : public TagExtractor {
public:
  static TagExtractorPtr createTagExtractor(const std::string& name, const std::string& regex,
                                            const std::string& substr = "");
  TagExtractorImpl(const std::string& name, const std::string& regex,
                   const std::string& substr = "");
  std::string name() const override { return name_; }
  bool extractTag(absl::string_view tag_extracted_name, std::vector<Tag>& tags,
                  IntervalSet<size_t>& remove_characters) const override;
  absl::string_view prefixToken() const override { return prefix_; }
  bool substrMismatch(absl::string_view stat_name) const;

private:
  static std::string extractRegexPrefix(absl::string_view regex);
  const std::string name_;
  const std::string prefix_;
  const std::string substr_;
  const std::regex regex_;
};

比如下面这个例子:

TEST(TagExtractorTest, TwoSubexpressions) {
  // 这是Tag Name和提取Tag Value的正则
  TagExtractorImpl tag_extractor("cluster_name", "^cluster\\.((.+?)\\.)");
  EXPECT_EQ("cluster_name", tag_extractor.name());
  // 这是一个完整的指标名称,通过正则来提取Tag value
  std::string name = "cluster.test_cluster.upstream_cx_total";
  std::vector<Tag> tags;
  IntervalSetImpl<size_t> remove_characters;
  ASSERT_TRUE(tag_extractor.extractTag(name, tags, remove_characters));
  std::string tag_extracted_name = StringUtil::removeCharacters(name, remove_characters);
  // 这是extracted后的名称
  EXPECT_EQ("cluster.upstream_cx_total", tag_extracted_name);
  ASSERT_EQ(1, tags.size());
  // 这是提取出来的Tag Name和 Tag value
  EXPECT_EQ("test_cluster", tags.at(0).value_);
  EXPECT_EQ("cluster_name", tags.at(0).name_);
}

TagProducer

TagProducer依靠TagExactor得到一系列的Tag,核心的方法就是produceTags,其数据结构如下。

class TagProducerImpl : public TagProducer {
public:
  ......
private:

  std::vector<TagExtractorPtr> tag_extractors_without_prefix_;
  std::unordered_map<absl::string_view, std::vector<TagExtractorPtr>, StringViewHash>
      tag_extractor_prefix_map_;
  std::vector<Tag> default_tags_;
};

核心的数据成员就是default_tags_这个是用来给每一个指标名称都要默认添加的默认Tag(是用户自定义配置的)。调用produceTags产生Tag的时候会自动将default_tags_添加到集合中。


TagProducerImpl::TagProducerImpl(const envoy::config::metrics::v2::StatsConfig& config) {
  // To check name conflict.
  reserveResources(config);
  std::unordered_set<std::string> names = addDefaultExtractors(config);

  for (const auto& tag_specifier : config.stats_tags()) {
    const std::string& name = tag_specifier.tag_name();
    .......
    } else if (tag_specifier.tag_value_case() ==
               envoy::config::metrics::v2::TagSpecifier::kFixedValue) {
      // 从StatsConfig将用户配置的固定的Tag Name和Tag Value放到default_tags_中
      // 这类Tag是不用通过正则提取,直接作为指标的Tag返回。
      default_tags_.emplace_back(Stats::Tag{name, tag_specifier.fixed_value()});
    }
  }
}

std::string TagProducerImpl::produceTags(absl::string_view metric_name,
                                         std::vector<Tag>& tags) const {
  //  默认将default_tags_放到集合中
  tags.insert(tags.end(), default_tags_.begin(), default_tags_.end());
    .....
}

除了默认的Tag外,剩余的Tag需要依靠TagExactor从指标名称中提取,两部份组合起来就是最终返回的Tag集合了。另外一个重要的数据成员是tag_extractors_without_prefix_是用来保存默认提供的所有的不带前缀的TagExtractor(比如_rq(_(\\d{3}))$", "_rq_这个正则是用来提取response code,这个Tag Value的提取规则就是不带前缀的),每当需要产生Tag的时候就遍历这个vector,通过调用extractTag 方法来提取Tag。

std::string TagProducerImpl::produceTags(absl::string_view metric_name,
                                         std::vector<Tag>& tags) const {
    .....
  // 遍历所有的TagEactor,一个个调用extractTag方法来产生Tag
  forEachExtractorMatching(
      metric_name, [&remove_characters, &tags, &metric_name](const TagExtractorPtr& tag_extractor) {
        tag_extractor->extractTag(metric_name, tags, remove_characters);
      });
  ....
}

可想而知,这些TagExactor并不是每一个都会产生Tag,比如和集群相关的Tag提取规则肯定是没办法用来提取http相关的指标名称的。因此为了加速这部分的查找,就搞出了tag_extractor_prefix_map_TagExactor按照前缀来分类(比如R"(^tcp\.((.*?)\.)\w+?$)"这个正则就是用来提取tcp相关指标的,前缀就是tcp)。提取Tag的时候,先提取指标的前缀,然后通过前缀找到可以用来提取Tag的TagExactor,最后只需要遍历这些TagExactor就可以高效的提取Tag了。

void TagProducerImpl::forEachExtractorMatching(
    absl::string_view stat_name, std::function<void(const TagExtractorPtr&)> f) const {
  IntervalSetImpl<size_t> remove_characters;
  for (const TagExtractorPtr& tag_extractor : tag_extractors_without_prefix_) {
    f(tag_extractor);
  }
  const absl::string_view::size_type dot = stat_name.find('.');
  if (dot != std::string::npos) {
    // 找指标的前缀
    const absl::string_view token = absl::string_view(stat_name.data(), dot);
    // 通过前缀找到对应的TagExactor
    const auto iter = tag_extractor_prefix_map_.find(token);
    if (iter != tag_extractor_prefix_map_.end()) {
      // 遍历TagExactor进行Tag的提取
      for (const TagExtractorPtr& tag_extractor : iter->second) {
        f(tag_extractor);
      }
    }
  }

上文中提到带前缀的提取规则和不带前缀的提取规则,我也列举了一些例子,下面我们来通过代码更深层次的理解一下。

std::string TagExtractorImpl::extractRegexPrefix(absl::string_view regex) {
  std::string prefix;
  //带前缀的正则一定是"^"开头,然后跟上一串前缀字符,最后结束是$、\\.或者?=\\.
  if (absl::StartsWith(regex, "^")) {
    for (absl::string_view::size_type i = 1; i < regex.size(); ++i) {
      // 遍历正则,找到前缀字符串,前缀字符串的特点就是不是数字和下划线
      // 直到遇到"点号"分割就结束,或者形如"^前缀字符串$"的形式。
      if (!absl::ascii_isalnum(regex[i]) && (regex[i] != '_')) {
        if (i > 1) {
          const bool last_char = i == regex.size() - 1;
          if ((!last_char && regexStartsWithDot(regex.substr(i))) ||
              (last_char && (regex[i] == '$'))) {
            prefix.append(regex.data() + 1, i - 1);
          }
        }
        break;
      }
    }
  }
  return prefix;
}

    // 没有\\.或$或?=\\.结束
  EXPECT_EQ("", extractRegexPrefix("^prefix(foo)."));
  // \\.结束
  EXPECT_EQ("prefix", extractRegexPrefix("^prefix\\.foo"));
  // ?=\\.结束
  EXPECT_EQ("prefix_optional", extractRegexPrefix("^prefix_optional(?=\\.)"));
  // 没有找到结束符
  EXPECT_EQ("", extractRegexPrefix("^notACompleteToken"));  
  // ^前缀字符串$
  EXPECT_EQ("onlyToken", extractRegexPrefix("^onlyToken$")); 
  // 没有^开头
  EXPECT_EQ("", extractRegexPrefix("(prefix)"));
  // 没有找到结束符
  EXPECT_EQ("", extractRegexPrefix("^(prefix)"));
  // 没有^开头
  EXPECT_EQ("", extractRegexPrefix("prefix(foo)"));

简单点理解,前缀字符串一定是一个完整的部分,我们知道指标名称是按照"."号将Tag Value链接起来的,因此一个完整的前缀字符一定是"."号作为其结束部分,又或者这个指标只有一个部分组成,也就是不需要"."号结束。最后来看下通过TagProducer提取Tag Value的例子:

TEST(UtilityTest, createTagProducer) {
  envoy::config::bootstrap::v2::Bootstrap bootstrap;
  auto producer = Utility::createTagProducer(bootstrap);
  ASSERT(producer != nullptr);
  std::vector<Stats::Tag> tags;
  auto extracted_name = producer->produceTags("http.config_test.rq_total", tags);
  ASSERT_EQ(extracted_name, "http.rq_total");
  ASSERT_EQ(tags.size(), 1);
}
// http.config_test.rq_total 是完整的指标名称,包含了tag,通过produceTags方法将这个完整的指标
// 进行了extracted。extracted后的名字就是http.rq_total,解析完后的Tag value就是config_test。

总结

本文主要讲解了Metric指标的基本组成,主要包含两个部分,一个是extraced指标名称,另外一个是Tag也就是Key/Value对,然后介绍到如何从完整的指标名称中提取Tag,Envoy中依赖正则来提取,默认提供了许多正则来提取Tag,用户也可以通过配置文件的方式来自定义Tag的提取规则。这部分主要是通过TagExactor来完成。使用的时候是通过TagProducer来完成,TagProducer默认会构造好TagExactor,当传入一个完整的指标名称时就通过遍历所有的TagExactor来从指标中提取Tag Value,最后返回提取好的Tag和extraced指标名称。为了加速提取的速度,Envoy会将提取规则划分为带有前缀的和不带前缀的,查找的时候先提取指标的前缀,然后找到可以用来提取规则的一系列的TagExactor来进行规则的提取。

参考文献: