• lambda nodejs 函数降低冷启动时间的最佳实践


    bg

    lambda nodejs 函数降低冷启动时间的最佳实践

    前言

    本文章的思路,继承发展自这两篇文章:

    这里要感谢这2篇文章的作者:ice breaker2年前就提供了这么优秀的思路和解决方案了,真是忍不住给他点赞呀。

    首先在看这篇文章之前,我先必须给你介绍一个概念,就是 冷启动时间

    什么是冷启动时间

    这个特性各个服务商的 serverless 云函数都存在,这个和 函数容器的生命周期 息息相关。

    Lambda 为例,Lambda 生命周期可以分为三个阶段:

    • Init:在此阶段,Lambda 会尝试解冻之前的执行环境,若没有可解冻的环境,Lambda 会进行资源创建,下载函数代码,初始化扩展和 Runtime,然后开始运行初始化代码(主程序外的代码)。
    • Invoke:在此阶段,Lambda 接收事件后开始执行函数。函数运行到完成后,Lambda 会等待下个事件的调用。
    • Shutdown:如果 Lambda 函数在一段时间内没有接收任何调用,则会触发此阶段。在 Shutdown 阶段,Runtime 关闭,然后向每个扩展发送一个 Shutdown 事件,最后删除环境。

    冷启动

    当您在触发 Lambda 时,若当前没有处于激活阶段的 Lambda 可供调用,则 Lambda 会下载函数的代码并创建一个 Lambda 的执行环境。从事件触发到新的 Lambda 环境创建完成这个周期通常称为 “冷启动时间”。显然,这个时间肯定是越短越好的。

    这里可以参考 AWS 这篇博客 以获取更多信息。

    其中 AWS 提供的几种降低冷启动时间的方式有:

    • 选择合适的编程语言 (我们大部分情况无法更换)
    • 减小应用程序大小
    • 预热 (定时触发器防止回收和预置并发,保留实例)
    • JVM 分层编译(java特供)

    其中可行性最高的方式,就是本篇文章要探讨的 减小应用程序大小

    打包服务端 js

    回到正题,为什么要去打包服务端 js 代码呢? 用 layer 的方式不是蛮好吗?

    这里必须要知道的一点是,函数冷启动的时间,是和整体运行以及其依赖的代码包大小,是息息相关的。

    比如上篇文章中的示例,我们把 uuid 这个依赖给做成 layer 上传了上去,但是你有没有想过,既然 uuid 的所有实现都是 js,为什么不把它整个源代码,打入我们的函数构建产物中呢?这样还省了依赖一个 layer 呢。

    同样的道理,我们函数也可以把 express,lodash 等等依赖,全部打入我们的函数包里去,以减小整体代码包的体积。

    这就像我们在写前端项目那样,本质上也会把所有运行时代码,全部给 inline 打成一个一个 chunk 到各个 js 里面去,毕竟浏览器可没有什么 node_modules 的加载机制。你写 vue 还是写 react 都是直接加载它们inline的整个代码的。

    什么是 inline

    这里给一个例子: 原先你的ts代码可能是这样写的:

    import express from 'express'
    
    • 1

    然后经过 tsc,产物变成了这样:

    // commonjs format
    const express = require('express')
    
    • 1
    • 2

    而假如走 inline 那产物中就不会出现 express,而是直接把 express 相关的代码全部给打了进来,效果类似于:

    // 部分代码
    var require_express = __commonJS({
      "../../node_modules/.pnpm/express@4.18.2/node_modules/express/lib/express.js"(exports, module2) {
        "use strict";
        var bodyParser2 = require_body_parser();
        var EventEmitter = require("events").EventEmitter;
        var mixin = require_merge_descriptors();
        var proto = require_application();
        var Route = require_route();
        var Router = require_router();
        var req = require_request2();
        var res = require_response2();
        exports = module2.exports = createApplication;
        function createApplication() {
          var app2 = function(req2, res2, next) {
            app2.handle(req2, res2, next);
          };
          mixin(app2, EventEmitter.prototype, false);
          mixin(app2, proto, false);
          app2.request = Object.create(req, {
            app: { configurable: true, enumerable: true, writable: true, value: app2 }
          });
          app2.response = Object.create(res, {
            app: { configurable: true, enumerable: true, writable: true, value: app2 }
          });
          app2.init();
          return app2;
        }
        exports.application = proto;
        exports.request = req;
        exports.response = res;
        exports.Route = Route;
        exports.Router = Router;
        exports.json = bodyParser2.json;
        exports.query = require_query();
        exports.raw = bodyParser2.raw;
        exports.static = require_serve_static();
        exports.text = bodyParser2.text;
        exports.urlencoded = bodyParser2.urlencoded;
        var removedMiddlewares = [
          "bodyParser",
          "compress",
          "cookieSession",
          "session",
          "logger",
          "cookieParser",
          "favicon",
          "responseTime",
          "errorHandler",
          "timeout",
          "methodOverride",
          "vhost",
          "csrf",
          "directory",
          "limit",
          "multipart",
          "staticCache"
        ];
        removedMiddlewares.forEach(function(name) {
          Object.defineProperty(exports, name, {
            get: function() {
              throw new Error("Most middleware (like " + name + ") is no longer bundled with Express and must be installed separately. Please see https://github.com/senchalabs/connect#middleware.");
            },
            configurable: true
          });
        });
      }
    });
    // ......
    
    • 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
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69

    显然这种方式下,可以打出更小更单一的包,因为所有的碎片化的 js 依赖,都被打成到了单文件里面去了,减少了 io 的次数,而且还能够一起制定策略,如 split chunk or compress

    要实现这种效果,其实基本上所有流行的打包工具都内置了这个功能。

    比如 webpack/esbuild,当然 rollup 也有对应的插件支持,@rollup/plugin-node-resolve 就是它的实现方式之一。

    其中笔者文章开头提到的 2 篇文章里的实现方式,就是基于 rollup 这个工具去实现的,在此不再叙述。在 2 年后的今天,也很高兴看到了更多,基于它们的开箱即用的工具,使得我们不需要安装大量的插件或者编写复杂的打包配置,就能实现同样的效果,让我们一起来看看吧。

    进一步封装的打包工具

    在过去,比较构建库比较流行 rollup 或者 esbuild,不过现在有了基于它们更进一步的打包工具: unbuild / tsup

    其中 unbuild 这个工具先跳过,我们会在 monorepo 章节中介绍它,这里我们主要来介绍 tsup 在函数打包中的用法。

    tsupesbuild 进一步封装而来。它太开箱即用了,甚至可以 0 config。它本身的打包配置,主要是基于约定的:

    比如它会默认去 inline 我们所有在运行时引用的,但是却是注册在 devDependencies 里的包

    dependencies 里的包,则是被默认加入了 external 中,不进行 node resolve

    这个约定实际上很简单却很实用,类似于这样 rollup 的配置:

    // rollup.config.ts
    import { readFileSync } from 'node:fs'
    const pkg = JSON.parse(
      readFileSync('./package.json', {
        encoding: 'utf8'
      })
    )
    const dependencies = pkg.dependencies as Record<string, string> | undefined
    const config: RollupOptions = {
      // ...
      plugins: [
        json(),
        nodeResolve(),
        commonjs(),
        typescript()
      ],
      external: [...(dependencies ? Object.keys(dependencies) : [])]
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    显然对比起来 tsup.config.ts 文件的配置就 简洁 多了,见下:

    // tsup.config.ts
    import { defineConfig } from 'tsup'
    const isDev = process.env.NODE_ENV === 'development'
    export default defineConfig({
      entry: ['src/index.ts'],
      splitting: false,
      sourcemap: isDev,
      clean: true,
      // external: []
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    接着注册指令即可 package.json 里的 npm scripts (这里我加了 cross-env 包和 NODE_ENV 环境变量是为了做更多,比如条件编译等等事情)

      "scripts": {
        "dev": "cross-env NODE_ENV=development tsup --watch",
        "build": "cross-env NODE_ENV=production tsup",
      },
    
    • 1
    • 2
    • 3
    • 4

    这样执行这命令,你的函数就被打入 dist/inde.js 里了,赶紧去检查一下产物吧。

    存在的弊端以及解决方案

    上面说的这种打包方式其实存在一定的弊端:

    1. 首先,它改变了第三方依赖的目录结构
    2. 其次它只能处理一些 js 依赖,不使用特定的插件,常常会出现一些非 js 依赖的缺失

    这个问题的严重性会导致一系列的问题,比如某些包源代码里面是依赖文件目录的:

    // node_modules/some-lib/dist/index.js
    const defaultDbFile = path.resolve(__dirname, '../data/ip2region.xdb')
    
    • 1
    • 2

    那这行代码被打入我们函数包就会有问题,因为目录结构被破坏了,这会导致第三方包调用出错。

    目录结构的变化如下:

    (打包前)本地可以运行的目录结构

    • src
      • index.ts
    • node_modules
      • some-lib
        • dist
          • index.js
        • data
          • ip2region.xdb

    (打包后)

    • dist
      • index.js
    • node_modules
      • some-lib
        • dist (用不到了)
          • index.js
        • data
          • ip2region.xdb

    注意此时 node_modules/some-lib/dist/index.js 里的代码inline到了 dist/index.js 里去了。但是 defaultDbFile 的引用的路径却变化了,因为 __dirname 变化了,此时正确的路径实际上是 path.resolve(__dirname, '../node_modules/some-lib/data/ip2region.xdb')

    那么如何解决呢?目前比较好的解决方案,是使用 external 的方式,不去主动 inline 那些可能会导致问题的包,并把那些包挑出来,做成 layer 再进行绑定。幸好这种包是小概率会遇到的,测试环境很容易发现问题。

    Next Chapter

    现在你已经学会了打包服务端代码的策略。

    下一篇,《更灵活的 serverless framework 配置文件》中,将会详细介绍如何让你的部署配置文件变得灵活起来。

    完整示例及文章仓库地址

    https://github.com/sonofmagic/serverless-aws-cn-guide

    如果你遇到什么问题,或者发现什么勘误,欢迎提 issue 给我

  • 相关阅读:
    AIGC实战——基于Transformer实现音乐生成
    数据分享|R语言逻辑回归、线性判别分析LDA、GAM、MARS、KNN、QDA、决策树、随机森林、SVM分类葡萄酒交叉验证ROC...
    Spring基础篇:面向切面编程
    Cookie与Session详解
    数据存储基础——存储类型
    Fiddler抓取手机app包
    Vue Admin分享
    CCF ChinaSoft 2023 论坛巡礼 | 智能化软件开发和维护论坛
    Jenkins实战中的一些技巧
    sql 时间有偏差的解决方法
  • 原文地址:https://blog.csdn.net/qq_33020601/article/details/132742846