优化 Serverless 函数冷启动时间

偏向技术
/ 0 评论 / 223 阅读 / 正在检测是否收录...
温馨提示:
本文最后更新于2021年09月23日,已超过1006天没有更新,若内容或图片失效,请留言反馈。

函数冷启动是使用 serverless 绕不过去的一个坎,无法彻底消除,只能极尽优化,冷启动是指在函数调用链路中包含了代码下载、启动函数实例容器、运行时初始化、用户代码初始化等环节。除平台自有优化外,我们能够做的优化有,代码包尽可能的小,和钱有关的优化有,内存适当加大,使用定时触发器预热函数,使用平台预留资源。

本文通过将 fastify 服务部署到阿里云函数计算来聊聊优化的经历,先说说原先的问题,普通打包后代码约 300M 左右,使用自定义环境,添加了版本较新的环境的包,冷启动耗时平均在 5 ~ 10s,加上 Web 端,得有 20s 左右,这意味着 99% 的用户会在页面渲染完成之前离开网站。

Rollup 是一个用于 Javascript 的模块打包工具,允许用户将代码和依赖打包成单个文件,输出到不同的类型,应用到不同的环境,更为突出的是 Rollup 的摇树优化(Tree-shaking)功能,由于许多包并未友好处理 package.json 的 files 字段,导致我们安装依赖时会下载许多额外的文件,这时候就需要 tree shaking 进行优化。借助官方和社区众多的插件,我们可以仅依靠配置文件就将项目打包成单个体积极小的文件,相比 Webpack 的庞大复杂,令人摸不着头脑,Rollup 不仅简单易用,容易上手,而且官方文档非常清晰,仅一个页面,再借助 gulp,即使是有不同的需求,也能轻易实现。

Rollup 配置

一个简单的应用于开发时的 rollup 配置如下,其含义为,指定入口文件为 src 目录下的 app.ts,输出 commonjs 类型到 outputDir 目录,同时保留模块结构,通过插件 clear 清空输出目录,copy 复制相关文件到输出目录,dynamicImportVars 处理动态模块引入,typescript 处理 typescript 代码,run 在构建完毕后会自动运行,watch 监听文件改动。

javascript
// rollup.config.js
const devConfig = {
  input: './src/app.ts',
  output: {
    dir: outputDir,
    format: 'cjs',
    exports: 'named',
    preserveModules: true,
    preserveModulesRoot: 'src',
  },
  plugins: [
    clear({targets: [outputDir]}),
    copy({
      targets: [{src: './.env.development', dest: outputDir, rename: '.env'}]
    }),
    dynamicImportVars({include: ['src/**']}),
    typescript(),
    run()
  ],
  watch: {
    chokidar: true,
    clearScreen: false,
  }
}
123456789101112131415161718192021222324

打包优化主要用于最终编译,根据不同的需求来使用不同的配置,同样的,使用 rollup 不意味着完全无修改,因为 rollup 会对第三方包进行 tree shaking,所以,如果使用的依赖不支持 tree shaking,那么就需要手动进行修改转换,还有一个在使用过程中更常见的问题是循环依赖(circular dependencies),除开可忽略不影响功能的依赖外,其余的就需要我们手动对源码进行删改,这里考究的是对自己项目用到依赖的功能有一个详细的了解,下面贴出生产模式下的 rollup 配置,其中插件 strip 根据 label 移除相应的块,replace 替换字符串,json 转换 json 文件为模块,nodeResolve 解析 node_modules 模块,commonjs 转换 CommonJS 代码为 es6 代码,terser 压缩代码,filesize 显示包文件大小,通过 rollup 提供的 hook 钩子,实现我们自己的处理。

javascript
// rollup.config.js
const buildConfig = {
  input: './src/app.ts',
  output: {
    file: outputDir + '/app.js',
    format: 'cjs',
    inlineDynamicImports: true,
    exports: 'named'
  },
  plugins: [
    clear({targets: [outputDir]}),
    copy({
      targets: [
        {src: ['./node_modules/nodejieba/dict', './node_modules/nodejieba/build/Release/nodejieba.node'], dest: path.join(outputDir, nodejiebaDir)},
      ]
    }),
    isProd && strip({include: ['**/*.ts', '**/*.js'], labels: ['development']}),
    json(),
    replace({
      preventAssignment: true,
      values: {
        'require(\'readable-stream/transform\')': 'require(\'stream\').Transform',
        'require("readable-stream/transform")': 'require("stream").Transform',
        'readable-stream': 'stream'
      }
    }),
    {
      async transform(code, id) {
        // mysql2
        if (id.match(/node_modules[\\/]mysql2[\\/]index\.js$/)) {
          code = code.replace('exports.__defineGetter__(\n' +
            '  \'createConnectionPromise\',\n' +
            '  () => require(\'./promise.js\').createConnection\n' +
            ');\n' +
            '\n' +
            'exports.__defineGetter__(\n' +
            '  \'createPoolPromise\',\n' +
            '  () => require(\'./promise.js\').createPool\n' +
            ');\n' +
            '\n' +
            'exports.__defineGetter__(\n' +
            '  \'createPoolClusterPromise\',\n' +
            '  () => require(\'./promise.js\').createPoolCluster\n' +
            ');', '')
        }
        if (id.match(/node_modules[\\/]mysql2[\\/]lib[\\/]pool\.js$/)) {
          code = code.replace('const mysql = require(\'../index.js\');', 'const SqlString = require(\'sqlstring\');');
          code = code.replace(/mysql/g, 'SqlString');
          code = code.replace('  promise(promiseImpl) {\n' +
            '    const PromisePool = require(\'../promise\').PromisePool;\n' +
            '    return new PromisePool(this, promiseImpl);\n' +
            '  }', '')
        }
        if (id.match(/node_modules[\\/]mysql2[\\/]lib[\\/]pool_connection\.js$/)) {
          code = code.replace('const Connection = require(\'../index.js\').Connection;', 'const Connection = require(\'./connection.js\');');
          code = code.replace('  promise(promiseImpl) {\n' +
            '    const PromisePoolConnection = require(\'../promise\').PromisePoolConnection;\n' +
            '    return new PromisePoolConnection(this, promiseImpl);\n' +
            '  }', '')
        }
        if (id.match(/node_modules[\\/]mysql2[\\/]lib[\\/]connection\.js$/)) {
          code = code.replace('  promise(promiseImpl) {\n' +
            '    const PromiseConnection = require(\'../promise\').PromiseConnection;\n' +
            '    return new PromiseConnection(this, promiseImpl);\n' +
            '  }', '')
        }
        // nodejieba
        if (id.match(/node_modules[\\/]nodejieba[\\/]index\.js$/)) {
          code = code.replace(`var binary = require('@mapbox/node-pre-gyp');`, '')
          code = code.replace(`var path = require('path');`, '')
          code = code.replace(`var binding_path = binary.find(path.resolve(path.join(__dirname,'./package.json')));`, '')
          code = code.replace(`var nodejieba = require(binding_path);`, `var nodejieba = require("./external/nodejieba/nodejieba.node");`)
          code = code.replace(/\s(dict|hmmDict|userDict|idfDict|stopWordDict)\s/g, ` let $1 `)
          code = code.replace(/__dirname \+ "(.*?)"/g, `"${nodejiebaDir}$1"`)
        }
        return code;
      }
    },
    dynamicImportVars({include: ['src/**']}),
    nodeResolve({preferBuiltins: false}),
    typescript(),
    commonjs({ignore: id => id.includes('nodejieba.node')}),
    terser(),
    filesize(),
  ]
}
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586

使用 rollup 打包之后的源码仅有 500k 不到,加上 nodejieba 模型文件也才 5m 左右,现在冷启动基本保持在 1s 以内

由于 nodejieba 在安装时有区分环境,所以我们需要使用 fun buiild 命令通过 docker 来保证和函数计算环境的一致性,或者从 nodejieba 官方仓库下载 linux 环境的 nodejieba.node

运行环境修改

修改 fastify 服务运行环境为 nodejs12,当使用 custom 运行环境时,实际容器内 node 版本为 10.x.x,恰好是 nodejieba 不支持的版本,所以我们需要修改运行环境,以 http 触发器为例,提供以下的代理函数,api 网关事件同理

helper/ http.ts

typescript
export default (app: any, options?: any) => async (req: any, res: any, context: any) => {
  options = options || {}

  if (app instanceof Promise) {
    app = await app
  }

  const { method, url, query, payload, headers } = req

  app.inject({ method, url, query, payload, headers }, (err: any, injectRes: any) => {
    if (err) {
      console.error(err)
      res.setStatusCode(500)
      res.send('')
      return
    }
    res.setStatusCode(injectRes.statusCode)
    for (const key in injectRes.headers) {
      if (injectRes.headers.hasOwnProperty(key)) {
        const value = injectRes.headers[key]
        res.setHeader(key, value)
      }
    }
    const contentType = (injectRes.headers['content-type'] || injectRes.headers['Content-Type'] || '').split(';')[0]
    const isBase64Encoded = options.binaryMimeTypes?.indexOf(contentType) > -1
    const body = isBase64Encoded ? injectRes.rawPayload.toString('base64') : injectRes.payload
    res.send(body)
  })
}
1234567891011121314151617181920212223242526272829

app.ts

typescript
import path from 'path'
import { fastify, FastifyInstance } from 'fastify'
import httpProxy from './helper/http'
import root from './routes/root'

const init = async () => {
  const app: FastifyInstance = fastify({
    logger: !!process.env.NODE_ENV,
    connectionTimeout: 20000
  })
  try {
    await app.register(root, { prefix: '/' })
    return app
  } catch (err) {
    app.log.error(err)
    process.exit(1)
  }
}

development:
;(async () => {
  const app = await init()
  await app.listen(9000, '0.0.0.0', (err, address) => {
    if (err) {
      app.log.error(err)
    }
  })
})()

module.exports.handler = httpProxy(init());
123456789101112131415161718192021222324252627282930

CDN 配置

使用阿里云的 CDN 服务,在函数计算添加自定义域名时,启用 CDN 加速

fc-add-cdn-domain

在 CDN 控制台添加缓存配置

fc-add-cdn-rule

写作最后

由结果推过程来看,内容并不多,但是真正的经历却是一个不断尝试、发现问题和解决问题的过程

Rollup 值得每位前端开发人员学习和关注!!!

0

评论 (0)

取消