How Logging Works
Caddy 拥有强大且灵活的日志功能,但它们可能与您习惯的有所不同,尤其是如果您来自较为古老的共享主机或其他传统 Web 服务器环境。
概述
日志有两个主要方面:生成与消费。
“生成”(Emission)是指产生消息。它包括三个步骤:
- 收集相关信息(上下文)
- 构建有用的表示(编码)
- 将该表示发送到输出(写入)
此功能内置于 Caddy 的核心,使得 Caddy 代码库或模块(插件)的任何部分都可以发出日志。
“消费”(Consumption)是对消息的接收与处理。为了有用,已生成的日志必须被消费。仅仅写入但从不读取的日志没有价值。消费日志可以很简单,例如管理员查看控制台输出;也可以很高级,例如将日志聚合工具或云服务附加到日志上以过滤、计数和索引日志消息。
Caddy 的角色
Caddy 是一个日志生成器(log emitter)。它不负责消费日志,除了为编码和写入日志所需的最小处理。这一点很重要,因为它使 Caddy 核心更简单,从而减少了错误和边缘情况,并减轻了维护负担。最终,日志处理不在 Caddy 核心的职责范围内。
不过,始终存在某个 Caddy 应用模块会消费日志的可能性。(据我们所知,目前尚未出现这样的模块。)
结构化日志
与大多数现代应用程序一样,Caddy 的日志是结构化的。这意味着消息中的信息不只是一个不透明的字符串或字节切片。相反,数据保持强类型,并由各个字段名键控,直到需要对消息进行编码并写出为止。
比较传统的非结构化日志——比如常见于传统 HTTP 服务器的古老通用日志格式(Common Log Format,CLF):
127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /apache_pb.gif HTTP/1.1" 200 2326
这种格式“具有结构”,但并不是“结构化”的:它只能用于记录 HTTP 请求。没有(高效的)方式对其进行不同的编码,因为它是一个不透明的字节字符串。它也缺少大量信息,甚至不包含请求的 Host 头!这种日志格式仅在托管单个站点并只需获取请求的最基本信息时有用。
现在比较 Caddy 中的等效结构化日志消息(以 JSON 编码并友好格式化以便展示):
{
"level": "info",
"ts": 1646861401.5241024,
"logger": "http.log.access",
"msg": "handled request",
"request": {
"remote_ip": "127.0.0.1",
"remote_port": "41342",
"client_ip": "127.0.0.1",
"proto": "HTTP/2.0",
"method": "GET",
"host": "localhost",
"uri": "/",
"headers": {
"User-Agent": ["curl/7.82.0"],
"Accept": ["*/*"],
"Accept-Encoding": ["gzip, deflate, br"],
},
"tls": {
"resumed": false,
"version": 772,
"cipher_suite": 4865,
"proto": "h2",
"server_name": "example.com"
}
},
"bytes_read": 0,
"user_id": "",
"duration": 0.000929675,
"size": 10900,
"status": 200,
"resp_headers": {
"Server": ["Caddy"],
"Content-Encoding": ["gzip"],
"Content-Type": ["text/html; charset=utf-8"],
"Vary": ["Accept-Encoding"]
}
}
您可以看到结构化日志更加有用且包含更多信息。这个日志消息中信息的丰富性不仅有用,而且几乎没有性能开销:Caddy 的日志是零分配的。结构化日志对数据类型或上下文没有限制:它们可以在任何代码路径中使用并包含任何类型的信息。
由于日志是结构化且强类型的,因此可以编码为任何格式。因此,如果您不想使用 JSON,日志可以编码为任何其他表示形式。Caddy 通过 日志编码器模块 支持其他格式,并且可以添加更多。
在结构化日志与传统格式的区别中,最重要的一点是:结构化日志(尽管有性能代价)可以被转换为传统的通用日志格式 ,但反之则不可行。从 CLF 转回结构化格式是非平凡的(至少效率低),并且考虑到信息的缺失,几乎不可能。
本质上,高效的结构化日志通常倡导以下原则:
- 日志多过日志少更好
- 过滤优于丢弃
- 推迟编码以获得更大的灵活性和互操作性
生成(Emission)
在代码中,日志生成类似如下:
logger.Debug("proxy roundtrip",
zap.String("upstream", di.Upstream.String()),
zap.Object("request", caddyhttp.LoggableHTTPRequest{Request: req}),
zap.Object("headers", caddyhttp.LoggableHTTPHeader(res.Header)),
zap.Duration("duration", duration),
zap.Int("status", res.StatusCode),
)
您可以看到,这个函数调用包含了日志级别、一条消息以及若干数据字段。所有这些都是强类型的,且 Caddy 使用零分配的日志库,因此日志生成速度快且开销几乎为零。
logger 变量是一个 zap.Logger,它可以携带任意数量的上下文,包括名称和数据字段。这允许日志记录器很好地“继承”父上下文,从而支持高级追踪和指标。
从那里,消息被送入一个高效的处理管道,在该管道中进行编码并写出。
日志管道
如上所述,消息由日志记录器(loggers)发出。消息随后被发送到日志(logs)进行处理。
Caddy 允许您配置多个日志来处理消息。一个日志由编码器、写入器、最低级别、采样比率以及一个要包含或排除的记录器列表组成。在 Caddy 中,总是存在一个名为 default 的默认日志。您可以在配置中通过在 此对象 中指定键为 "default" 的日志来定制它。
- 编码器(Encoder):日志的格式。将内存中的数据表示转换为字节切片。编码器可以访问日志消息的所有字段。
- 写入器(Writer):日志输出。可以是任何日志写入模块,例如写入文件或网络套接字。它只是写入字节。
- 级别(Level):日志有不同的级别,从 DEBUG 到 FATAL。低于指定级别的消息将被该日志忽略。
- 采样(Sampling):极热的路径可能会生成比能有效处理的更多日志;启用采样是一种在仍能得到有代表性样本的同时降低负载的方法。
- 包含/排除(Include/exclude):每条消息由一个记录器发出,记录器有一个名称(通常源自模块 ID)。日志可以包含或排除来自某些记录器的消息。
当 Caddy 发出一条日志消息时:
- 检查发出该消息的记录器名称是否与每个日志的包含/排除列表匹配;如果被包含(或未被排除),该消息就会被允许进入该日志。
- 如果启用采样,会进行快速计算以决定是否保留该日志消息。
- 使用该日志配置的编码器对消息进行编码。
- 将编码后的字节写入该日志配置的写入器。
默认情况下,所有消息都会发送到所有配置的日志。这遵循上述结构化日志的价值观。您可以通过设置包含/排除列表来限制哪些消息发送到哪些日志,但这主要用于从不同模块中过滤消息;并不打算将其用作日志聚合服务。为了保持 Caddy 日志管道的简洁和高效,日志消息的高级处理被推迟到消费端。
消费
在消息被发送到输出后,消费者会读取它们、解析它们并进行相应处理。
这是与生成日志完全不同的问题域,Caddy 核心不处理消费(尽管某个 Caddy 应用模块当然可以)。有许多工具可用于处理 JSON 消息流(或其他格式)的流程,并用于查看、过滤、索引和查询日志。您甚至可以编写或实现自己的工具。
例如,如果您运行需要基于特定字段(例如主机名)将 CLF 分开写入不同文件的传统软件,您可以使用或编写一个简单工具来读取 JSON,调用 sprintf() 创建 CLF 字符串,然后根据 request.host 字段的值将其写入相应文件。
Caddy 的日志功能也可用于实现指标和追踪:指标基本上是计数具有特定特征的消息,追踪则基于消息之间的共性将多条消息关联在一起。
通过消费 Caddy 的日志,您可以做出无数的可能性!