冷启动是无服务器计算中按需计价模式所带来的不可避免的问题。本文将介绍 Azure Functions 的冷启动,介绍如何使用 .NET 中的 AOT 编译机制优化冷启动性能,并对其效果进行验证。

Azure Functions 中的冷启动

在无服务器计算的按需计价模式中,当函数在一段时间内未被执行后,其所有的实例都会被释放。当下一次执行请求发生时,必须先启动一个新的实例,函数才能运行。虽然函数计算应用的体量很小,启动新实例的成本很低,但也并非完全没有成本。一个新的函数应用实例启动需要经过以下步骤:

  1. 准备基础设施与运行环境
  2. 将部署的应用从持久存储下载到服务器上
  3. 加载应用配置
  4. 初始化应用

这些步骤需要时间。这种情况下,从执行请求到函数真正开始执行之间存在一个比较明显的延迟。这一延迟对 HTTP 等使用场景具有很大的负面影响。

另外,冷启动不止代表实例数量从 0 到 1 的过程。当负载超过现有实例的处理能力时,也需要启动新的实例,任何路由到新实例执行的请求也可能会受冷启动的影响。

Azure Functions 提供三种托管模式,分别是消耗型、高级型、应用服务型。其中,消耗型最符合标准定义的无服务器计算模式。在消耗型托管模式中,应用的伸缩完全由 Azure 控制,Azure 会在负载高时启动新实例,也会在函数没有执行时回收所有实例。因此,消耗型托管模式下部署的函数应用会受到冷启动的影响。

高级型与消耗型相似,其伸缩也是由 Azure 控制的。但高级型总是保证有至少一个实例处于运行状态,用户需要为这个热实例付费 (费用与消耗型的费用相比十分高昂)。相应地,应用不会经历从 0 到 1 的冷启动过程,函数应用在任何时候都可以保持较快的反应速度。高级型的托管模式还允许应用参与预热过程,即应用在预热过程中即初始化完毕,在正式启动后可以立即执行处理。因此,高级型托管模式下部署的函数应用不受冷启动的影响。

应用服务型则是在应用服务中运行函数应用,本质上是在 “有服务器” 的环境中运行无服务器的代码。这种模式下,函数应用的伸缩由用户控制,而不会自动伸缩,因而不存在 “冷启动” 的概念,自然也不受冷启动影响。

优化冷启动性能的通用方案

我们可以对照上文所示的冷启动过程对冷启动性能进行优化。

步骤 1 完全是由服务提供商控制的,用户无法参与。目前主流的服务提供商都通过预热实例等方式来优化这方面的启动性能。

对于步骤 2 中的应用下载过程,有三种优化路径:

  • 使用更高性能的存储方案
    在 Azure 中,可以使用高级存储服务。高级存储服务使用 SSD 作为存储基础设施,可以提供更大的吞吐率与更低的延迟。

  • 将存储与函数应用部署在相近位置
    在 Azure 中,将存储与函数应用部署在同一 Azure 区域。这不仅可以降低网络延迟,还可以避免产生跨区数据传输的流量费用。

  • 降低部署包体积
    可能是存储方面优化中最有效的方案。尽可能降低依赖数量,将执行频率低又有大量依赖的函数拆分成单独的函数应用并单独部署,以保证最经常执行的应用有不被不必要的依赖拖累。拆分应用也有利于避免应用本身过大。

    如果使用 .NET 开发 Azure Functions 应用,则 Azure 的函数运行时中已经加载了一系列常用的依赖。生成部署包时,生成工具默认会从部署包中剔除这些依赖。尽量不要关闭这一优化,如因依赖版本问题确有必要,则应对具体的依赖关闭优化,而非全局关闭。

对于步骤 3, 则要尽量避免一些特定的应用配置。例如,如果需要在 Windows 平台下使用证书,则必须通过设置 WEBSITE_LOAD_USER_PROFILE 环境变量来加载操作系统的用户配置文件。经测试这将增加 10 - 15 秒的启动延迟,在本人的情况里,占了总启动时间的 75%.

对于步骤 4 中的应用初始化进行优化,则可能是更有效的途径。除了对应用自身的启动性能进行优化外,还可以:

  • 降低依赖数量
    尽量降低启动时加载的依赖数量。可以将具有不同依赖的函数拆分开来单独部署,避免因加载不必要的依赖而耗时。

  • 使用 AOT 编译
    如果使用 .NET 开发,则可以通过 AOT 编译来避免 JIT 编译造成的启动延迟。本文后面将详细介绍这一方案。

.NET 中的 AOT 编译

默认情况下,.NET 使用 JIT 编译。编译器生成 IL 代码,由运行时在代码首次运行时,根据实际运行的平台编译成高度优化的机器代码。这使得 .NET 应用程序可以跨平台运行,也使得最终运行的机器代码具有比 AOT 编译潜在更高的性能。但这同时也意味着代码首次执行时,JIT 编译将会造成延迟。

实际上,JIT 编译可能是不适合函数应用这一场景的。原因如下:

  • 作为服务器应用程序,函数应用的部署平台是确定的,JIT 的跨平台优势在这里无法体现
  • 函数计算的适用场景是轻量级,执行时间短的代码,此时 JIT 产生的机器代码的性能优势可能难以感知
  • JIT 编译会消耗时间,提高冷启动的延迟

此时,可以尝试使用 AOT 编译,提前编译出机器码,以避免 JIT 编译带来的延迟。.NET 的 AOT 编译支持分级编译。当运行时发现某部分代码经常使用时,会重新从 IL 生成高度优化的机器代码,以提高代码的执行性能。

要启用 AOT 编译,则需在生成时加入以下参数:

  • -p:PublishReadyToRun=true
    启用 AOT 编译
  • -r platform
    指定 AOT 编译的目标平台。其中 platform 为目标平台的平台 ID

当然,与任何性能相关的问题相同,使用 AOT 编译并不一定会带来性能提升,在有些情况下甚至可能会降低性能。以下是几个可能的原因:

  • AOT 编译产生的机器代码的优化程度不及 JIT 编译,在特定的使用模式下可能会产生性能问题
  • 为了支持分级编译等功能,在 .NET 中使用 AOT 编译的程序集将同时包括机器代码与 IL 代码,这将增大部署包体积

因此,有必要根据自己应用程序的实际情况,对其效果进行测试。

效果验证

使用如下方法测试 AOT 编译对本人某函数应用的影响:

  1. 在原函数应用中添加一个 HTTP 函数,函数等待 5 秒后返回 200 响应

  2. 使用依赖注入在新添加的 HTTP 函数中注入其它函数常用的依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class ColdStartTestFunction
    {
    public ColdStartTestFunction(HttpClient httpClient, CosmosClient cosmosClient)
    {
    this.httpClient = httpClient;
    this.cosmosClient = cosmosClient;
    }

    [FunctionName("ColdStartTest")]
    public async Task<IActionResult> RunAsync(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req)
    {
    await Task.Delay(5000);
    return new OkResult();
    }

    private readonly HttpClient httpClient;
    private readonly CosmosClient cosmosClient;
    }
  3. 将函数应用在 Linux x64 与 Windows x86 两种平台,JIT 与 AOT 两种编译配置下部署共四套

    之所以在 Windows 上使用 32 位平台,是因为如上文所述,在 Azure 上冷启动只对消耗型函数有影响。而 Azure 消耗型函数每个实例只使用一个 vCore 以及最大 1.5 GB 内存。因此对于消耗型托管方案,使用 64 位平台是没有好处的,还会因为内存地址长度翻倍而增大应用程序的内存用量。在 Linux 上运行的函数只提供 64 位选项。

  4. 编写另一个函数应用,每隔 2 - 60 分钟对上述四个函数各发送一次请求

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    public class ColdStartTestActivator
    {
    public ColdStartTestActivator(IConfiguration config)
    {
    linuxAotUri = config.GetValue<string>("LinuxAotUri");
    linuxJitUri = config.GetValue<string>("LinuxJitUri");
    winAotUri = config.GetValue<string>("WindowsAotUri");
    winJitUri = config.GetValue<string>("WindowsJitUri");
    }

    [FunctionName("ColdStartTest")]
    public async Task RunOrchestrator([OrchestrationTrigger] IDurableOrchestrationContext context)
    {
    int delay = await context.CallActivityAsync<int>("ColdStartTest_Activity", null);
    await context.CreateTimer(context.CurrentUtcDateTime.AddMinutes(delay), CancellationToken.None);
    context.ContinueAsNew(null);
    }

    [FunctionName("ColdStartTest_Activity")]
    public async Task<int> SayHello([ActivityTrigger] object input, ILogger log)
    {
    HttpClient client = new HttpClient();
    Task linuxAot = client.GetAsync(linuxAotUri);
    Task linuxJit = client.GetAsync(linuxJitUri);
    Task winAot = client.GetAsync(winAotUri);
    Task winJit = client.GetAsync(winJitUri);
    try
    {
    await Task.WhenAll(linuxAot, linuxJit, winAot, winJit);
    }
    catch (Exception ex)
    {
    log.LogWarning(ex, "Error making test requests.");
    }
    return new Random((int)DateTime.Now.Ticks).Next(2, 60);
    }

    [FunctionName("ColdStartTest_HttpStart")]
    public async Task<IActionResult> HttpStart(
    [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestMessage req,
    [DurableClient] IDurableOrchestrationClient starter)
    {
    string instanceId = "6fe8a9ea-ff6c-4682-8253-27d8c5461c4d";
    var existingInstance = await starter.GetStatusAsync(instanceId);
    if (existingInstance != null)
    {
    if (existingInstance.RuntimeStatus == OrchestrationRuntimeStatus.Running
    || existingInstance.RuntimeStatus == OrchestrationRuntimeStatus.ContinuedAsNew
    || existingInstance.RuntimeStatus == OrchestrationRuntimeStatus.Pending
    || existingInstance.RuntimeStatus == OrchestrationRuntimeStatus.Unknown)
    {
    return new OkResult();
    }
    }
    await starter.StartNewAsync("ColdStartTest", instanceId);
    return new OkResult();
    }

    private readonly string linuxAotUri;
    private readonly string linuxJitUri;
    private readonly string winAotUri;
    private readonly string winJitUri;
    }
  5. 四个函数在执行时会向 Application Insights 报告其运行的时长与宿主的 ID. 每个宿主 ID 的第一次请求必为冷启动。因而可以编写 Kusto 查询来获取冷启动时长的数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    (requests
    | where cloud_RoleName == "coldstart-win-jit"
    and name == "ColdStartTest"
    | summarize arg_min(timestamp, corrected = duration - 5000) by tostring(customDimensions.HostInstanceId)
    | summarize avg(corrected), min(corrected), max(corrected), percentile(corrected, 90), percentile(corrected, 60), count()
    | extend category = "Windows JIT")
    | union
    (requests
    | where cloud_RoleName == "coldstart-win-aot"
    and name == "ColdStartTest"
    | summarize arg_min(timestamp, corrected = duration - 5000) by tostring(customDimensions.HostInstanceId)
    | summarize avg(corrected), min(corrected), max(corrected), percentile(corrected, 90), percentile(corrected, 60), count()
    | extend category = "Windows AOT")
    | union
    (requests
    | where cloud_RoleName == "coldstart-linux-jit"
    and name == "ColdStartTest"
    | summarize arg_min(timestamp, corrected = duration - 5000) by tostring(customDimensions.HostInstanceId)
    | summarize avg(corrected), min(corrected), max(corrected), percentile(corrected, 90), percentile(corrected, 60), count()
    | extend category = "Linux JIT")
    | union
    (requests
    | where cloud_RoleName == "coldstart-linux-aot"
    and name == "ColdStartTest"
    | summarize arg_min(timestamp, corrected = duration - 5000) by tostring(customDimensions.HostInstanceId)
    | summarize avg(corrected), min(corrected), max(corrected), percentile(corrected, 90), percentile(corrected, 60), count()
    | extend category = "Linux AOT")
    | order by percentile_corrected_90
    | project Category = category, Count = count_, Average = avg_corrected, Min = min_corrected, Max = max_corrected, P90 = percentile_corrected_90, P60 = percentile_corrected_60
  6. 放置数日,得到以下结果 (时间数据单位皆为毫秒)

    测试结果表示,在 90 百分位时,Windows JIT 启动用时约为 5215 毫秒;Linux AOT 启动用时约为 2986 毫秒;Linux JIT 启动用时约为 2796 毫秒;Windows AOT 启动用时约为 1681 毫秒

    各次测试结果的散点图

    可以发现,使用 AOT 编译,在 Windows 环境下可以带来 68% 的冷启动性能提升,在 Linux 环境下则影响不大。

    此外,在不使用 AOT 编译的情况下,Windows 环境中部署的函数应用的冷启动时间远长于 Linux 环境中的函数应用。但使用 AOT 编译后,Windows 环境中部署的函数应用在冷启动性能方面呈现出显著优势。

    此外,在相同的测试条件下,Windows 环境中部署的函数应用的冷启动次数比 Linux 环境中的应用冷启动次数少 16%. 这意味着,使用 Windows 环境部署函数应用将有利于降低冷启动的频率。

根据以上结果,本人决定在部署该函数应用时选择 Windows 环境,并开启 AOT 编译。

总结

经过测试,AOT 编译在部分情况下可以显著提升函数应用冷启动的性能。在着手对 .NET 函数应用的冷启动性能进行优化时,可以在经过对具体应用场景的测试后,启用 AOT 编译。