基於 Apache Flink 的實時 Error 日誌告警

本文首發自本人的 Flink 專欄《Flink 實戰與性能調優》

大數據時代,隨着公司業務不斷的增長,數據量自然也會跟着不斷的增長,那麼業務應用和集羣服務器的的規模也會逐漸擴大,幾百臺服務器在一般的公司已經是很常見的了。那麼將應用服務部署在如此多的服務器上,對開發和運維人員來說都是一個挑戰。一個優秀的系統運維平臺是需要將部署在這麼多服務器上的應用監控信息彙總成一個統一的數據展示平臺,方便運維人員做日常的監測、提升運維效率,還可以及時反饋應用的運行狀態給應用開發人員。舉個例子,應用的運行日誌需要按照時間排序做一個展示,並且提供日誌下載和日誌搜索等服務,這樣如果應用出現問題開發人員首先可以根據應用日誌的錯誤信息進行問題的排查。那麼該如何實時的將應用的 Error 日誌推送給應用開發人員呢,接下來我們將講解日誌的處理方案。

日誌處理方案的演進

日誌處理的方案也是有一個演進的過程,要想弄清楚整個過程,我們先來看下日誌的介紹。

什麼是日誌?

日誌是帶時間戳的基於時間序列的數據,它可以反映系統的運行狀態,包括了一些標識信息(應用所在服務器集羣名、集羣機器 IP、機器設備系統信息、應用名、應用 ID、應用所屬項目等)

日誌處理方案演進

日誌處理方案的演進過程:

日誌處理 v1.0: 應用日誌分佈在很多機器上,需要人肉手動去機器查看日誌信息。日誌處理 v2.0: 利用離線計算引擎統一的將日誌收集,形成一個日誌搜索分析平臺,提供搜索讓用戶根據關鍵字進行搜索和分析,缺點就是及時性比較差。日誌處理 v3.0: 利用 Agent 實時的採集部署在每臺機器上的日誌,然後統一發到日誌收集平臺做彙總,並提供實時日誌分析和搜索的功能,這樣從日誌產生到搜索分析出結果只有簡短的延遲(在用戶容忍時間範圍之內),優點是快,但是日誌數據量大的情況下帶來的挑戰也大。

日誌採集工具對比

上面提到的日誌採集,其實現在已經有很多開源的組件支持去採集日誌,比如 Logstash、Filebeat、Fluentd、Logagent 等,這裏簡單做個對比。

Logstash

Logstash 是一個開源數據收集引擎,具有實時管道功能。Logstash 可以動態地將來自不同數據源的數據統一起來,並將數據標準化到你所選擇的目的地。如下圖所示,Logstash 將採集到的數據用作分析、監控、告警等。

優勢:Logstash 主要的優點就是它的靈活性,它提供很多插件,詳細的文檔以及直白的配置格式讓它可以在多種場景下應用。而且現在 ELK 整個技術棧在很多公司應用的比較多,所以基本上可以在往上找到很多相關的學習資源。

劣勢:Logstash 致命的問題是它的性能以及資源消耗(默認的堆大小是 1GB)。儘管它的性能在近幾年已經有很大提升,與它的替代者們相比還是要慢很多的,它在大數據量的情況下會是個問題。另一個問題是它目前不支持緩存,目前的典型替代方案是將 Redis 或 Kafka 作爲中心緩衝池:

Filebeat

作爲 Beats 家族的一員,Filebeat 是一個輕量級的日誌傳輸工具,它的存在正彌補了 Logstash 的缺點,Filebeat 作爲一個輕量級的日誌傳輸工具可以將日誌推送到 Kafka、Logstash、ElasticSearch、Redis。它的處理流程如下圖所示:

優勢:Filebeat 只是一個二進制文件沒有任何依賴。它佔用資源極少,儘管它還十分年輕,正式因爲它簡單,所以幾乎沒有什麼可以出錯的地方,所以它的可靠性還是很高的。它也爲我們提供了很多可以調節的點,例如:它以何種方式搜索新的文件,以及當文件有一段時間沒有發生變化時,何時選擇關閉文件句柄。

劣勢:Filebeat 的應用範圍十分有限,所以在某些場景下我們會碰到問題。例如,如果使用 Logstash 作爲下游管道,我們同樣會遇到性能問題。正因爲如此,Filebeat 的範圍在擴大。開始時,它只能將日誌發送到 Logstash 和 Elasticsearch,而現在它可以將日誌發送給 Kafka 和 Redis,在 5.x 版本中,它還具備過濾的能力。

Fluentd

Fluentd 創建的初衷主要是儘可能的使用 JSON 作爲日誌輸出,所以傳輸工具及其下游的傳輸線不需要猜測子字符串裏面各個字段的類型。這樣它爲幾乎所有的語言都提供庫,這也意味着可以將它插入到自定義的程序中。它的處理流程如下圖所示:

優勢:和多數 Logstash 插件一樣,Fluentd 插件是用 Ruby 語言開發的非常易於編寫維護。所以它數量很多,幾乎所有的源和目標存儲都有插件(各個插件的成熟度也不太一樣)。這也意味這可以用 Fluentd 來串聯所有的東西。

劣勢:因爲在多數應用場景下得到 Fluentd 結構化的數據,它的靈活性並不好。但是仍然可以通過正則表達式來解析非結構化的數據。儘管性能在大多數場景下都很好,但它並不是最好的,它的緩衝只存在與輸出端,單線程核心以及 Ruby GIL 實現的插件意味着它大的節點下性能是受限的。

Logagent

Logagent 是 Sematext 提供的傳輸工具,它用來將日誌傳輸到 Logsene(一個基於 SaaS 平臺的 Elasticsearch API),因爲 Logsene 會暴露 Elasticsearch API,所以 Logagent 可以很容易將數據推送到 Elasticsearch 。

優勢:可以獲取 /var/log 下的所有信息,解析各種格式的日誌,可以掩蓋敏感的數據信息。它還可以基於 IP 做 GeoIP 豐富地理位置信息。同樣,它輕量又快速,可以將其置入任何日誌塊中。Logagent 有本地緩衝,所以在數據傳輸目的地不可用時不會丟失日誌。

劣勢:沒有 Logstash 靈活。

日誌結構設計

前面介紹了日誌和對比了常用日誌採集工具的優勢和劣勢,通常在不同環境,不同機器上都會部署日誌採集工具,然後採集工具會實時的將新的日誌採集發送到下游,因爲日誌數據量畢竟大,所以建議發到 MQ 中,比如 Kafka,這樣再想怎麼處理這些日誌就會比較靈活。假設我們忽略底層採集具體是哪種,但是規定採集好的日誌結構化數據如下:

public class LogEvent {
    private String type;//日誌的類型(應用、容器、...)
    private Long timestamp;//日誌的時間戳
    private String level;//日誌的級別(debug/info/warn/error)
    private String message;//日誌內容
    //日誌的標識(應用 ID、應用名、容器 ID、機器 IP、集羣名、...)
    private Map<String, String> tags = new HashMap<>();
}

然後上面這種 LogEvent 的數據(假設採集發上來的是這種結構數據的 JSON 串,所以需要在 Flink 中做一個反序列化解析)就會往 Kafka 不斷的發送數據,樣例數據如下:

{
    "type": "app",
    "timestamp": 1570941591229,
    "level": "error",
    "message": "Exception in thread \"main\" java.lang.NoClassDefFoundError: org/apache/flink/api/common/ExecutionConfig$GlobalJobParameters",
    "tags": {
        "cluster_name": "zhisheng",
        "app_name": "zhisheng",
        "host_ip": "127.0.0.1",
        "app_id": "21"
    }
}

那麼在 Flink 中如何將應用異常或者錯誤的日誌做實時告警呢?

異常日誌實時告警項目架構

整個異常日誌實時告警項目的架構如下圖所示。

應用日誌散列在不同的機器,然後每臺機器都有部署採集日誌的 Agent(可以是上面的 Filebeat、Logstash 等),這些 Agent 會實時的將分散在不同機器、不同環境的應用日誌統一的採集發到 Kafka 集羣中,然後告警這邊是有一個 Flink 作業去實時的消費 Kafka 數據做一個異常告警計算處理。如果還想做日誌的搜索分析,可以起另外一個作業去實時的將 Kafka 的日誌數據寫入進 ElasticSearch,再通過 Kibana 頁面做搜索和分析。

日誌數據發送到 Kafka

上面已經講了日誌數據 LogEvent 的結構和樣例數據,因爲要在服務器部署採集工具去採集應用日誌數據對於本地測試來說可能稍微複雜,所以在這裏就只通過代碼模擬構造數據發到 Kafka 去,然後在 Flink 作業中去實時消費 Kafka 中的數據,下面演示構造日誌數據發到 Kafka 的工具類,這個工具類主要分兩塊,構造 LogEvent 數據和發送到 Kafka。

@Slf4j
public class BuildLogEventDataUtil {
    //Kafka broker 和 topic 信息
    public static final String BROKER_LIST = "localhost:9092";
    public static final String LOG_TOPIC = "zhisheng_log";


    public static void writeDataToKafka() {
        Properties props = new Properties();
        props.put("bootstrap.servers", BROKER_LIST);
        props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        KafkaProducer producer = new KafkaProducer<String, String>(props);


        for (int i = 0; i < 10000; i++) {
            //模擬構造 LogEvent 對象
            LogEvent logEvent = new LogEvent().builder()
                    .type("app")
                    .timestamp(System.currentTimeMillis())
                    .level(logLevel())
                    .message(message(i + 1))
                    .tags(mapData())
                    .build();
//            System.out.println(logEvent);
            ProducerRecord record = new ProducerRecord<String, String>(LOG_TOPIC, null, null, GsonUtil.toJson(logEvent));
            producer.send(record);
        }
        producer.flush();
    }


    public static void main(String[] args) {
        writeDataToKafka();
    }


    public static String message(int i) {
        return "這是第 " + i + " 行日誌!";
    }


    public static String logLevel() {
        Random random = new Random();
        int number = random.nextInt(4);
        switch (number) {
            case 0:
                return "debug";
            case 1:
                return "info";
            case 2:
                return "warn";
            case 3:
                return "error";
            default:
                return "info";
        }
    }


    public static String hostIp() {
        Random random = new Random();
        int number = random.nextInt(4);
        switch (number) {
            case 0:
                return "121.12.17.10";
            case 1:
                return "121.12.17.11";
            case 2:
                return "121.12.17.12";
            case 3:
                return "121.12.17.13";
            default:
                return "121.12.17.10";
        }
    }


    public static Map<String, String> mapData() {
        Map<String, String> map = new HashMap<>();
        map.put("app_id", "11");
        map.put("app_name", "zhisheng");
        map.put("cluster_name", "zhisheng");
        map.put("host_ip", hostIp());
        map.put("class", "BuildLogEventDataUtil");
        map.put("method", "main");
        map.put("line", String.valueOf(new Random().nextInt(100)));
        //add more tag
        return map;
    }
}

如果之前 Kafka 中沒有 zhisheng_log 這個 topic,運行這個工具類之後也會自動創建這個 topic 了。

Flink 實時處理日誌數據

在 3.7 章中已經講過如何使用 Flink Kafka connector 了,接下來就直接寫代碼去消費 Kafka 中的日誌數據,作業代碼如下:

public class LogEventAlert {
    public static void main(String[] args) throws Exception {
        final ParameterTool parameterTool = ExecutionEnvUtil.createParameterTool(args);
        StreamExecutionEnvironment env = ExecutionEnvUtil.prepare(parameterTool);
        Properties properties = KafkaConfigUtil.buildKafkaProps(parameterTool);
        FlinkKafkaConsumer011<LogEvent> consumer = new FlinkKafkaConsumer011<>(
                parameterTool.get("log.topic"),
                new LogSchema(),
                properties);
        env.addSource(consumer)
                .print();
        env.execute("log event alert");
    }
}

因爲 Kafka 的日誌數據是 JSON 的,所以在消費的時候需要額外定義 Schema 來反序列化數據,定義的 LogSchema 如下:

public class LogSchema implements DeserializationSchema<LogEvent>, SerializationSchema<LogEvent> {


    private static final Gson gson = new Gson();


    @Override
    public LogEvent deserialize(byte[] bytes) throws IOException {
        return gson.fromJson(new String(bytes), LogEvent.class);
    }


    @Override
    public boolean isEndOfStream(LogEvent logEvent) {
        return false;
    }


    @Override
    public byte[] serialize(LogEvent logEvent) {
        return gson.toJson(logEvent).getBytes(Charset.forName("UTF-8"));
    }


    @Override
    public TypeInformation<LogEvent> getProducedType() {
        return TypeInformation.of(LogEvent.class);
    }
}

配置文件中設置如下:

kafka.brokers=localhost:9092
kafka.group.id=zhisheng
log.topic=zhisheng_log

接下來先啓動 Kafka,然後運行 BuildLogEventDataUtil 工具類,往 Kafka 中發送模擬的日誌數據,接下來運行 LogEventAlert 類,去消費將 Kafka 中的數據做一個驗證,運行結果如下圖所示,可以發現有日誌數據打印出來了。

處理應用異常日誌

上面已經能夠處理這些日誌數據了,但是需求是要將應用的異常日誌做告警,所以在消費到所有的數據後需要過濾出異常的日誌,比如可以使用 filter 算子進行過濾。

.filter(logEvent -> "error".equals(logEvent.getLevel()))

過濾後只有 error 的日誌數據打印出來了,如下圖所示:

再將作業打包通過 UI 提交到集羣運行的結果如下:

再獲取到這些 Error 類型的數據後,就可以根據這個數據構造成一個新的 Event,組裝成告警消息,然後在 Sink 處調用下游的通知策略進行告警通知,當然這些告警通知策略可能會很多,然後還有收斂策略。具體的通知策略和收斂策略在這節不做細講,最後發出的應用異常日誌告警消息中會攜帶一個鏈接,點擊該鏈接可以跳轉到對應的應用異常頁面,這樣就可以查看應用堆棧的詳細日誌,更加好定位問題。

小結與反思

本節開始講了日誌處理方案的演進,接着分析最新日誌方案的實現架構,包含它的日誌結構設計和異常日誌實時告警的方案,然後通過模擬日誌數據發送到 Kafka,Flink 實時去處理這種日誌的數據進行告警。

本節涉及的代碼地址:https://github.com/zhisheng17/flink-learning

專欄還有更多精彩文章,感興趣的掃描下面二維碼訂閱

END

關注我
公衆號(zhisheng)裏回覆 面經、ES、Flink、 Spring、Java、Kafka、監控 等關鍵字可以查看更多關鍵字對應的文章。

你點的每個贊,我都認真當成了喜歡

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章