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

RFC-0069:ELF 运行程序中的标准 I/O
状态已接受
领域
  • 组件框架
说明

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

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

总结

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

设计初衷

组件管理器启动后,其 stdout 和 stderr 流会绑定到调试日志,因为在启动过程的这一阶段不存在替代方案。例如 Archivist 是 Archivist,提供 LogSink 服务的组件本身由组件管理器启动。

最近,组件管理器会将所有组件的 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 句柄,并将它们路由到调试日志。因此,合理地假设 Fuchsia 中的许多开发者都依赖此功能。随着 2021 年临近,开始迁移越来越多的组件,我们应该允许开发者维护他们所依赖的 stdout 和 stderr 支持。

要求

后备日志记录服务必须是 LogSink,而非 debuglog。出于多种原因,我们必须改用 LogSink。首先,debuglog 旨在用于内核,如上所述。其次,debuglog 为所有进程使用小型 (128kb) 共享环缓冲区,并在 FIFO 基础上轮替消息。Archivist 会定期排空这些消息,并将其转发到 LogSink。但是,如果在 Archivist 将它们读取到 LogSink 之前,它们就被从调试日志缓冲区中推出,就有可能会被“丢失”。将 LogSink 用于 stdout 和 stderr 日志记录服务不仅可以消除消息被丢弃的可能性,还可以降低使用调试日志的其他组件和进程被丢弃其消息的可能性。此外,当缓冲区旋转速度超过排空速度时,没有现有的机制可以跟踪丢失了多少丢失内容。第三,调试日志不支持严重级别(例如 DEBUG、INFO 等)这是一项关键要求,因为我们需要区分 stdout 消息和 stderr 消息。对于日志记录,我们只能通过将每个输出流映射到特定的严重级别来实现这一点。

设计

我们提议在 ELF 组件的 program 节中引入两个新的枚举值:forward_stdout_toforward_stderr_to。对于 ELF 组件,枚举将是可选的,并且默认为 none。如果未设置,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 发送垃圾内容来拒绝服务。因此,您无需执行任何其他操作。不过请注意,此功能提供了一种机制,可将任意字节放入另一个进程的地址空间(从组件到 Archivist)的地址空间中。如果缓冲区过大,则可能会出现问题,尽管我们在上面提到了缓解措施。此外,由于此实现几乎可以处理输入流,因此我们可以降低权限升级的风险。如果这是一个复杂的解析器,出现 bug 和漏洞的可能性就会增加。

隐私注意事项

LogSink 后端和所有 LogSink 客户端已经符合隐私保护规定,日志被归因于其来源,并且已经有充分的 PII 清理机制。因此,您无需执行任何其他操作。

测试

在单元测试之外,我们还将添加集成测试,以确保将日志写入 Archivist。这些集成测试将使用所有支持的语言编写:C/C++、Rust 和 Go。系统不会测试 Dart,因为 ELF 运行程序不会处理 Dart 组件执行。

文档

此标志将记录在“组件 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 的运行程序(如“网络”运行程序)会是什么样子?因此,我们决定将 POSIX 兼容性问题作为单独的工作流进行研究,并不在此 RFC 的讨论范围内。

新组件简介

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

FDIO

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