作者:笃敏

概述

走近iLogtail

iLogtail是一款高功用的轻量级可观测数据收集器,由阿里云SLS团队官方供给,能够运转在包括服务器、容器和嵌入式等多种环境中,其主旨在于协助开发者构建一致的数据收集层,助力可观测渠道打造各种上层运用场景。iLogtail多年来一直安稳服务阿里集团、蚂蚁集团以及众多公有云上的企业客户,现在现已有千万级的安装量,每天收集数十PB的可观测数据,广泛运用于线上监控、问题剖析/定位、运营剖析、安全剖析等多种场景。

iLogtail架构开展历程

早在2013年,iLogtail作为阿里巴巴集团飞天5K项目中担任机器监控及日志收集的中心组件,现已被广泛地运用于集团内部机器。现在,10多年过去了,伴跟着云原生和可观测性概念的逐步推广,iLogtail在商业化和开源的进程中也阅历了一系列的架构迭代。

单一文件收集阶段

该阶段是iLogtail的起步阶段,也是iLogtail的命名由来,其首要才能是收集和解析日志文件并发送至日志服务后端进行存储。功用上,这一阶段的iLogtail具有如下特色:

  • 只能收集日志文件;
  • 假定日志为单一格局,每种格局的日志仅支撑一种处理办法(如正则解析、Json解析等);
  • 只能将日志发送至日志服务;

万字心路历程:从十年迈架构决议重构开端

依据上述功用需求,这一阶段的iLogtail架构及完结具有如下特色:

  • 彻底由C 完结,在日志收集方面具有明显优势;
  • 因为需求单一,因而全体架构偏向于单体架构,代码规划以面向进程为主,类的功用差异不明确,多个模块运用同一个类对象,导致类间依靠严峻,可扩展性较差;
  • 功用完结与日志服务相关概念(如LogGroup和Logstore等)强绑定,普适性较差;

Golang插件扩展阶段

跟着可观测性概念的提出,iLogtail不再逗留于单一的日志收集场景,逐步向更普适的可观测数据收集器范畴开展。明显,要成为顶流的可观测数据收集器,有必要至少满意以下几个条件:

  • 多样化的数据输入输出选项
  • 个性化的数据处理才能组合
  • 高功用的数据处理吞吐才能

因为C 的开发生态有限,为了在短期内能够快速完结上述方针,iLogtail在起步阶段的根底上引进了依据Golang言语开发的插件系统,其全体架构演变为了如下所示的结构:

万字心路历程:从十年迈架构决议重构开端

Golang插件系统是依据现代可观测处理流水线的思维进行规划的,具有如下特色:

  • 每个收集装备对应一条完好的流水线,各个流水线之间的资源相互独立,互不影响;
  • 每条流水线支撑多个输入和输出,一同支撑从C 主程序中接收数据及向C 主程序发送数据;
  • 每条流水线支撑多个处理插件级联,有用提高处理才能;
  • 插件系统本身具有装备办理才能,支撑装备的热加载,可独立于C 主程序进行作业。

能够看到,Golang插件系统的引进极大地扩展了iLogtail的输入输出通道,且必定程度提高了iLogtail的处理才能。可是,囿于C 部分的完结,输入输出与处理模块间的组合才能依然是受限的,仅支撑以下几种数据通路:

  1. 收集日志文件并运用C 的处理才能,终究将数据投递至日志服务SLS(1和4号组合);

  2. 收集日志文件并运用Golang插件进行处理,终究将数据投递至日志服务SLS(2和4号组合);

  3. 收集日志文件并运用Golang插件进行处理,终究将数据投递至第三方存储(2和5号组合);

  4. 收集其它输入(如syslog)并运用Golang插件进行处理,终究将数据投递至日志服务SLS(3和4号组合);

  5. 收集其它输入(如syslog)并运用Golang插件进行处理,终究将数据投递至第三方存储(3和5号组合)。

由此可见,比较于起步阶段的iLogtail,该阶段的iLogtail架构具有如下特色:

  • C 和Golang多言语完结,C 部分具有功用优势,Golang部分具有功用优势;
  • 支撑多样化输入和输出选项;
  • 数据处理才能有必定提高,但输入输出与处理模块间的组合才能存在多种约束:
    • C 部分原生的高功用处理才能依然局限于收集日志文件并投递至日志服务的场景运用;
    • C 部分的高功用处理才能无法与插件系统的多样化处理才能相结合,二者只能选其一,然后下降了杂乱日志处理场景的功用。

为什么要重构

从前面的描绘可知,原有iLogtail架构最大的问题在于输入输出与处理模块间的组合才能受限,这直接影响了商业版iLogtail在解析杂乱日志场景中的功用。可是,跟着iLogtail的开源,这一问题的对立变得更加杰出:开源社区绝大多数用户都挑选将数据投递至第三方存储,这意味着绝大多数社区用户将无法享受到iLogtail原生的高功用处理才能!除此之外,iLogtail的开源还将本来并不明显的问题露出出来:

  1. 因为C 主程序代码存在错综杂乱的类间依靠联系,导致开发难度极大,加之C 的处理才能无法被社区所运用,因而C 主程序的开发几乎无人问津。

  2. 不管是C 主程序仍是Golang插件系统,其内部的数据交互模型只适用于可观测数据中的Log,而无法表达Metric和Trace。除此以外,这些数据结构均针对SLS而规划,导致在向第三方存储系统投递数据时,有必要进行额定的数据结构转化,然后下降全体的功用。

  3. 碍于C 主程序代码错综杂乱的类间依靠联系,商业版代码与开源版的剥离只能选用十分原始和丑恶的文件替换办法。这种操作直接导致如下两个成果:

    • 开源版代码中存在许多意义不明的无用空函数;
    • 在进行商业版代码开发时,首要需求进行文件替换,然后容易引进开源版和商业版代码的不一致,对联调联测带来诸多不便,影响开发和发布功率。

综上所述,不管是从产品演进,仍是从开发体验,原有iLogtail架构现已严峻约束了其快速开展。因而,对iLogtail的架构进行晋级现已火烧眉毛。

方针

《重构:改善既有代码的规划》一书中对重构的意义和办法论有详细的论述。关于iLogtail而言,本次重构的首要方针不仅仅逗留于工程层面的优化,更重要的是经过对原有架构的晋级来支撑产品未来的快速开展。详细来说,本次架构晋级能够分为如下几个方针:

  1. 将iLogtail的内部数据模型更换为通用数据模型,以减少数据投递时不必要的数据结格局转化;

  2. 将C 主程序的输入、处理和输出才能全面插件化,便于从产品侧一致C 部分和Golang部分的插件概念;

  3. 在C 主程序中添加可观测流水线的概念,强化C 主程序的流水线装备办理才能,以支撑C 处理才能间的级联和C 处理才能与Golang处理才能的组合,然后增强C 的主体方位;

  4. 一致商业版和开源版的收集装备格局,均选用流水线办法的装备结构,以习惯最新的iLogtail架构;

  5. 优化收集装备热加载的办法,提高装备容错才能;

  6. 优化商业版代码嵌入开源版代码的路径,经过仅追加文件而非切换文件的办法来完结,提高开发功率。

实践

数据模型通用化

在原有iLogtail架构中,输入、处理和输出模块之间交互的数据模型是依据SLS后端的数据结构LogGroup,其protobuf界说如下:

message Log
{
    required uint32 Time = 1;
    message Content
    {
        required string Key = 1;
        required string Value = 2;
    }
    repeated Content Contents= 2;
    repeated string values = 3;
    optional fixed32 Time_ns = 4;
}
message LogTag
{
    required string Key = 1;
    required string Value = 2;
}
message LogGroup
{
    repeated Log Logs= 1;
    optional string Category = 2;
    optional string Topic = 3;
    optional string Source = 4;
    optional string MachineUUID = 5;
    repeated LogTag LogTags = 6;
}

能够看到,每个LogGroup包括若干Log以及LogTag,以及其他一些元信息。明显,运用这个数据结构作为iLogtail内部的数据模型是有所不足的:

  • 如LogGroup这个姓名所示,该数据结构仅适用于表达可观测数据中的Log,而无法表达Metric和Trace,缺少普适性;
  • LogGroup是一个PB,应当仅仅在终究发送数据时运用,而不合适作为通用的内存数据模型。另一方面,这个PB只适用于SLS,并不适用于其他第三方存储。因而,在往第三方存储发送数据时,需求额定进行数据格局转化,下降收集功率。

因而,在新的架构中,咱们需求将线程间的交互数据模型改成通用数据结构,这样做的优点在于:

  • 支撑表达可观测数据的一切类型,包括Log、Metric和Trace,提高数据结构的普适性;
  • 发送模块可依据本身需求,挑选不同的协议对通用数据结构进行序列化,提高发送协议的灵敏性和功用。

为此,咱们界说如下的数据模型层次:

万字心路历程:从十年迈架构决议重构开端

PipelineEventGroup

PipelineEventGroup是新架构中输入、处理和输出模块间的交互数据结构,与原有架构中的LogGroup相对应,它包括以下的成员变量:

  • mEvents:一组作业;
  • mMetadata:EventGroup同享的元信息,例如机器ip、容器称号、日志路径等;仅在生成EventGroup时可写,且保存于内存中,不必于终究输出;
  • mTags:EventGroup同享的tag,与原有架构中的LogTag相对应,用于保存mMetadata中用户需求实践输出的信息,仅在tag处理插件中可写;
  • mSourceBuffer:EventGroup同享的内存分配器,一切成员变量触及的内存分配均需由该分配器分配。

PipelineEvent

PipelineEvent是一个笼统基类,表示一个作业,它的成员变量包括作业类型、当时作业的收集时刻以及该作业地点的EventGroup。它的子类包括LogEvent、MetricEvent和SpanEvent,它们别离代表可观测数据中的Log、Metric和Trace。

需求强调的是,考虑到内存分配的问题,PipelineEvent不能独立于PipelineEventGroup存在,有必要依附于某一PipelineEventGroup。因而,PipelineEvent的树立只能经过PipelineEventGroup的AddLogEvent、AddMetricEvent和AddSpanEvent函数来进行。

PipelineEventPtr

PipelineEventPtr是PipelineEvent的包装类,它包括一个指向PipelineEvent的指针,对外供给模板函数Is和Cast函数。这样做的意图首要是为了支撑更高效的PipelineEvent与子类之间的转化,而无需调用功率相对较低的dynamic_cast函数,详细转化细节可拜见源码。

插件笼统

可观测流水线是现代可观测数据收集器的必要元素,而可观测流水线的中心组成是插件,包括输入、处理和输出插件。在原有架构中,只要Golang插件系统存在插件概念,C 主程序中缺少相关概念。因而,为了能够树立一致的流水线,有必要在C 主程序中新增插件的概念。

为了一致一切插件的共有行为,咱们首要界说一切类型插件的笼统基类Plugin:

class Plugin {
public:
    virtual ~Plugin() = default;
    virtual const std::string& Name() const = 0;
    // other setters && getters
protected:
    PipelineContext* mContext = nullptr;
};

其间,成员变量mContext指向插件所属流水线(Pipeline)的上下文信息(详细意义将在下文介绍),成员函数Name()回来该插件的姓名。

输入、处理和输出插件均为Plugin的承继类:

万字心路历程:从十年迈架构决议重构开端

处理插件

接口界说

处理插件的笼统基类Processor的界说如下:

class Processor : public Plugin {
public:
    virtual ~Processor() {}
    virtual bool Init(const Json::Value& config) = 0;
    virtual void Process(std::vector<PipelineEventGroup>& logGroupList);
protected:
    virtual bool IsSupportedEvent(const PipelineEventPtr& e) const = 0;
    virtual void Process(PipelineEventGroup& logGroup) = 0;
};

其间,公有成员函数的阐明如下:

  • Init函数:担任依据收集装备实例化插件,并回来是否成功实例化;
  • Process函数:担任对输入的每一个PipelineEventGroup进行处理,并将处理成果经过同一变量回来。
原有才能笼统

在原有架构中,因为假定日志文件仅存在一种格局且只能进行一次格局解析,而且针对某些特定格局的日志有特别的读取逻辑(实践没必要),因而在代码层面选用了一种强耦合的规划办法。详细来说,一切格局的日志同享一个基类LogFileReader,其首要担任读取日志。关于每一种格局的日志,都独自设置一个类承继LogFileReader,其成员函数首要用于对文本日志进行行切分和解析。除此以外,关于一些工具函数,还专门设置一个LogParser类,该类只包括静态成员,本质上是面向进程的包装。

万字心路历程:从十年迈架构决议重构开端

明显,将日志文件读取和日志解析的才能一致放到一个类中是一个不太合理的规划,彻底缺少可扩展性。为此,咱们需求将日志切分(LogSplit函数)和日志解析(ParseLogLine函数)的才能从LogFileReader类中剥离开来,一同和LogParser类中相关的函数进行从头组合,然后形成多个独立的处理插件。

因为Golang和C 或许在某些方面会供给相同的处理才能,为了差异二者,咱们称C 的处理插件为“原生处理插件”,而Golang的处理插件则称为“扩展处理插件”。据此,咱们能够在C 部分笼统出如下几个原生处理插件:

  1. ProcessorSplitLogStringNative:日志切分处理插件,用于对日志块依照指定分隔符进行切分生成多个作业;

  2. ProcessorSplitRegexNative:日志切分处理插件,用于对日志块依照正则表达式进行切分生成多个作业;

  3. ProcessorParseRegexNative:正则解析插件,经过正则匹配解析作业指定字段内容并提取新字段;

  4. ProcessorParseJsonNative:Json解析插件,解析作业中Json格局字段内容并提取新字段;

  5. ProcessorParseDelimiterNative:分隔符解析插件,解析作业中分隔符格局字段内容并提取新字段;

  6. ProcessorParseTimestampNative:时刻解析插件,用于解析作业中记录时刻的字段,并将成果置为作业的__time__字段;

除了上述处理才能外,原有iLogtail还供给了字段过滤和脱敏的处理才能,它们均归于LogFilter类的才能,在日志发送前被调用(调用点也不合理)。为此,咱们也将这两种处理才能笼统成原生处理插件:

  1. ProcessorFilterRegexNative:作业过滤插件,用于依据作业字段内容来过滤作业;

  2. ProcessorDesensitizeNative:脱敏插件,用于对作业的字段内容进行脱敏;

终究,咱们在前文说到,PipelineEventGroup的mTag成员是从mMetadata成员中获取的,而这一进程也需求新增如下的原生处理插件来完结:

  1. ProcessorTagNative:tag处理插件,用于将PipelineEventGroup的mMetadata成员挑选性地加入mTag成员用于终究输出,一同支撑对tag的key进行重命名。

至此,咱们现已将C 主程序的处理才能笼统成9个独立的原生处理插件,一同简化LogFileReader类使其专注于文件读取功用,并删去LogFileReader类的一切承继类、LogFilter类和LogParser类。

输入插件

接口界说

输入插件的笼统基类Input的界说如下:

class Input : public Plugin {
public:
    virtual ~Input() = default;
    virtual bool Init(const Json::Value& config, Json::Value& optionalGoPipeline) = 0;
    virtual bool Start() = 0;
    virtual bool Stop(bool isPipelineRemoving) = 0;
};

其间,公有成员函数的阐明如下:

  • Init函数:担任依据收集装备实例化插件,并回来是否成功实例化以及或许的Golang流水线组件;
  • Start函数:发动输入插件;
  • Stop函数:依据流水线是否即将被移除,采纳不同的战略中止输入插件;
原有才能笼统

C 部分的收集输入才能包括日志文件收集和eBPF方针收集。出于篇幅和典型性的考虑,本文仅以日志文件收集的才能笼统为例阐明输入才能的插件笼统,eBPF方针收集的插件笼统可直接参阅代码。

咱们在前文说到,关于iLogtail的Golang插件系统,每一条流水线都具有彻底独立的资源。详细来说,每条流水线的输入、处理和发送模块都各自有一个独立的线程在作业,线程之间经过流水线独享的缓冲行列来交换数据。这种规划十分直观,能够确保流水线之间互不影响。可是,此种办法的最大问题在于资源耗费,即整个客户端所耗费的线程数量与流水线的数量成正比。关于资源受限场景,这种资源无限增长的特性会对服务器产生较大压力,乃至产生负面影响。

比较于Golang,功用是C 的优势。因而,在原有的C 部分,文件收集选用的是总线办法,即只用一个线程来轮番收集每个装备指定的日志文件(实践不止一个线程,可是数量固定,不与装备数量相关)。明显,从功用视点考虑,即便咱们要将文件收集笼统成输入插件,咱们依然应该坚持原有的总线办法,即一切文件输入插件同享一个线程。但这种“一对多”的办法就会产生一个对立点:输入插件的接口语义是独立发动和中止的,可是选用总线办法明显有必要一切的文件输入插件一致发动和中止,而非每条流水线独立启停。

那怎么处理这种“一对多”引进的对立呢?咱们能够借鉴规划办法中的署理办法(Proxy)思维,新增一个大局办理文件读取的类FileServer,该类具有一个线程担任顺次轮番读取一切文件输入插件指定的文件。而文件输入插件的Start和Stop函数仅仅将当时插件的装备注册到FileServer类中和从类中删去,并视状况调用FileServer类的Start和Stop函数履行真实的收集启停。

万字心路历程:从十年迈架构决议重构开端

据此,文件输入插件的Start和Stop函数别离如下所示:

bool InputFile::Start() {
    if (!FileServer::GetInstance()->IsRunning()) {
        FileServer::GetInstance()->Start();
    }
    if (mEnableContainerDiscovery) {
        mFileDiscovery.SetContainerInfo(
            FileServer::GetInstance()->GetAndRemoveContainerInfo(mContext->GetPipeline().Name()));
    }
    FileServer::GetInstance()->AddFileDiscoveryConfig(mContext->GetConfigName(), &mFileDiscovery, mContext);
    FileServer::GetInstance()->AddFileReaderConfig(mContext->GetConfigName(), &mFileReader, mContext);
    FileServer::GetInstance()->AddMultilineConfig(mContext->GetConfigName(), &mMultiline, mContext);
    FileServer::GetInstance()->AddExactlyOnceConcurrency(mContext->GetConfigName(), mExactlyOnceConcurrency);
    return true;
}
bool InputFile::Stop(bool isPipelineRemoving) {
    if (!FileServer::GetInstance()->IsPaused()) {
        FileServer::GetInstance()->Pause();
    }
    if (!isPipelineRemoving && mEnableContainerDiscovery) {
        FileServer::GetInstance()->SaveContainerInfo(mContext->GetPipeline().Name(), mFileDiscovery.GetContainerInfo());
    }
    FileServer::GetInstance()->RemoveFileDiscoveryConfig(mContext->GetConfigName());
    FileServer::GetInstance()->RemoveFileReaderConfig(mContext->GetConfigName());
    FileServer::GetInstance()->RemoveMultilineConfig(mContext->GetConfigName());
    FileServer::GetInstance()->RemoveExactlyOnceConcurrency(mContext->GetConfigName());
    return true;
}

能够看到,文件输入插件InputFile类的Start函数只做了如下两件事:

  1. 假如文件收集总线程未发动,则调用FileServer类的Start函数发动线程;
  2. 将插件相关装备注册到FileServer类中。

相似的,InputFile类的Stop函数也只做了两件事:

  1. 假如文件收集线程未暂停,则调用FileServer类的Stop函数暂停大局文件收集;
  2. 将插件相关装备从FileServer类中删去。

经过这种署理办法,咱们奇妙地将文件收集的详细完结隐藏在文件输入插件InputFile背面,然后对外供给了一致的接口描绘,提高了代码的可扩展性和可保护性。

可扩展性

虽然原有的C 输入插件仅有2个,可是在完结了本次架构晋级后,新增C 输入不再是一个难题。虽然文件输入插件选用了总线办法,但这并不意味着新的结构不支撑相似于Golang输入插件那样的独立运转办法。关于某些输入源,假如现已有包装较好的SDK,那选用总线办法来收集明显会十分费事,选用每个插件独立运转或许是一个更便利的完结办法。但不管怎样,从功用视点动身,咱们依然引荐一切输入插件都选用相似文件收集的总线办法来节约资源和提高功率。

输出插件

接口界说

输出插件的笼统基类Flusher的界说如下:

class Flusher : public Plugin {
public:
    virtual ~Flusher() = default;
    virtual bool Init(const Json::Value& config, Json::Value& optionalGoPipeline) = 0;
    virtual bool Start() = 0;
    virtual bool Stop(bool isPipelineRemoving) = 0;
};

其间,公有成员函数的阐明如下:

  • Init函数:担任依据收集装备实例化插件,并回来是否成功实例化以及或许的Golang流水线组件;
  • Start函数:发动输出插件;
  • Stop函数:依据流水线是否即将被移除,采纳不同的战略中止输出插件;
原有才能笼统

C 部分的数据发送才能只包括往日志服务(SLS)发送数据,因而只需将该才能笼统成SLS输出插件FlusherSLS即可。与文件收集相似,原有的SLS发送才能也选用的是总线办法。因而,在完结SLS输出插件时,咱们也选用相似文件输入插件的办法,保存总线办法,即有一个大局办理发送的类SLSSender,该类具有一个线程担任顺次轮番发送一切SLS输出插件的数据。与文件收集不同的是,在流水线改变期间,发送线程是无需中止的。因而,SLS输出插件的Start和Stop函数仅仅将当时插件的装备注册到SLSSender类中和从类中删去,并不触及真实的发送启停。

万字心路历程:从十年迈架构决议重构开端

据此,SLS输出插件的Start和Stop函数别离如下所示:

bool FlusherSLS::Start() {
    SLSSender::Instance()->IncreaseProjectReferenceCnt(mProject);
    SLSSender::Instance()->IncreaseRegionReferenceCnt(mRegion);
    SLSSender::Instance()->IncreaseAliuidReferenceCntForRegion(mRegion, mAliuid);
    return true;
}
bool FlusherSLS::Stop(bool isPipelineRemoving) {
    SLSSender::Instance()->DecreaseProjectReferenceCnt(mProject);
    SLSSender::Instance()->DecreaseRegionReferenceCnt(mRegion);
    SLSSender::Instance()->DecreaseAliuidReferenceCntForRegion(mRegion, mAliuid);
    return true;
}

能够看到,SLS输出插件flusherSLS类的Start和Stop函数仅仅将插件相关装备注册到SLSSender类中或从类中删去。明显,经过这种署理办法,咱们也将SLS发送的详细完结隐藏在SLS输出插件背面,然后对外供给了一致的接口描绘。

可扩展性

与输入插件相似,结构关于输出插件也一同支撑总线办法和独立运转办法。但同样,从功用视点动身,咱们依然引荐一切输出插件都选用总线办法来节约资源和提高功率。

流水线笼统

与插件相似,在原有架构中,仅Golang插件系统存在流水线的概念,C 主程序中仅有收集装备而缺少流水线的概念。因而,需求在C 主程序中新增流水线概念,这样做的优点在于:

  • 一致C 主程序和Golang插件系统的流水线,加强C 主程序的主体方位;
  • 支撑C 处理才能的级联,极大地提高C 部分关于杂乱日志的处理才能;
  • 便于C 插件和Golang插件的组合,然后供给更灵敏的插件编列才能,一同从产品层面供给更加一致的视图。

插件编列

咱们界说iLogtail的流水线Pipeline为如下形状:

万字心路历程:从十年迈架构决议重构开端

能够看到,每条流水线可包括恣意个输入、处理和输出插件,插件类型既能够是C 插件,也能够是Golang插件,但存在如下的仅有约束:原生处理插件仅可出现在扩展处理插件之前,即不允许在运用扩展处理插件后再运用原生处理插件! 添加此项约束首要依据如下考量:

  • 从产品层面,扩展处理插件仅起到辅助作用,仅在单纯运用原生处理插件无法满意处理需求时运用。因而,扩展处理插件相关于原生处理插件而言是补充和追加联系,而非对等联系。
  • 从架构层面,毕竟原生处理插件和扩展处理插件别离由不同的言语完结,二者之间的交互有必要经过CGO接口来完结。从功用视点,应当尽或许防止频频的CGO接口调用,因而在处理阶段,只允许数据单向地从C 主程序流向Golang插件系统。
⚠️以上插件编列约束描绘针对的是终究架构,因为架构晋级实践是分阶段进行的,故不同版本的实践约束请拜见源码和配套的阐明文档。

依据以上描绘,在新架构中,数据的或许通路如下所示:

万字心路历程:从十年迈架构决议重构开端

在流水线初始化阶段,依据不同的插件组合,选定终究的数据通路。关于有多条通路可供挑选的插件组合,咱们以如下原则选定终究通路:

  1. 数据通路应尽或许多地经过C 组件;
  2. 数据通路应当尽或许减少CGO接口;

万字心路历程:从十年迈架构决议重构开端

因为Golang插件系统的运转也是以流水线的办法进行的,并不能以插件的办法独自存在,因而咱们从头界说Golang流水线为流水线的子流水线。从上图中也能看到,Golang子流水线或许有两种办法:

  • 包括输入插件:如2、3、5组合或许2、4组合;
  • 不包括输入插件:如1、3、4组合。

因而,关于恣意一条流水线,其或许不含Golang子流水线,也或许包括多条Golang子流水线,但最多只或许存在两条(如插件组合2、3、4),详细状况视插件编列成果而定。

插件实例

明显,一条流水线中能够存在多个相同的插件。为了差异同名插件,以及便利插件运转状况的可观测,咱们需求额定在插件之上添加一层封装——插件实例。关于流水线而言,它办理的仅仅插件实例,对插件本身无感。一切对插件实例的操作实践上是在操作插件本身,因而这实践上也是规划办法中署理办法(Proxy)在iLogtail新架构中的又一次运用。

与插件相似,为了一致一切插件实例的共有行为,咱们也会首要界说一切类型插件实例的笼统基类PluginInstance,然后在此根底上派生出不同类型的插件实例:InputInstance、ProcessInstance和FlusherInstance。整个类层次如下图所示:

万字心路历程:从十年迈架构决议重构开端

能够看到,每个插件实例都有一个mId成员用于仅有标识一个插件实例,以及一个mPlugin成员指向真实的插件。

反应行列

除了插件实例,流水线中另一个重要的组成部分是连接各模块的缓冲行列,它在资源管控和处理突发流量方面起到着重要作用。

在原有架构中,C 部分的缓冲行列都是以LogStore为粒度的,即每个Logstore一个行列。明显,以Logstore作为行列的颗粒度是不合适的,原因包括:

  • LogStore是SLS特有的概念,关于往第三方存储发送数据的场景(例如开源场景),没有Logstore的概念,只能默许一切的装备共用一个Logstore,即一切装备共用一个缓冲行列。这明显是不合理的。
  • 即便是往日志服务投递数据的场景,因为一个Logstore包括多个收集装备,因而难以经过反应行列完结装备级的资源管控。

另一方面,关于Golang插件系统,因为天然的流水线资源独立性,缓冲行列自然是流水线等级的。比较于原有C 的完结,这明显更契合装备等级资源管控的实践需求。可是与线程资源一样,缓冲行列的数量也是与流水线数量直接成正比的,也意味着内存运用会明显高于C 原有的完结办法。

那有没有什么办法既能够完结装备等级的资源管控,又能尽或许少地占用资源呢?

答案自然是必定的,咱们能够选用如下图所示的架构:

万字心路历程:从十年迈架构决议重构开端

阐明如下:

  • 与Golang插件系统相似,每个流水线具有一个独立的处理行列;
  • 关于发送行列,从Process线程的视点看,每个发送插件具有一个发送行列。但实践上这个发送行列内部可进一步包括多个子行列,例如SLS输出插件依然坚持每个Logstore一个发送行列;
  • 发送行列与处理行列之间的反应不再是一对一的联系,而是改成多对一的联系。

运用这样的架构有如下优点:

  • 因为流水线的资源管控往往只需求对流水线的源头进行操控即可,因而处理行列坚持以流水线为粒度能够确保流水线资源操控的正常进行,一同还便于对流水线进行优先级的差异。
  • 因为不同的发送服务端有着不同的资源管控粒度(例如SLS对Logstore的流量有约束),但这些细节关于Process线程来说没有意义。因而,经过规划办法中的署理办法(Proxy)坚持一个发送插件实例一个逻辑上的发送行列能够最大程度简化类间交互,增强可扩展性,一同下降内存运用。
  • 运用规划办法中的观察者办法(Observer)有助于提高反应行列交互的可扩展性。

流水线界说

至此,咱们能够给出流水线Pipeline的界说:

class Pipeline {
public:
    bool Init(Config&& config);
    void Start();
    void Process(std::vector<PipelineEventGroup>& logGroupList);
    void Stop(bool isRemoving);
    // other getters & setters
private:
    std::string mName;
    std::vector<std::unique_ptr<InputInstance>> mInputs;
    std::vector<std::unique_ptr<ProcessorInstance>> mProcessorLine;
    std::vector<std::unique_ptr<FlusherInstance>> mFlushers;
    Json::Value mGoPipelineWithInput;
    Json::Value mGoPipelineWithoutInput;
    FeedbackQueue<PipelineEventGroup> mProcessQueue;
    mutable PipelineContext mContext;
    std::unique_ptr<Json::Value> mConfig;
    // other private members
};

其间的公有成员函数阐明如下:

  • Init函数:依据收集装备进行插件编列,实例化一切的C 插件,并加载或许存在的Golang子流水线;
  • Start函数:依照从输出到输出的次序(即数据通路图中的5至1次序)顺次发动各个组件;
  • Process函数:按次序运用C 插件对输入的PipelineEventGroup列表进行处理;
  • Stop函数:依照从输入到输出的次序(即数据通路图中的1至5次序)顺次中止各个组件。

成员变量首要包括:

  • mName:流水线的姓名,与收集装备名相同;
  • mInputs:C 输入插件实例列表;
  • mProcessors:原生处理插件实例列表;
  • mflushers:C 输出插件实例列表;
  • mGoPipelineWithInput:包括输入插件的Golang子流水线,可选;
  • mGoPipelineWithoutInput:不包括输入插件的Golang子流水线,可选;
  • mContext:流水线上下文;
  • mProcessQueue:当时流水线的处理行列;
  • mConfig:收集装备的原始内容。

其间需求阐明的是mContext成员,它归于PipelineContext类,该类首要用于记录流水线的一些信息,便于流水线中的插件获取。PipelineContext类的成员首要包括:

  • mConfigName:流水线的称号;
  • mGlobalConfig:流水线等级的装备,由收集装备给出;
  • mPipeline:指向当时流水线的指针;
  • mLogger和mAlarm:用于打印日志和发送告警的大局组件。

收集装备办理优化

原有代码中的收集装备办理模块底子由ConfigManager类来担任,但完结极为紊乱,没有任何规划思维可言,和其它模块的耦合严峻,在开源社区面对“一改就错”的为难地步。因而,在新架构中,原有的收集装备办理模块将悉数抛弃,在确保兼容性的前提下,从头规划相关功用。

装备格局

限于前史原因,iLogtail可识别的收集装备格局包括两种:

商业版装备:选用平铺结构,没有任何层次,且仅支撑JSON格局

{
    "aliuid": "1234567890",
    "category": "test_logstore",
    "create_time": 1693370409,
    "defaultEndpoint": "cn-shanghai-intranet.log.aliyuncs.com",
    "delay_alarm_bytes": 0,
    "delay_skip_bytes": 0,
    "discard_none_utf8": false,
    "discard_unmatch": true,
    "docker_exclude_env": {},
    "docker_exclude_label": {},
    "docker_file": false,
    "docker_include_env": {},
    "docker_include_label": {},
    "enable": true,
    "enable_tag": false,
    "file_encoding": "utf8",
    "file_pattern": "*.log",
    "filter_keys": [],
    "filter_regs": [],
    "group_topic": "aaaaaaab",
    "keys": [
        "k1,k2"
    ],
    "local_storage": true,
    "log_begin_reg": ".*",
    "log_path": "/home",
    "log_type": "common_reg_log",
    "log_tz": "",
    "max_depth": 10,
    "max_send_rate": -1,
    "merge_type": "topic",
    "preserve": true,
    "preserve_depth": 1,
    "priority": 0,
    "project_name": "test-project",
    "raw_log": false,
    "regex": [
        "(\d )x(.*)"
    ],
    "region": "cn-shanghai",
    "send_rate_expire": 0,
    "sensitive_keys": [],
    "tail_existed": false,
    "timeformat": "",
    "topic_format": "none",
    "tz_adjust": false,
    "version": 3
}

开源版装备:选用流水线结构,有较好的层次,但只支撑YAML格局

inputs:
  - Type: file_log
    LogPath: /home
    FilePattern: '*.log'
    MaxDepth: 10
processors:
  - Type: processor_regex_accelerate
    Regex: '(\d )x(.*)'
    Keys: ["k1", "k2"]
flushers:
  - Type: flusher_sls
    ProjectName: test-project
    LogstoreName: test_logstore
    Endpoint: cn-shanghai-intranet.log.aliyuncs.com

为了匹配新架构,iLogtail 2.0启用全新的收集装备结构:

万字心路历程:从十年迈架构决议重构开端

其间,inputs、processors、aggregators和flushers中可包括恣意数量的插件,包括C 插件和Golang插件。

装备文件安排

在原有架构中,装备文件的安排没有一致的标准,包括文件格局不一致和寄存方位不一致:

  • 商业版管控端下发的装备为一个文件寄存一切的收集装备,文件格局仅支撑JSON,默许方位为/usr/local/ilogtail/user_log_config.json;
  • 本地商业版装备既支撑一个文件一个收集装备,也支撑一个文件多个收集装备,文件格局仅支撑JSON,默许寄存方位为/etc/ilogtail/user_config.d目录和user_local_config.json;
  • 本地开源版装备仅支撑一个文件一个收集装备,文件格局仅支撑YAML,默许寄存方位为/etc/ilogtail/user_yaml_config.d目录;
  • 开源版管控端下发的装备为一个文件一个收集装备,文件格局仅支撑YAML,默许寄存方位为/etc/ilogtail/remote_yaml_config.d目录;

为了一致上述紊乱的状况,一同供给可扩展性,在新架构中,一致选用下述规矩来安排文件:

  • 每个文件寄存一个收集装备,文件名即为收集装备名;
  • 文件名后缀标识文件格局,支撑json和yaml(或yml);
  • 同一来源的收集装备放在同一个目录下,默许寄存方位为/etc/ilogtail/config/,其间代表来源,现在包括:
    • 商业版管控端下发的装备:enterprise
    • 开源版管控端下发的装备:common
    • 本地:local

装备热加载

在新架构中,对收集装备改变的监控悉数经过监控磁盘装备文件是否改变来完结,相关作业一致由ConfigWatcher类来担任:

class ConfigWatcher {
public:
    static ConfigWatcher* GetInstance();
    ConfigDiff CheckConfigDiff();
    void AddSource(const std::string& dir, std::mutex* mux = nullptr);
private:
    std::vector<std::filesystem::path> mSourceDir;
    std::map<std::string, std::pair<uintmax_t, std::filesystem::file_time_type>> mFileInfoMap;
    // other members
};

能够看到,ConfigWatcher类对外供给两个办法:

  • AddSource函数:向mSourceDir注册新的需求监控的寄存收集装备的目录;
  • CheckConfigDiff函数:检查一切被监控目录的收集装备文件是否有改变,回来新增、删去和存在修正的装备(记录在ConfigDiff结构体中),并在mFileInfoMap中更新最新的文件状况。

这儿要点重视一下CheckConfigDiff函数,该函数不仅仅判别收集装备文件的状况是否有改变,还会解析装备并检查装备的合法性,整个流程如下所示:

万字心路历程:从十年迈架构决议重构开端

当CheckConfigDiff函数回来非空,则会进一步调用PipelineManager类的UpdatePipelines函数将装备加载成实践的流水线:

void logtail::PipelineManager::UpdatePipelines(ConfigDiff& diff) {
    for (const auto& name : diff.mRemoved) {
        mPipelineNameEntityMap[name]->Stop(true);
        mPipelineNameEntityMap.erase(name);
    }
    for (auto& config : diff.mModified) {
        auto p = BuildPipeline(std::move(config));
        if (!p) {
            continue;
        }
        mPipelineNameEntityMap[config.mName]->Stop(false);
        mPipelineNameEntityMap[config.mName] = p;
        p->Start();
    }
    for (auto& config : diff.mAdded) {
        auto p = BuildPipeline(std::move(config));
        if (!p) {
            continue;
        }
        mPipelineNameEntityMap[config.mName] = p;
        p->Start();
    }
}

能够看到,选用上述两步走的装备热加载办法,能够最大程度提高流水线的容错才能,即仅当收集装备对应的流水线彻底合法时才会进行加载。关于正在运转的流水线,假如因为某些原因导致对应的收集装备文件非法,则现在正在运转的流水线仍会继续正常运转,不会被非法的收集装备影响。

长途装备下发

在原有架构中,一切的长途装备下发功用(包括商业版和开源版管控端)都由ConfigManager类来担任,彻底不具有可扩展性。为了处理这一问题,在新架构中,咱们界说笼统基类ConfigProvider类用于一致一切拉取长途装备的行为:

class ConfigProvider {
public:
    virtual void Init(const std::string& dir);
    virtual void Stop() = 0;
protected:
    std::filesystem::path mSourceDir;
    mutable std::mutex mMux;
};

其间,各成员函数的阐明如下:

  • Init函数:履行初始化操作,创立mSourceDir目录并调用ConfigWatcher类的AddSource函数注册目录,一同发动线程守时拉取远端装备。
  • Stop函数:中止ConfigProvider。

关于不同的装备来源,能够从ConfigProvider类派生不同的子类,现在包括:

  • 商业版管控端装备拉取:EnterpriseConfigProvider类;
  • 开源版管控端装备拉取:CommonConfigProvider类。

进程装备办理优化

在原有架构中,非收集装备级(即进程级和模块级)参数一致由AppConfig类进行办理,由此带来的后果包括:

  • AppConfig类无限增长,内部缺少有用安排,时刻一久便难以保护;
  • 几乎一切模块都要经过AppConfig类来获取参数,因而代码中存在许多的AppConfig::GetInstance()函数,造成代码冗余和阅览不便。
  • 有一些参数仅在商业版中运用,导致AppConfig类需求保护开源版和商业版两份,添加出现不一致的概率。

为了处理这一问题,在新架构中,AppConfig类仅保护进程等级的参数(如内存上限等)和多个模块共用的参数。关于其他仅在单个模块中运用的参数,一致在相应的模块中保护,然后有用处理上述问题。

商业版代码嵌入办法优化

在原有架构中,因为类的功用边界模糊,各个类之间存在严峻的依靠和耦合,因而商业版特有的功用代码散落在各个文件中,导致无法从代码库中洁净剥离,只能经过文件替换的办法来完结开源和商业版代码的切换,严峻影响开发功率。

为了彻底处理这一问题,需求将商业版功用进行归类和从头整合。然后,针对不同的需求和场景,运用不同的嵌入战略:

商业版独有的功用:

  • 组成独自的类放在独自的文件中,直接追加到开源版的目录中;
  • 关于公共文件中的调用点,运用__ENTERPRISE__宏来操控开源和商业版的编译行为;
例: 商业版代码中运用ShennongManager类来收集特定方针,该类包括一个线程资源,需求在装备改变时暂停和发动线程。因而,在PipelineManager类中存在如下调用点:
#ifdef __ENTERPRISE__
  ShennongManager::GetInstance()->Pause();
#endif
// ...
#ifdef __ENTERPRISE__
  ShennongManager::GetInstance()->Resume();
#endif

商业版和开源版行为存在差异:

  • 尽或许运用单例办法;
  • 将开源版的类作为基类,然后将类中行为不同的办法声明为虚函数;
  • 将商业版的类作为开源类的派生类,并重写虚函数;
  • 在GetInstance函数中运用__ENTERPRISE__宏和指向基类的指针来操控实践生效的类;
  • 将商业版文件直接追加到开源版的目录中;
例:商业版和开源版在发送可观测数据方面存在差异,因而界说开源版的ProfileSender类如下:
class ProfileSender {
public:
    static ProfileSender* GetInstance();
    virtual void SendToProfileProject(const std::string& region, sls_logs::LogGroup& logGroup);
// other members
};
ProfileSender* ProfileSender::GetInstance() {
#ifdef __ENTERPRISE__
    static ProfileSender* ptr = new EnterpriseProfileSender();
#else
    static ProfileSender* ptr = new ProfileSender();
#endif
    return ptr;
}

为了完结上述才能,需求对原有代码进行重安排,首要作业如下:

  • 将与商业版装备拉取相关的代码从ConfigManager类和EventDispatcher类中剥离出来,从头组成EnterpriseConfigProvider类;
  • 将与商业版鉴权相关的代码从ConfigManager类中剥离出来,移动到EnterpriseSLSControl类中;
  • 将与商业版可观测数据发送相关的代码从ConfigManager类中剥离出来,移动到EnterpriseProfileSender类中;
  • 将商业版特别的方针监控代码从EventDispatcher类中剥离出来,从头组成ShennongManager类;
  • 将与商业版相关的非装备级参数从AppConfig类中剥离出来,别离移动到上述新树立的类中。

除此以外,因为主文件中没有类的概念,因而将与装备办理相关的内容从主文件logtail.cpp剥离出来,和原EventDispatcher类中的Dispatch函数进行重组,形成Application类,尽量完结开源版和商业版的代码复用。

在完结上述一切作业之后,终究修CMakeLists.txt文件,添加如下逻辑:

option(ENABLE_ENTERPRISE "enable enterprise feature")
if (ENABLE_ENTERPRISE)
    add_definitions(-D__ENTERPRISE__)
    include(${CMAKE_CURRENT_SOURCE_DIR}/enterprise_options.cmake)
else ()
    include(${CMAKE_CURRENT_SOURCE_DIR}/options.cmake)
endif ()

至此,商业版代码与开源版代码的分离作业悉数完结,商业版代码文件以纯追加的办法嵌入到开源版代码中,再也不必替换文件,极大地提高了开发功率。

考虑

从决议重构之初到iLogtail 2.0形状初现,前后至少阅历了大半年的时刻。在传统认知里,重构是一件吃力不讨好的作业,稍不留神就会引发各种兼容性问题,乃至毛病。尤其是关于iLogtail这种已有10年前史,前史包袱十分重的产品而言,进行架构晋级能够说是如履薄冰。但即便如此,为什么还要坚持去做重构?一句话,长痛不如短痛。10年前的需求与现行产品定位之间的差异日益增大,强行在原有架构上继续演进只会带来更多潜在的问题,乃至于无法演进。因而,想要在可观测数据收集范畴继续引领行业,在开源社区扩大影响,架构晋级是一个必经的途径。

话虽如此,对原有的iLogtail进行架构晋级绝非易事。从决议重构到终究成型,在此期间遇到了诸多挑战和困难,也走了不少的弯路,这儿简略总结一下:

1. 新架构应该怎么规划?

虽然知道原有架构不合理,且对开展方向有一个大约的认知,可是新架构究竟怎么规划却是一个值得商榷的问题。明显,关于任何范畴,没有输入自然就不会有输出。得益于日常对其他干流可观测数据收集器架构的继续调研和学习,笔者对现代可观测流水线的底子理念和规划思维有了一个底子的认知。在规划iLogtail的新架构时,首要选用如下原则:

  • 关于可观测流水线的通用概念(如数据类型和流水线界说等),iLogtail要尽量做到和范畴内其它竞品坚持一致,防止别出心裁给用户搬迁带来不便和困惑;
  • 关于架构完结,不能简略照搬其他干流可观测数据收集器的架构,而是在吸收其规划思维的前提下,针对iLogtail本身的特色(如双言语完结)进行原创规划,合适自己的才是最好的。
  • 关于iLogtail的本身优势(如C 的高功用和装备热加载),在完好保存的一同,还需求将本来阻止优势发挥的约束尽或许地去除,使得本身优势能够在更多的场景中发挥作用,提高产品的中心竞争力。

2. 确定好新架构后,怎么分阶段来完结架构晋级?

从前文的介绍能够看到,新架构与原有架构的差异较大,晋级触及到的模块众多,作业量大。明显,一口气完结架构晋级是不可行的,有必要分阶段分模块进行,在CI的配合下确保每一个模块的重构都是契合预期的。

那怎么分阶段呢?这儿就走过一些弯路,因为从架构规划的视点,咱们会习惯性地从外往里进行考虑(即依照上文实践一节从后往前的次序),但这会陷入一个层层依靠的问题。例如,在重构装备办理模块的时分,会依靠Pipeline类和PipelineManager类。为此,需求优先重构这两个类。可是重构这两个类的时分,又会依靠各种插件类的完结。依照这个次序进行下去,终究的依靠便是PipelineEvent类。明显,依照这个次序进行下去,相当于一口气完结架构晋级,因而底子不可行。

正确的做法是由里向外进行重构,先重构PipelineEvent类,再重构插件类,以此类推终究重构Application类,即依照上文介绍的次序。凭借这个次序,就能够将整个架构晋级进程分成至少6个大阶段,每个阶段都能够独自CI而不会影响既有功用的正常作业。可是,想要正确履行这种次序,就有必要要求对方针架构有一个完好明晰的认知和详细的规划。假如对方针架构的知道只逗留在粗结构的层面,那必定无法精确得到各个模块的依靠联系,然后得到并非彻底正确的阶段差异,终究影响实践重构的进度和功率。

任何时分,想清楚了再做永远是事半功倍的底子前提,关于架构晋级来说尤甚。

3. iLogtail原有的测验系统不健全,怎么确保重构后的代码不引进兼容性问题?

这或许是iLogtail重构最头疼的问题,原有的iLogtail UT代码覆盖率不高,回归测验只覆盖干流场景,关于小众功用底子归于监控盲区。为此,只能对原有代码进行完好的梳理和阅览,要点重视如下几个点:

  • 每个类详细担任的功用,为后续类合并和重构奠定根底;
  • 类间依靠,尤其是相关参数在多个类内运用的状况;
  • 不常用的功用点,了解其预期行为,然后为补充UT作准备。

当然,上述办法也只能尽或许防止重构引发的不兼容问题,可是在现有的条件和时刻允许范围内,这现已是最佳战略。事实上,在整个架构晋级进程中,有大约2个月左右的时刻是在履行上述操作的,这也为后续实践重构奠定了坚实的根底。

4. 怎么确保代码质量?

在决议进行架构晋级时,笔者才从业一年,虽说有必定的C 开发经验,可是关于重构这么大的事的确没有阅历过。怎么确保新写的代码契合通用标准,一同又确保运转功率,是一个亟需处理的问题。

为此,在规划新架构的间隙,笔者又从头翻阅了一些经典作品,例如规划办法相关的《Design Patterns: Elements of Reusable Object-Oriented Software》,C 开发相关的《Effective C 》系列、《C Concurrency in Action》等书本,一同也经过一些博客和官方文档学习C 17的新特性。与之前抱着学习的情绪去阅览不同,当你带着问题和需求去从头阅览这些作品时,会更能领悟到书中一些总结性经验的实践意义。凭借着这些消化吸收后的经验,笔者一步一步打磨自己的代码,并适时对新代码进行小范围的二次重构以增强代码的复用和可扩展性。

总结

回想整个架构晋级的进程,从接受任务时的苍茫,到终究晋级底子完结时的高兴,半年多的时刻阅历了许多,也成长了许多。关于iLogtail而言,阅历本次架构晋级,也算是浴火重生,向着现代顶流可观测数据收集器的方针又迈进了一大步。不管关于用户,仍是关于社区开发者,信任一切人都会从本次架构晋级中受益。让咱们一同期待iLogtail在未来继续蓬勃开展,供给更快更强的数据收集才能!