vivo大数据日志采集Agent设计实践

2022-11-28 09:27:13 浏览数 (1)

作者:vivo 互联网存储技术团队- Qiu Sidi

在企业大数据体系建设过程中,数据采集是其中的首要环节。然而,当前行业内的相关开源数据采集组件,并无法满足企业大规模数据采集的需求与有效的数据采集治理,所以大部分企业都采用自研开发采集组件的方式。本文通过在vivo的日志采集服务的设计实践经验,为大家提供日志采集Agent在设计开发过程中的关键设计思路。

一、概述

在企业大数据体系的建设过程中,数据的处理一般包含4个步骤:采集、存储、计算和使用。其中,数据采集,是建设过程中的首要的环节,也是至关重要的环节,如果没有采集就没有数据,更谈不上后续的数据处理与使用。所以,我们看到的企业中的运营报表、决策报表、日志监控、审计日志等的数据来源都是基于数据采集。一般的,我们对数据采集的定义是,把各种分散的源头上的数据(可以包括企业产品的埋点的日志、服务器日志、数据库、IOT设备日志等)统一汇聚到大数据存储组件的过程(如下图所示)。其中,日志文件类型的采集场景,是各种数据采集类型中最常见的一种。接下来,将围绕该场景提出我们的设计实践方案。

通常,日志采集服务可以分为几个部分(业界常见的架构如下图所示):日志采集Agent组件(常见的开源采集Agent组件有Flume、Logstash、Scribe等)、采集传输与存储组件(如kafka、HDFS)、采集管理平台。Bees采集服务是vivo自研的日志采集服务,本文章是通过在Bees采集服务中的关键组件bees-agent的开发实践后,总结出一个通用的日志采集Agent设计中的核心技术点和一些关键思考点,希望对大家有用。

二、特性&能力

  1. 具备基本的日志文件的实时与离线采集能力
  2. 基于日志文件,无侵入式采集日志
  3. 具备自定义的过滤超大日志的能力
  4. 具备自定义的过滤采集、匹配采集、格式化的能力
  5. 具备自定义的限速采集的能力
  6. 具备秒级别的实时采集时效性
  7. 具备断点续传能力,升级和停止不丢数据
  8. 具备可视化的、中心化的采集任务管理平台
  9. 丰富的监控指标与告警(包括采集流量、时效性、完整性等)
  10. 低系统资源开销(包括磁盘、内存、CPU及网络等)

三、设计原则

  1. 简单优雅
  2. 健壮稳定

四、关键设计

目前业界流行的日志采集Agent组件,开源的有Flume、Logstash、Scribe、FileBeats、Fluentd等,自研的有阿里的Logtail。它们都有不错的性能与稳定性,如果想要快速上手,可以不妨使用它们。但是一般大企业会有个性化的采集需求,比如采集任务大规模管理、采集限速、采集过滤等,还有采集任务平台化、任务可视化的需求,为了满足上面这些需求我们自研了一个日志采集Agent。

在做一切的设计和开发之前,我们设定了采集Agent最基本的设计原则,即简单优雅、健壮稳定。

日志文件采集的一般流程会包括:文件的发现与监听、文件读取,日志内容的格式化、过滤、聚合与发送。当我们开始着手开始设计这样一个日志采集Agent时,会遇到不少关键的难点问题,比如:日志文件在哪里?如何发现日志文件新增?如何监听日志内容追加?如何识别一个文件?宕机重启怎么办?如何断点续传?等等问题,接下来,我们针对日志采集Agent设计过程中遇到的关键问题,为大家一一解答。(注:下文出现的文件路径与文件名都为演示样例非真实路径)

4.1 日志文件发现与监听

Agent要如何知道采集哪些日志文件呢?

最简单的设计,就是在Agent的本地配置文件中,把需要采集的日志文件路径都一一罗列进去,比如 /home/sample/logs/access1.log、/home/sample/logs/access2.log、/home/sample/logs/access3.log 等,这样Agent通过读取配置文件得到对应的日志文件列表,这样就能遍历文件列表读取日志信息。但是实际情况是,日志文件是动态生成的,像一般tomcat的业务日志,每个小时都会滚动生成一个新的的日志文件,日志名字通常会带上时间戳,命名类似 /data/sample/logs/access.2021110820.log,所以采用直接配置固定的文件列表方式是行不通的。

所以,我们想到可以使用一个文件夹路径和日志文件名使用正则表达式或者通配符来表示(为了方便,下文统一使用通配符来表示)。机器上的日志一般固定存在某一个目录下,比如 /data/sample/logs/ 下,文件名由于某种规则是滚动产生的(比如时间戳),类似 access.2021110820.log、access.2021110821.log、access.2021110822.log,我们可以简单粗暴使用 access.*.log 的通配方法来匹配这一类的日志,当然实际情况可以根据你需要的匹配粒度去选择你的正则表达式。有了这个通配符方法,我们的Agent就能的匹配滚动产生的一批日志文件了。

如何持续发现和监听到新产生的日志文件呢?

由于新的日志文件会由其他应用程序(比如Nginx、Tomcat等)持续的按小时动态产生的,Agent如何使用通配符快速去发现这个新产生的文件呢?

最容易想到的,是使用轮询的设计方案,即是通过一个定时任务来检查对应目录下的日志文件是否有增加,但是这种简单的方案有个问题,就是如果轮询间隔时间太长,比如间隔设置为10s、5s,那么日志采集的时效性满足不了我们的需求;如果轮询间隔时间太短,比如500ms,大量的无效的轮询检查又会消耗许多CPU资源。幸好,Linux内核给我们提供一种高效的文件事件监听机制:Linux Inotify机制。该机制可监听任意文件的操作,比如文件创建、文件删除和文件内容变更,内核会给应用层一个对应的事件通知。Inotify这种的事件机制比轮询机制高效的多,也不存在CPU空跑浪费系统资源的情况。在java中,使用java.nio.file.WatchService,可以参考如下核心代码:

代码语言:java复制
/**
 * 订阅文件或目录的变更事件
 */
public synchronized BeesWatchKey watchDir(File dir, WatchEvent.Kind<?>... watchEvents) throws IOException {
    if (!dir.exists() && dir.isFile()) {
        throw new IllegalArgumentException("watchDir requires an exist directory, param: "   dir);
    }
    Path path = dir.toPath().toAbsolutePath();
    BeesWatchKey beesWatchKey = registeredDirs.get(path);
    if (beesWatchKey == null) {
        beesWatchKey = new BeesWatchKey(subscriber, dir, this, watchEvents);
        registeredDirs.put(path, beesWatchKey);
        logger.info("successfully watch dir: {}", dir);
    }
    return beesWatchKey;
}
 
public synchronized BeesWatchKey watchDir(File dir) throws IOException {
    WatchEvent.Kind<?>[] events = {
            StandardWatchEventKinds.ENTRY_CREATE,
            StandardWatchEventKinds.ENTRY_DELETE,
            StandardWatchEventKinds.ENTRY_MODIFY
    };
    return watchDir(dir, events);
}

综合以上思考,日志文件的发现和日志内容变更的监听,我们使用的是"inotify机制为主 轮询机制兜底"、"通配符"的设计方案,如下图所示:

4.2 日志文件的唯一标识

要设计日志文件的唯一标识,如果直接使用日志文件的名称是行不通的,日志文件名可能被频繁重复使用,比如,一些应用程序使用的日志框架在输出日志时,对于当前应用正在输出的日志命名是不带任何时间戳信息的,比如固定是 access.log,只有等到当前小时写入文件完毕时,才把文件重命名为 access.2021110820.log,此时新生产的日志文件命名也是 access.log,该文件名对于采集Agent来说是重复的,所以文件名是无法作为文件唯一标识。

我们想到使用Linux操作系统上的文件inode号作为文件标识符。Unix/Linux文件系统使用inode号来识别不同文件,即使移动文件或重命名文件,inode号是保持不变的,创建一个新文件,会给这个新文件分配一个新的不重复的inode号,这样就能与现有磁盘上的其他文件很好区分。我们使用 ls -i access.log 可以快速查看该文件的inode号,如下代码块所示:

代码语言:java复制
ls -i access.log
62651787 access.log

一般来说,使用系统的inode号作为标识,已经能满足大多数的情况了,但是为了更严谨的考虑,还可以进一步升级方案。因为Linux 的inode号存在复用的情况,这里的"复用"要和"重复"区别一下,在一台机器上的所有文件不会同一时刻出现重复的两个inode号,但是当文件删除后,另一个新文件创建时,这个文件的inode号是可能复用之前删除文件的inode号的,代码逻辑处理不好,很可能造成日志文件漏采集,这一点是要注意的。为了规避这个问题,我们把文件的唯一标识设计为" 文件inode与文件签名组合",这里的文件签名使用的是该文件内容前128字节的Hash值,代码参考如下:

代码语言:java复制
public static String signFile(File file) throws IOException {
        String filepath = file.getAbsolutePath();
        String sign = null;
        RandomAccessFile raf = new RandomAccessFile(filepath, "r");
        if (raf.length() >= SIGN_SIZE) {
           byte[] tbyte = new byte[SIGN_SIZE];
           raf.seek(0);
           raf.read(tbyte);
           sign = Hashing.sha256().hashBytes(tbyte).toString();
        }
        return sign;
    }

关于inode再补充点小知识。Linux inode是会满的,inode的信息存储本身也会消耗一些硬盘空间,因为inode号只是inode内容中的一小部分,inode内容主要是包含文件的元数据信息:如文件的字节数、文件数据block的位置、文件的读写执行权限、文件的时间戳等,可以用stat命令,查看某个文件完整的inode信息(stat access.log)。因为这样的设计,操作系统是将硬盘分成两个区域的:一个是数据区,存放文件数据;另一个是inode区,存放inode所包含的信息。每个inode节点的大小,一般是128字节或256字节。查看每个硬盘分区的inode总数和已经使用的数量,可以使用df -i命令。由于每个文件都必须有一个inode,如果一个日志机器上,日志文件小而且数量太多,是有可能发生操作系统inode用完了即是inode区磁盘满了,但是我们使用的数据区硬盘还未存满的情况。这时,就无法在硬盘上创建新文件。所以在日志打印规范上是要避免产生大量的小日志文件的。

4.3 日志内容的读取

发现并且能有效监听日志文件后,我们应该如何去读取这个日志文件中实时追加的日志内容呢?日志内容的读取,我们期望从日志文件中把每一行的日志内容逐行读取出来,每一行以n或者r为分隔符。很显然,我们不能直接简单采用InputStreamReader去读取,因为Reader只能按照字符从头到尾读取整个日志文件,不适合读取实时追加日志内容的情况;最合适的选择应该是使用RandomAccessFile。RandomAccessFile它为代码开发者提供了一个可供设置的指针,通过指针开发者可以访问文件的随机位置,参考下图:

通过这种方式,当某一时刻出现线程读取到文件末尾时,只需要记录当前的位置,线程就进入等待状态,直到有新的日志内容写入后,线程又重新启动,启动后可以接着上次的尾部往下读取,代码参考如下。另外,在进程挂或者宕机恢复后,也会用到RandomAccessFile来从指定点位开始读取,不需要从整个文件头部重新读取。关于断点续传的能力后文会提到。

代码语言:java复制
RandomAccessFile raf = new RandomAccessFile(file, "r");
byte[] buffer;
private void readFile() {
    if ((raf.length() - raf.getFilePointer()) < BUFFER_SIZE) {
        buffer = new byte[(int) (raf.length() - raf.getFilePointer())];
    } else {
        buffer = new byte[BUFFER_SIZE];
    }
    raf.read(buffer, 0, buffer.length);
}

4.4 实现断点续传

机器宕机、Java进程OOM重启、Agent升级重启等这些是常有的事,那么如何在这些情况下保障采集数据的正确呢?这个问题主要考虑的是采集Agent断点续传的能力。一般的,我们在采集过程中需要记录当前的采集点位(采集点位,即RandomAccessFile中最后的指针指向的位置,一个整型数值),当Agent把对应缓冲区的数据成功发送到kafka后,此时可以先把最新点位的数值更新到内存,并且通过一个定时任务(默认是3s)持久化内存中的采集点位数值到本地的磁盘的点位文件中。这样,当出现进程停止,重新启动时,加载本次磁盘文件中的采集点位,并使用RandomAccessFile移动到对应的点位,实现了从上一次停止的点位继续往下采集的能力,Agent可以恢复到原有的状态,从而实现了断点续传,有效规避重复采集或者漏采集的风险。

Agent针对的每一个采集任务会有一个对应的点位文件,一个Agent如果有多个采集任务,将会对应多个点位文件。一个点位文件存储的内容格式为JSON数组(如下图所示)。其中file表示任务所采集的文件的名字,inode即文件的inode,pos即position的缩小,表示点位的数值;

代码语言:java复制
[
    {
        "file": "/home/sample/logs/bees-agent.log",
        "inode": 2235528,
        "pos": 621,
        "sign": "cb8730c1d4a71adc4e5b48931db528e30a5b5c1e99a900ee13e1fe5f935664f1"
    }
]

4.5 实时数据发送

前面主要介绍了,日志文件的实时的发现、实时的日志内容变更监听、日志内容的读取等设计方案,接下来介绍Agent的数据发送

最简单的模型是,Agent通过Kafka Client把数据直接发送到Kafka分布式消息中间件,这也是一种简洁可行的方案。实际上在Bees的采集链路架构中,在Agent与Kafka的数据链路中我们增加了一个"组件bees-bus“(如下图所示)。

bees-bus组件主要起到汇聚数据的作用,类似于Flume在采集链路中聚合的角色。Agent基于Netty开源框架实现NettyRpcClient与Bus之间通讯实现数据发送。网络传输部分展开讲内容较多,非本文章重点就此带过(具体可参考Flume NettyAvroRpcClient实现)。

这里稍微补充下,我们引入bees-bus的目的主要有以下几个:

  1. 收敛来自于Agent过多的网络连接数,避免所有Agent直连Kafka broker对其造成较大的压力;
  2. 数据汇聚到Bus后,Bus具备流量多路输出的能力,可以实现跨机房Kafka数据容灾;
  3. 在遇到流量陡增的情况下, 会导致topic分区所在broker机器磁盘IO繁忙进而导致数据反压到客户端, 由于kafka副本迁移比较耗时所以出现问题后恢复较慢,Bus可以起到一层缓冲层的作用。

4.6 离线采集能力

除了上面常见的实时日志采集的场景外(一般是日志采集到kafka这类消息中间件),Bees采集还有一个离线日志采集的场景。所谓离线日志采集,一般是指把日志文件是采集到HDFS下(参考下图)。

这些日志数据是用于下游的Hive离线数仓建设、离线报表分析使用。该场景数据时效性没有那么强,一般是按天为单位使用数据(我们常说的T 1数据),所以日志数据采集无需像实时日志采集一样,实时的一行一行的采集。离线采集一般可以按照固定时间一个批次采集。我们默认是每隔一小时定时采集上个小时产生的一个完整的小时日志文件,比如在21点的05分,采集Agent则开始采集上个小时产生的日志文件(access.2021110820.log),该文件保存了20点内产生的完整的(20:00~20:59)日志内容。

实现离线的采集能力,我们的Agent通过集成HDFS Client的基本能力来实现,HDFS Client中使用 FSDataOutputStream 可以快速的完成一个文件PUT到HDFS的目录下。

尤其要关注的一点是,离线采集需要特别的增加了一个限流采集的能力。由于离线采集的特点是,在整点左右的时刻,所有的机器上的Agent会几乎同时全量开启采集,如果日志量大、采集速度过快,可能会造成该时刻公司网络带宽被快速占用飙升,超出全网带宽上限,进一步会影响其他业务的正常服务,引发故障;还有一个需要关注的就是离线采集整点时刻对机器磁盘资源的需求是很大,通过限流采集,可以有效削平对磁盘资源的整点峰值,避免影响其他服务。

4.7 日志文件清理策略

业务日志源源不断的产生落到机器的磁盘上,单个小时的日志文件大小,小的可能是几十MB,大的可以是几十GB,磁盘很有可能在几小时内被占满,导致新的日志无法写入造成日志丢失,另一方面可能导致更致命的问题,linux 操作系统报 “No space left on device 异常",引发其他进程的各种故障;所以机器上的日志文件需要有一个清理的策略。

我们采用的策略是,所有的机器都默认启动了一个shell的日志清理脚本,定期检查固定目录下的日志文件,规定日志文件的生命周期为6小时,一旦发现日志文件是6小时以前的文件,则会对其进行删除(执行 rm 命令)。

因为日志文件的删除,不是由日志采集Agent自身发起和执行的,那么可能出现”采集速度跟不上删除速度(采集落后6小时)“的情况。比如日志文件还在采集,但是删除脚本已经检测到该文件生命周期已达6小时准备对其进行删除;这种情况,我们只需要做好一点,保证采集Agent对该日志文件的读取句柄是正常打开的,这样的话,即使日志清理进程对该文件执行了rm操作(执行rm后只是将该文件从文件系统的目录结构上解除链接 unlink,实际文件还未从磁盘彻底删除),采集Agent持续打开的句柄,依然能正常采集完此文件;这种"采集速度跟不上删除速度"是不能长时间存在,也有磁盘满的风险,需要通过告警识别出来,根本上来说,需要通过负载均衡或者降低日志量的方法,来减少单机器日志长时间采集不过来的情况。

4.8 系统资源消耗与控制

Agent采集进程是随着业务进程一起部署在一个机器上的,共同使用业务机器的资源(CPU、内存、磁盘、网络),所以在设计时,要考虑控制好Agent采集进程对机器资源的消耗,同时要做好对Agent进程对机器资源消耗的监控。一方面保障业务有稳定的资源可以正常运行;另外可以保障Agent自身进程正常运作。通常我们可以采用以下方案:

1. 针对CPU的消耗控制。

我们可以较方便采用Linux系统层面的CPU隔离的方案来控制,比如TaskSet;通过TaskSet命令,我们可以在采集进程启动时,设定采集进程绑定在某个限定的CPU核心上面(进程绑核,即设定进程与CPU亲和性,设定以后Linux调度器就会让这个进程/线程只在所绑定的核上面去运行);这样的设定之后,可以保障采集进程与业务进程在CPU的使用上面互相不影响。

2. 针对内存的消耗控制。

由于采集Agent采用java语言开发基于JVM运行,所以我们可以通过JVM的堆参数配置即可控制;bees-agent一般默认配置512MB,理论上最低值可以是64MB,可以根据实际机器资源情况和采集日志文件大小来配置;事实上,Agent的内存占用相对稳定,内存消耗方面的风险较小。

3.针对磁盘的消耗控制。

由于采集Agent是一个IO密集型进程,所以磁盘IO的负载是我们需要重点保障好的;在系统层面没有成熟的磁盘IO的隔离方案,所以只能在应用层来实现。我们需要清楚进程所在磁盘的基准性能情况,然后在这个基础上,通过Agent自身的限速采集能力,设置采集进程的峰值的采集速率(比如:3MB/s、5MB/s);除此之外,还需要做好磁盘IO负载的基础监控与告警、采集Agent采集速率大小的监控与告警,通过这些监控告警与值班分析进一步保障磁盘IO资源。

4.针对网络的消耗控制。

这里说的网络,重点要关注是跨机房带宽上限。避免同一时刻,大批量的Agent日志采集导致跨机房的带宽到达了上限,引发业务故障。所以,针对网络带宽的使用也需要有监控与告警,相关监控数据上报到平台汇总计算,平台通过智能计算后给Agent下发一个合理的采集速率。

4.9 自身日志监控

为了更好的监控线上所有的Agent的情况,能够方便地查看这些Agent进程自身的log4j日志是很有必要的。为了达成这一目的,我们把Agent自身产生的日志采集设计成一个普通的日志采集任务,就是说,采集Agent进程自身,自己采集自己产生的日志,于是就可以把所有Agent的日志通过Agent采集汇聚到下游Kafka,再到Elasticsearch存储引擎,最后通过Kibana或其他的日志可视化平台可以查看。

4.10 平台化管理

目前的生产环境Agent实例数量已经好几万,采集任务数量有上万个。为了对这些分散的、数据量多的Agent进行有效的集中的运维和管理,我们设计了一个可视化的平台,管理平台具备以下Agent控制能力:Agent 的现网版本查看,Agent存活心跳管理,Agent采集任务下发、启动、停止管理,Agent采集限速管理等;需要注意的是,Agent与平台的通讯方式,我们设计采用简单的HTTP通讯方式,即Agent以定时心跳的方式(默认5分钟)向平台发起HTTP请求,HTTP请求体中会包含Agent自身信息,比如idc、ip、hostname、当前采集任务信息等,而HTTP返回体的内容里会包含平台向Agent下发的任务信息,比如哪个任务启动、哪个任务停止、任务的具体参数变更等。

五、与开源能力对比

bees-agent与flume-agent对比

  1. 内存需求大大降低。bees-agent 采用无 Channel 设计,大大节省内存开销,每个 Agent 启动 ,JVM 堆栈最低理论值可以设置为64MB;
  2. 实时性更好。bees-agent 采用Linux inotify事件机制,相比 Flume Agent 轮询机制,采集数据的时效性可以在1s以内;
  3. 日志文件的唯一标识,bees-agent 使用inode 文件签名,更准确,不会出现日志文件误采重采;
  4. 用户资源隔离。bees-agent 不同 Topic 的日志采集任务,采用不同的线程隔离采集,互相无影响;
  5. 真正的优雅退出。bees-agent 在正常采集过程中,随时使用平台的"停止命令"让 Agent 优雅退出,不会出现无法退出的尴尬情况,也能保证日志无任何丢失;
  6. 更丰富的指标数据。bees-agent 包括采集速率、采集总进度,还有 机器信息、JVM 堆情况、类数量、JVM GC次数等;
  7. 更丰富的定制化能力。bees-agent 具备关键字匹配采集能力、日志格式化能力、平台化管理的能力等;

六、总结

前文介绍了vivo日志采集Agent在设计过程中的一些核心技术点:包括日志文件的发现与监听、日志文件的唯一标识符设计、日志文件的实时采集与离线采集的架构设计、日志文件的清理策略、采集进程对系统资源的消耗控制、平台化管理的思路等,这些关键的设计思路覆盖了自研采集agent大部分的核心功能,同时也覆盖了其中的难点痛点,能让后续的开发环节更加畅通。当然,还有一些高阶的采集能力未涵盖本文介绍在内,比如"如何做好日志采集数据的完整性对账","数据库类型的场景的采集设计"等,大家可以继续探索解决方案。

从2019年起,vivo大数据业务的日志采集场景就是由Bees数据采集服务支撑。bees-agent在生产环境持续服务,至今已有3年多的稳定运行的记录,有数万个bees-agent实例正在运行,同时在线支撑数万个日志文件的采集,每天采集PB级别的日志量。实践证明,bees-agent的稳定行、健壮性、丰富的功能、性能与合理的资源情况,都符合最开始设计的预期,本文的设计思路的也一再被证实行之有效。

0 人点赞