RFC-0069:ELF Runner 中的标准 I/O

RFC-0069:ELF Runner 中的标准 I/O
状态已接受
区域
  • 组件框架
说明

ELF 组件将 stdout 和 stderr 流转发到 LogSink 服务的机制。

Gerrit 更改
作者
审核人
提交日期(年-月-日)2021-02-02
审核日期(年-月-日)2021-02-17

摘要

引入了两个新标志 forward_stdout_toforward_stderr_to,用于控制希望在启动时接收 stdout 和/或 stderr 流的组件的选择接受行为。启用后,这些数据流将由 LogSink 服务提供支持。

设计初衷

启动组件管理器时,其标准输出和标准错误输出流会绑定到debuglog,因为启动过程中的此阶段没有其他替代方案。例如,提供 LogSink 服务的组件 Archivist 本身就是由组件管理器启动的。

直到最近,组件管理器才将所有组件的 stdout 和 stderr 流重定向到内核的调试日志。为此,我们复制了组件管理器自己的 stdout 和 stderr 句柄,这些句柄本身已绑定到调试日志(如上所详述)。不过,这带来了一些问题。首先,用户模式组件不应写入调试日志,因为该日志仅供内核使用和具有高特权的用户模式组件使用;相反,大多数用户模式组件应改为写入 LogSink 服务。其次,在用户未明确选择的情况下提供两个出站数据流违反了最小权限原则

目前,组件管理器完全缺少此功能,我们希望在解决之前存在的两个缺点的同时,将其重新引入。我们建议向 ELF 运行程序添加一个新标志,以便明确选择启用,而不是默认向所有组件授予 stdout 和/或 stderr。

组件框架团队正在进行一项长期的迁移,即从 appmgr(组件 v1)迁移到组件框架(组件 v2)。这项工作的一个主要项目是迁移 Netstack 团队拥有的所有组件。迁移所有这些组件的前提是支持 stdout/stderr。之所以有此要求,是因为 Netstack 组件是使用 Go 编写的。与使用 C++ 或 Rust 编写的程序不同,Go 程序是在运行时环境中执行的,该环境会在设置期间向 stderr 发出错误,然后开发者编写的程序才会开始运行。由于错误会在程序的入口点之前记录,因此 Go 组件作者无法将 stdout 和 stderr 句柄绑定到日志记录服务。为了解决此问题,我们可以分叉并修改 Go 的运行时,以便在开始执行用户编写的 Go 程序之前添加必要的日志记录初始化。当然,这会给我们带来维护 Go 分支的巨大技术负担。或者,组件管理器可以在组件(和 Go 运行时)启动之前将 stdout 和 stderr 句柄绑定到日志记录服务,以便 Fuchsia 的日志记录服务捕获错误消息。

更广泛地说,我们希望减轻上述 v1 向 v2 迁移工作的技术负担。目前,appmgr 会向所有 v1 组件提供 stdout 和 stderr 句柄,并将其路由到 debuglog。因此,我们有理由假设 Fuchsia 中的许多开发者都依赖于此功能。随着 2021 年的到来,我们开始迁移越来越多的组件,因此应允许开发者维护他们依赖的 stdout 和 stderr 支持。

要求

后备日志记录服务必须是 LogSink,而不是 debuglog。我们必须改用 LogSink 的原因有很多。首先,如上所述,debuglog 适用于内核使用。其次,debuglog 为所有进程使用一个小型(128kb)共享环形缓冲区,并按 FIFO 方式轮替消息。归档程序会定期清空这些消息,并将其转发到 LogSink。不过,如果这些日志在归档程序将其读取到 LogSink 之前就从调试日志缓冲区中滚出,则可能会丢失。为标准输出和标准错误输出日志记录服务使用 LogSink 不仅可以消除消息丢失的可能性,还可以降低使用调试日志的其他组件和进程丢失消息的可能性。此外,目前还没有任何机制可以跟踪当缓冲区旋转速度快于其耗尽速度时丢失了多少数据。第三,debuglog 不支持严重程度级别(例如 DEBUG、INFO 等)这是一项关键要求,因为我们需要区分 stdout 消息和 stderr 消息。至于日志记录,我们只能通过将每个输出流映射到特定的严重级别来实现此目的。

设计

该提案旨在为 ELF 组件引入两个新的枚举值 forward_stdout_toforward_stderr_toprogram 诗节。枚举对于 ELF 组件是可选的,默认为 none。如果为 null,ELF 运行程序将不会将 stdoutstderr 句柄绑定到 LogSink 服务(当前行为),并且系统会继续忽略这些流。将这些值设置为 log 后,ELF 运行程序将创建一个套接字,用于捕获 stdoutstderr 流的输出。然后,它会将读取的字节转发到 LogSink 服务。对于 stdout,它会发送 INFO 消息;对于 stderr,它会发送 WARN 消息。消息将以换行符分隔,并且每行都将作为原子消息分区到 LogSink 服务。LogSink 服务的消息大小上限为 32KB,因此我们将字节流缓冲区上限设为 30KB(以留出一些空间来存储消息元数据)。超出该边界的字节将被舍弃,并且只有部分消息会发送到 LogSink 服务。这引发了一个有趣的极端情况,即代码点的一部分可以在 30KB 边界处拆分。在这种情况下,系统不会执行任何特殊处理,并且会按原样解码代码点的无效前半部分。所有输入字节都将使用 String::from_utf8_lossy 解码为 UTF-8。根据此函数的 API,所有无效的 UTF-8 序列都将替换为 U+FFFD REPLACEMENT CHARACTER

{
    program: {
        "runner": "elf",
        "forward_stdout_to": "log",
        "forward_stderr_to": "log",
    }
}

实现

由于该功能将仅限于 ELF 运行程序,因此实现所需的更改非常少。我们预计,实现此更改只需 2 个 CL 即可。

性能

由于行只会发送到 LogSink,因此性能开销非常低。此功能将引入的最显著开销是字节流解析和换行符拆分。不过,由于日志记录是一项不规则的操作,并且字节流本身往往很短,因此此开销相对较低。更重要的是,这只会影响明确选择启用此行为的组件。因此,如果确实出现了性能问题,我们只需在这些组件中将 forward_stdout_toforward_stderr_to 设置为 none,暂时回到起点,直到解决根本性能问题,即可快速解决问题。

安全注意事项

Archivist 已实施归因和流量控制机制,因此行为异常的组件无法通过向其标准输出 (stdout) 发送大量数据来拒绝服务。因此,您无需执行任何其他操作。不过,我们应注意,此功能提供了一种将任意字节放入其他进程地址空间(从组件到归档器)的机制。如果缓冲区过大,这可能会造成问题,但我们在上面提到了缓解措施。此外,由于此实现会尽可能少地处理输入流,因此可降低提权风险。如果这是一个复杂的解析器,则出现 bug 和漏洞的可能性会增加。

隐私注意事项

LogSink 后端和所有 LogSink 客户端均已符合隐私保护要求,日志会归因于其来源,并且已实施足够的个人身份信息 (PII) 清理机制。因此,您无需执行任何其他操作。

测试

除了单元测试之外,我们还将添加集成测试,以确保日志会写入归档程序。这些集成测试将使用所有受支持的语言编写:C/C++、Rust 和 Go。由于 ELF 运行程序不处理 Dart 组件执行,因此不会对 Dart 进行测试。

文档

此标志将在 Components v2 文档的 ELF 运行程序部分中记录。

缺点、替代方案和未知情况

编号标识名

我们探索了从进程框架向组件框架提升编号句柄的方式来支持此功能。虽然我们最终决定不这样做,但清单文件的原始设计大致如下所示:

{
    program: {
        "runner": "elf",
        "handles": ["STDOUT", "STDERR"]
    }
}

ELF 运行程序会读取常量字符串,并在内部将其映射到相应的编号句柄。最终,我们决定不这样做,因为我们不知道另一个编号标识名的直接用例。此外,如果我们确实找到了应在 ELF 运行程序的 program 诗节中配置的另一个编号句柄,则可以轻松更新清单文件语法和 ELF 运行程序实现。

组件管理器

我们还探索了为编号句柄引入新的框架级功能。清单文件的粗略设计如下所示:

{
    use: [
        {
            "handle": "stdout-to-log",
            "from": "framework",
            "number": "STDOUT",
        },
        {
            "handle": "stderr-to-log",
            "from": "framework",
            "number": "STDERR",
        }
    ]
}

由于上述与编号句柄相关的原因,以及在组件管理器级别引入此功能会引发与组件管理器的 POSIX 兼容性相关的新问题,因此我们认为这种方法不适用。例如,所有运行程序是否都必须实现此功能?对于不使用 stdout/stderr 的运行程序(例如“web”运行程序),该如何处理?因此,我们决定将 POSIX 兼容性问题作为一个单独的工作流程来处理,不在本 RFC 的范围内。

推出新组件

我们还探索了在由组件框架团队拥有和管理的新组件中实现转换层(用于解析 stdout/stderr 字节流并将其转发到 LogSink 服务的部分)。不过,经过多次讨论,我们认为这种方法不切实际,因为我们必须设计一种方法来在记录消息时保留日志归因。归档程序使用事件功能获取组件来源信息(例如标识名),如果我们使用中间层组件,则所有这些信息都会丢失。

FDIO

我们还探索了使用 Fuchsia 的 POSIX 兼容库 fdio 来实现此功能。也就是说,创建一个新类型来识别 stdout/stderr 文件描述符,并在内部(在 fdio 中)将输出重定向到 LogSink。不过,经过多次讨论,我们决定放弃修改 fdio,因为这样做会使实现变得更加困难。我们发现,在 POSIX 兼容性方面存在一些极端情况,无法使用 fdio 中的 LogSink 转发器实现。此外,基于 fdio 的实现会产生更多不确定性,并会重复工作。或者,如果我们使用套接字(如上所述),则它将“开箱即用”地符合 POSIX 规范。