赞
踩
作者:笃敏
iLogtail是一款高性能的轻量级可观测数据采集器,由阿里云SLS团队官方提供,可以运行在包括服务器、容器和嵌入式等多种环境中,其宗旨在于帮助开发者构建统一的数据采集层,助力可观测平台打造各种上层应用场景。iLogtail多年来一直稳定服务阿里集团、蚂蚁集团以及众多公有云上的企业客户,目前已经有千万级的安装量,每天采集数十PB的可观测数据,广泛应用于线上监控、问题分析/定位、运营分析、安全分析等多种场景。
早在2013年,iLogtail作为阿里巴巴集团飞天5K项目中负责机器监控及日志收集的核心组件,已经被广泛地应用于集团内部机器。如今,10多年过去了,伴随着云原生和可观测性概念的逐步推广,iLogtail在商业化和开源的过程中也经历了一系列的架构迭代。
该阶段是iLogtail的起步阶段,也是iLogtail的命名由来,其主要能力是采集和解析日志文件并发送至日志服务后端进行存储。功能上,这一阶段的iLogtail具有如下特点:
基于上述功能需求,这一阶段的iLogtail架构及实现具有如下特点:
随着可观测性概念的提出,iLogtail不再停留于单一的日志采集场景,逐步向更普适的可观测数据采集器领域发展。显然,要成为顶流的可观测数据采集器,必须至少满足以下几个条件:
由于C++的开发生态有限,为了在短期内能够快速实现上述目标,iLogtail在起步阶段的基础上引入了基于Golang语言开发的插件系统,其整体架构演变为了如下所示的结构:
Golang插件系统是基于现代可观测处理流水线的思想进行设计的,具有如下特点:
可以看到,Golang插件系统的引入极大地扩展了iLogtail的输入输出通道,且一定程度提升了iLogtail的处理能力。然而,囿于C++部分的实现,输入输出与处理模块间的组合能力仍然是受限的,仅支持以下几种数据通路:
采集日志文件并使用C++的处理能力,最后将数据投递至日志服务SLS(1和4号组合);
采集日志文件并使用Golang插件进行处理,最后将数据投递至日志服务SLS(2和4号组合);
采集日志文件并使用Golang插件进行处理,最后将数据投递至第三方存储(2和5号组合);
采集其它输入(如syslog)并使用Golang插件进行处理,最后将数据投递至日志服务SLS(3和4号组合);
采集其它输入(如syslog)并使用Golang插件进行处理,最后将数据投递至第三方存储(3和5号组合)。
由此可见,相比于起步阶段的iLogtail,该阶段的iLogtail架构具备如下特点:
从前面的描述可知,原有iLogtail架构最大的问题在于输入输出与处理模块间的组合能力受限,这直接影响了商业版iLogtail在解析复杂日志场景中的性能。然而,随着iLogtail的开源,这一问题的矛盾变得更加突出:开源社区绝大多数用户都选择将数据投递至第三方存储,这意味着绝大多数社区用户将无法享受到iLogtail原生的高性能处理能力!除此之外,iLogtail的开源还将原本并不显著的问题暴露出来:
由于C++主程序代码存在错综复杂的类间依赖关系,导致开发难度极大,加之C++的处理能力无法被社区所使用,因此C++主程序的开发几乎无人问津。
不论是C++主程序还是Golang插件系统,其内部的数据交互模型只适用于可观测数据中的Log,而无法表达Metric和Trace。除此以外,这些数据结构均针对SLS而设计,导致在向第三方存储系统投递数据时,必须进行额外的数据结构转换,从而降低整体的性能。
碍于C++主程序代码错综复杂的类间依赖关系,商业版代码与开源版的剥离只能采用非常原始和丑陋的文件替换方式。这种操作直接导致如下两个结果:
综上所述,不论是从产品演进,还是从开发体验,原有iLogtail架构已经严重制约了其快速发展。因此,对iLogtail的架构进行升级已经迫在眉睫。
《重构:改善既有代码的设计》一书中对重构的意义和方法论有详细的阐述。对于iLogtail而言,本次重构的主要目标不仅仅停留于工程层面的优化,更重要的是通过对原有架构的升级来支撑产品未来的快速发展。具体来说,本次架构升级可以分为如下几个目标:
将iLogtail的内部数据模型更换为通用数据模型,以减少数据投递时不必要的数据结格式转换;
将C++主程序的输入、处理和输出能力全面插件化,便于从产品侧统一C++部分和Golang部分的插件概念;
在C++主程序中增加可观测流水线的概念,强化C++主程序的流水线配置管理能力,以支持C++处理能力间的级联和C++处理能力与Golang处理能力的组合,从而增强C++的主体地位;
统一商业版和开源版的采集配置格式,均采用流水线模式的配置结构,以适应最新的iLogtail架构;
优化采集配置热加载的方式,提升配置容错能力;
优化商业版代码嵌入开源版代码的路径,通过仅追加文件而非切换文件的方式来实现,提升开发效率。
在原有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内部的数据模型是有所不足的:
因此,在新的架构中,我们需要将线程间的交互数据模型改成通用数据结构,这样做的好处在于:
为此,我们定义如下的数据模型层次:
PipelineEventGroup是新架构中输入、处理和输出模块间的交互数据结构,与原有架构中的LogGroup相对应,它包含以下的成员变量:
PipelineEvent是一个抽象基类,表示一个事件,它的成员变量包含事件类型、当前事件的采集时间以及该事件所在的EventGroup。它的子类包括LogEvent、MetricEvent和SpanEvent,它们分别代表可观测数据中的Log、Metric和Trace。
需要强调的是,考虑到内存分配的问题,PipelineEvent不能独立于PipelineEventGroup存在,必须依附于某一PipelineEventGroup。因此,PipelineEvent的建立只能通过PipelineEventGroup的AddLogEvent、AddMetricEvent和AddSpanEvent函数来进行。
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;
};
其中,公有成员函数的说明如下:
在原有架构中,由于假定日志文件仅存在一种格式且只能进行一次格式解析,并且针对某些特定格式的日志有特殊的读取逻辑(实际没必要),因此在代码层面采用了一种强耦合的设计模式。具体来说,所有格式的日志共享一个基类LogFileReader,其主要负责读取日志。对于每一种格式的日志,都单独设置一个类继承LogFileReader,其成员函数主要用于对文本日志进行行切分和解析。除此以外,对于一些工具函数,还专门设置一个LogParser类,该类只包含静态成员,本质上是面向过程的包装。
显然,将日志文件读取和日志解析的能力统一放到一个类中是一个不太合理的设计,完全缺乏可扩展性。为此,我们需要将日志切分(LogSplit函数)和日志解析(ParseLogLine函数)的能力从LogFileReader类中剥离开来,同时和LogParser类中相关的函数进行重新组合,从而形成多个独立的处理插件。
由于Golang和C++可能在某些方面会提供相同的处理能力,为了区分二者,我们称C++的处理插件为“原生处理插件”,而Golang的处理插件则称为“扩展处理插件”。据此,我们可以在C++部分抽象出如下几个原生处理插件:
ProcessorSplitLogStringNative:日志切分处理插件,用于对日志块按照指定分隔符进行切分生成多个事件;
ProcessorSplitRegexNative:日志切分处理插件,用于对日志块按照正则表达式进行切分生成多个事件;
ProcessorParseRegexNative:正则解析插件,通过正则匹配解析事件指定字段内容并提取新字段;
ProcessorParseJsonNative:Json解析插件,解析事件中Json格式字段内容并提取新字段;
ProcessorParseDelimiterNative:分隔符解析插件,解析事件中分隔符格式字段内容并提取新字段;
ProcessorParseTimestampNative:时间解析插件,用于解析事件中记录时间的字段,并将结果置为事件的__time__字段;
除了上述处理能力外,原有iLogtail还提供了字段过滤和脱敏的处理能力,它们均属于LogFilter类的能力,在日志发送前被调用(调用点也不合理)。为此,我们也将这两种处理能力抽象成原生处理插件:
ProcessorFilterRegexNative:事件过滤插件,用于根据事件字段内容来过滤事件;
ProcessorDesensitizeNative:脱敏插件,用于对事件的字段内容进行脱敏;
最后,我们在前文提到,PipelineEventGroup的mTag成员是从mMetadata成员中获取的,而这一过程也需要新增如下的原生处理插件来完成:
至此,我们已经将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;
};
其中,公有成员函数的说明如下:
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函数只做了如下两件事:
类似的,InputFile类的Stop函数也只做了两件事:
通过这种代理模式,我们巧妙地将文件采集的具体实现隐藏在文件输入插件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;
};
其中,公有成员函数的说明如下:
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++主程序中新增流水线概念,这样做的好处在于:
我们定义iLogtail的流水线Pipeline为如下形态:
可以看到,每条流水线可包含任意个输入、处理和输出插件,插件类型既可以是C++插件,也可以是Golang插件,但存在如下的唯一限制:原生处理插件仅可出现在扩展处理插件之前,即不允许在使用扩展处理插件后再使用原生处理插件! 增加此项限制主要基于如下考量:
⚠️ 以上插件编排限制描述针对的是最终架构,由于架构升级实际是分阶段进行的,故不同版本的实际限制请参见源码和配套的说明文档。 |
---|
根据以上描述,在新架构中,数据的可能通路如下所示:
在流水线初始化阶段,根据不同的插件组合,选定最终的数据通路。对于有多条通路可供选择的插件组合,我们以如下原则选定最终通路:
由于Golang插件系统的运行也是以流水线的形式进行的,并不能以插件的形式单独存在,因此我们重新定义Golang流水线为流水线的子流水线。从上图中也能看到,Golang子流水线可能有两种形式:
因此,对于任意一条流水线,其可能不含Golang子流水线,也可能包含多条Golang子流水线,但最多只可能存在两条(如插件组合2、3、4),具体情况视插件编排结果而定。
显然,一条流水线中可以存在多个相同的插件。为了区分同名插件,以及方便插件运行状态的可观测,我们需要额外在插件之上增加一层封装——插件实例。对于流水线而言,它管理的只是插件实例,对插件本身无感。所有对插件实例的操作实际上是在操作插件本身,因此这实际上也是设计模式中代理模式(Proxy)在iLogtail新架构中的又一次应用。
与插件类似,为了统一所有插件实例的共有行为,我们也会首先定义所有类型插件实例的抽象基类PluginInstance,然后在此基础上派生出不同类型的插件实例:InputInstance、ProcessInstance和FlusherInstance。整个类层次如下图所示:
可以看到,每个插件实例都有一个mId成员用于唯一标识一个插件实例,以及一个mPlugin成员指向真正的插件。
除了插件实例,流水线中另一个重要的组成部分是连接各模块的缓冲队列,它在资源管控和处理突发流量方面起到着重要作用。
在原有架构中,C++部分的缓冲队列都是以LogStore为粒度的,即每个Logstore一个队列。显然,以Logstore作为队列的颗粒度是不合适的,原因包括:
另一方面,对于Golang插件系统,由于天然的流水线资源独立性,缓冲队列自然是流水线级别的。相比于原有C++的实现,这显然更符合配置级别资源管控的实际需求。但是与线程资源一样,缓冲队列的数量也是与流水线数量直接成正比的,也意味着内存使用会显著高于C++原有的实现方式。
那有没有什么方法既可以实现配置级别的资源管控,又能尽可能少地占用资源呢?
答案自然是肯定的,我们可以采用如下图所示的架构:
说明如下:
使用这样的架构有如下好处:
至此,我们可以给出流水线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 };
其中的公有成员函数说明如下:
成员变量主要包括:
其中需要说明的是mContext成员,它属于PipelineContext类,该类主要用于记录流水线的一些信息,便于流水线中的插件获取。PipelineContext类的成员主要包括:
原有代码中的采集配置管理模块基本由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插件。
在原有架构中,配置文件的组织没有统一的规范,包括文件格式不统一和存放位置不统一:
为了统一上述混乱的情况,同时提供可扩展性,在新架构中,统一采用下述规则来组织文件:
在新架构中,对采集配置变更的监控全部通过监控磁盘配置文件是否变更来完成,相关工作统一由 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类对外提供两个方法:
这里重点关注一下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;
};
其中,各成员函数的说明如下:
对于不同的配置来源,可以从ConfigProvider类派生不同的子类,目前包括:
在原有架构中,非采集配置级(即进程级和模块级)参数统一由AppConfig类进行管理,由此带来的后果包括:
为了解决这一问题,在新架构中,AppConfig类仅维护进程级别的参数(如内存上限等)和多个模块共用的参数。对于其他仅在单个模块中使用的参数,统一在相应的模块中维护,从而有效解决上述问题。
在原有架构中,由于类的功能界限模糊,各个类之间存在严重的依赖和耦合,因此商业版特有的功能代码散落在各个文件中,导致无法从代码库中干净剥离,只能通过文件替换的方式来完成开源和商业版代码的切换,严重影响开发效率。
为了彻底解决这一问题,需要将商业版功能进行归类和重新整合。然后,针对不同的需求和场景,使用不同的嵌入策略:
商业版独有的功能:
例: 商业版代码中使用ShennongManager类来采集特定指标,该类包含一个线程资源,需要在配置变更时暂停和启动线程。因此,在PipelineManager类中存在如下调用点: |
---|
#ifdef __ENTERPRISE__
ShennongManager::GetInstance()->Pause();
#endif
// ...
#ifdef __ENTERPRISE__
ShennongManager::GetInstance()->Resume();
#endif
商业版和开源版行为存在差异:
例:商业版和开源版在发送可观测数据方面存在差异,因此定义开源版的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; }
为了实现上述能力,需要对原有代码进行重组织,主要工作如下:
除此以外,由于主文件中没有类的概念,因此将与配置管理相关的内容从主文件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的新架构时,主要采用如下原则:
2. 确定好新架构后,如何分阶段来完成架构升级?
从前文的介绍可以看到,新架构与原有架构的区别较大,升级涉及到的模块众多,工作量大。显然,一口气完成架构升级是不可行的,必须分阶段分模块进行,在CI的配合下确保每一个模块的重构都是符合预期的。
那怎么分阶段呢?这里就走过一些弯路,因为从架构设计的角度,我们会习惯性地从外往里进行思考(即按照上文实践一节从后往前的顺序),但这会陷入一个层层依赖的问题。例如,在重构配置管理模块的时候,会依赖Pipeline类和PipelineManager类。为此,需要优先重构这两个类。但是重构这两个类的时候,又会依赖各种插件类的实现。按照这个顺序进行下去,最终的依赖便是PipelineEvent类。显然,按照这个顺序进行下去,相当于一口气完成架构升级,因此根本不可行。
正确的做法是由里向外进行重构,先重构PipelineEvent类,再重构插件类,以此类推最后重构Application类,即按照上文介绍的顺序。借助这个顺序,就可以将整个架构升级过程分成至少6个大阶段,每个阶段都可以单独CI而不会影响既有功能的正常工作。但是,想要正确执行这种顺序,就必须要求对目标架构有一个完整清晰的认知和详细的设计。如果对目标架构的认识只停留在粗框架的层面,那必然无法准确得到各个模块的依赖关系,从而得到并非完全正确的阶段划分,最终影响实际重构的进度和效率。
任何时候,想清楚了再做永远是事半功倍的基本前提,对于架构升级来说尤甚。
3. iLogtail原有的测试体系不健全,如何保证重构后的代码不引入兼容性问题?
这或许是iLogtail重构最头疼的问题,原有的iLogtail UT代码覆盖率不高,回归测试只覆盖主流场景,对于小众功能基本属于监控盲区。为此,只能对原有代码进行完整的梳理和阅读,重点关注如下几个点:
当然,上述方法也只能尽可能避免重构引发的不兼容问题,但是在现有的条件和时间允许范围内,这已经是最佳策略。事实上,在整个架构升级过程中,有大约2个月左右的时间是在执行上述操作的,这也为后续实际重构奠定了坚实的基础。
4. 如何保证代码质量?
在决定进行架构升级时,笔者才从业一年,虽说有一定的C++开发经验,但是对于重构这么大的事确实没有经历过。如何保证新写的代码符合通用规范,同时又保证运行效率,是一个亟需解决的问题。
为此,在设计新架构的间隙,笔者又重新翻阅了一些经典著作,例如设计模式相关的《Design Patterns: Elements of Reusable Object-Oriented Software》,C++开发相关的《Effective C++》系列、《C++ Concurrency in Action》等书籍,同时也通过一些博客和官方文档学习C++17的新特性。与之前抱着学习的态度去阅读不同,当你带着问题和需求去重新阅读这些著作时,会更能领悟到书中一些总结性经验的实际含义。凭借着这些消化吸收后的经验,笔者一步一步打磨自己的代码,并适时对新代码进行小范围的二次重构以增强代码的复用和可扩展性。
回想整个架构升级的过程,从接受任务时的迷茫,到最后升级基本完成时的喜悦,半年多的时间经历了很多,也成长了很多。对于iLogtail而言,经历本次架构升级,也算是浴火重生,向着现代顶流可观测数据采集器的目标又迈进了一大步。不论对于用户,还是对于社区开发者,相信所有人都会从本次架构升级中受益。让我们一起期待iLogtail在未来继续蓬勃发展,提供更快更强的数据采集能力!
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。