萬字心路歷程:從十年老架構決定重構開始

阿里云云原生發表於2024-03-22

作者:篤敏

概述

走近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在未來繼續蓬勃發展,提供更快更強的資料採集能力!

相關文章