🚄

Serverless Webpackの個別ビルドの時間を爆速にする

2021/11/18に公開

概要

serverless-webpackのプラグインを利用して開発をしています。
特定の関数でのみ利用しているnode_modulesが重く、その影響で、すべての関数でlambdaのコールドスタートに数秒かかっていました。
serverless-webpackはデフォルトでは、全体で一つのzipファイルを作成します。そのzipファイルをserverless frameworkがlambdaにデプロイしています。
そのため、そのlambdaが利用するかどうかにかかわらず、全体で利用するすべてのソースをパッケージします。

serverless frameworkにはこの問題を解消するオプションがあります。individuallyのオプションを設定するとfunctionごとに別々のpackageを作成します。

https://www.serverless.com/framework/docs/providers/aws/guide/packaging#packaging-functions-separately

その場合、1つずつ作成するので、例えば50個関数があった場合は50回webpackを実行することになります。
並列の実行数は設定できるのですが、実行時間がかかってしまいデプロイに時間がかかるようになります。
依存関係にあるnode_modulesは大体一緒なため、同じファイルを作成する手順を省略できないか検討してみました。

結論

  • webpackの処理を無理やり省略することで、不要なビルドを実行しないようにできた。

  • 変更点は→のリンクにまとめています。 https://github.com/merutin/serverless-webpack/pull/1/files

  • lambdaの件数にもよるが、dployまでの時間が1/10程度になった。

  • esbuildに乗り換えるか、重い処理なくしたい。

serverless frameworkの構造について確認

webpackの処理はserverless-webpackというserverless frameworkのプラグインで行っています。
そのため、処理はserverless frameworkのプラグインの仕組みをちゃんと理解する必要があります。
まずは、プラグインについてみていきます。
serverless frameworkではコマンドを実行すると、まずはプラグインが読み込みされます。
そのあとにコマンドに従った処理を実行します。awsの場合は「aws:common:」で始まる処理がいくつかあるようです。
それぞれの処理の前後にpluginが処理を挟めるようになっています。

webpackのpluginはaws:common:cleanupTempDirが呼ばれたときにvalidateとcompileを実行するようになっていました。
このへんの処理はserverless frameworkのデバッグをONにしてコマンドを実行するとわかりやすいかもです

ログの一例
$env:SLS_DEBUG="*"
npx serverless package --stage local

Serverless: Load command interactiveCli
Serverless: Load command config
Serverless: Load command config:credentials
Serverless: Load command config:tabcompletion
Serverless: Load command config:tabcompletion:install
Serverless: Load command config:tabcompletion:uninstall
Serverless: Load command create
Serverless: Load command install
Serverless: Load command package
Serverless: Load command deploy
Serverless: Load command deploy:function
Serverless: Load command deploy:list
Serverless: Load command deploy:list:functions
Serverless: Load command invoke
Serverless: Load command invoke:local
Serverless: Load command info
Serverless: Load command logs
Serverless: Load command metrics
Serverless: Load command print
Serverless: Load command remove
Serverless: Load command rollback
Serverless: Load command rollback:function
Serverless: Load command slstats
Serverless: Load command plugin
Serverless: Load command plugin
Serverless: Load command plugin:install
Serverless: Load command plugin
Serverless: Load command plugin:uninstall
Serverless: Load command plugin
Serverless: Load command plugin:list
Serverless: Load command plugin
Serverless: Load command plugin:search
Serverless: Load command config
Serverless: Load command config:credentials
Serverless: Load command upgrade
Serverless: Load command uninstall
Serverless: Load command webpack
Serverless: Load command delete-appsync
Serverless: Load command validate-schema
Serverless: Load command graphql-playground
Serverless: Load command deploy-appsync
Serverless: Load command update-appsync
Serverless: Load command dynamodb
Serverless: Load command dynamodb:migrate
Serverless: Load command dynamodb:seed
Serverless: Load command dynamodb:start
Serverless: Load command dynamodb:noStart
Serverless: Load command dynamodb:remove
Serverless: Load command dynamodb:install
Serverless: Load command offline
Serverless: Load command offline:start
Serverless: Load command login
Serverless: Load command logout
Serverless: Load command generate-event
Serverless: Load command test
Serverless: Load command dashboard
Serverless: Load command output
Serverless: Load command output:get
Serverless: Load command output:list
Serverless: Load command param
Serverless: Load command param:get
Serverless: Load command param:list
Serverless: Load command studio
Serverless: Invoke package
Serverless: Invoke aws:common:validate
Serverless: Invoke aws:common:cleanupTempDir
Serverless: Invoke webpack:validate
Serverless: Invoke webpack:compile
Serverless: Bundling with Webpack...

## 中略

Serverless: Invoke webpack:package
Serverless: No external modules needed
Serverless: Copying existing artifacts...
Serverless: Packaging service...
Serverless: Invoke aws:package:finalize
Serverless: Invoke aws:common:moveArtifactsToPackage

serverless frameworkを実行すると、どのような処理が実行されるかは以下のリンクを参照してみてください。
https://gist.github.com/HyperBrain/50d38027a8f57778d5b0f135d80ea406

他のオプションがないのか探す

individuallyのオプションはserverless.tsのpackageの下に記述します。

const serverlessConfiguration: Serverless = {
  service: 'test',
  frameworkVersion: '2',
  package: {
    individually: true,
  },
 ...
 }

serverlessの設定を確認すると、functionごとにもindividuallyの設定ができるように記載があります。
https://www.serverless.com/framework/docs/providers/aws/guide/packaging#packaging-functions-separately

service: my-service
functions:
  hello:
    handler: handler.hello
  world:
    handler: handler.hello
    package:
      individually: true
  • functionのindividuallyの設定を見て一部のfunctionだけ個別にwebpackが実行されなか試してみました。が、webpackのpluginがfunction単位でのindividuallyのオプションに対応していないようで、functionのindividuallyはtrueにしても特に動作が変わることはありませんでした。

修正してみる

webpackからesbuildに変更する

今までの前提を覆すようですが、lambdaの数が数十くらいであれば、これが一番手っ取り早いです。webpackだと1分くらいかかっていたビルドが1秒程度で終わりました。
serverless frameworkのプラグインが用意されているので、serverless-webpackの替わりにseverless-esbuildを利用すればOKです。
https://www.npmjs.com/package/serverless-esbuild

ただ、私の試している環境では、esbuildだとうまく起動しなかったので、今回は採用しませんでした。動かなかった話はまた別途記事にする予定です。

webpackのビルドを最小限にする

webpackのビルド結果はほとんど一緒になるようにしているので、同じビルドのものは省略できないか検討してみました。

webpack pluginの処理と対応策

まずはwebpackのpluginが何をしているのか調べていきます。
中身を見ていくと、compileが命名的にwebpackの処理を行っていそうなので、そこに絞って確認をすることにしました(実際にwebpackの処理もここで行っていました)。
printデバックをしまくった結果、individuallyのオプションを設定するとwebpackにわたってくる引数が複数(functionsの数)になることがわかりました。

https://github.com/serverless-heaven/serverless-webpack/blob/master/lib/compile.js#L93

なお、deploy等のコマンドでは、serverless-webpackは.webpackのフォルダを作成した後に.serverlessにzipファイルのみをコピーして.webpackのフォルダを削除しています。そのため、webpackの結果も知りたい場合はnpx serverless webpackを実行してください。

ソースを見てみた結果、次のようなことがわかりました。

  • compileの処理自体はconfigsの分だけwebpackを実行するのみ
  • configsはindividuallyによって配列かどうか変わってくるが、compile以前の処理で処理しているっぽい
  • webpackは並列の実行数を設定できる
    • 値をある程度大きくすると並列実行の数は増えるが、CPUの負荷が大きい
  • compileの結果渡すのはoutputPathとexternalModulesのObjectのみ

ソースの一部抜粋です。

compile.js
function webpackCompile(config, logStats) {
  // webpackを呼び出ししている部分
  // BpPromiseは単純にPromiseだと考えてOK
  return BbPromise.fromCallback(cb => webpack(config).run(cb)).then(stats => {
    // ensure stats in any array in the case of concurrent build.
    stats = stats.stats ? stats.stats : [stats];

    _.forEach(stats, compileStats => {
      logStats(compileStats);
      if (compileStats.hasErrors()) {
        throw new Error('Webpack compilation error, see stats above');
      }
    });

    // webpackの結果、返すのはoutputPathとexternalModulesのみ
    return _.map(stats, compileStats => ({
      outputPath: compileStats.compilation.compiler.outputPath,
      externalModules: getExternalModules(compileStats)
    }));
  });
}

// 引数の配列ごとにwebpackCompileをして、flatにする
function webpackConcurrentCompile(configs, logStats, concurrency) {
  return BbPromise.map(configs, config => webpackCompile(config, logStats), { concurrency }).then(stats =>
    _.flatten(stats)
  );
}

module.exports = {
  compile() {
    this.serverless.cli.log('Bundling with Webpack...');

    // ここのconfigsがfunctionの数の配列になる
    const configs = ensureArray(this.webpackConfig);
    const logStats = getStatsLogger(configs[0].stats, this.serverless.cli.consoleLog);

    if (!this.configuration) {
      return BbPromise.reject('Missing plugin configuration');
    }
    // custom.webpack.concurrencyで設定できる
    const concurrency = this.configuration.concurrency;

    // webpackの処理。concurrencyの数だけ並列で実行している
    return webpackConcurrentCompile(configs, logStats, concurrency).then(stats => {
      this.compileStats = { stats };
      return BbPromise.resolve();
    });
  }
};

また、configsは以下のようになっていました。抜き出しているのは配列の1要素で、これがfunctionの分だけ存在します。

  {
    mode: 'production',
    entry: { 'src/handler': './src/handler.ts' },
    devtool: false,
    resolve: { extensions: [Array] },
    output: {
      libraryTarget: 'commonjs',
      path: 'C:\\workspace\\serverless-webpack-test\\.webpack\\test',
      filename: '[name].js'
    },
    target: 'node',
    module: { rules: [Array] },
    plugins: [],
    context: 'C:\\workspace\\serverless-webpack-test',
    node: false
  },

entryがserverless frameworkで指定しているhandlerのパスです。ここが一緒であればserverless-webpackのプラグインでは同じビルドの結果になります。
outputのpathが出力先になります。serverless frameworkのfunction名が末端のフォルダ名になります。上記の場合はtestです。

ということは、webpackCompileの返り値をwebpackの処理をせずに、configsから作成できればよさそうです。outputは上記の例の場合以下のようになっていました。

  [
    {
      outputPath: 'C:\\workspace\\serverless-webpack-test\\.webpack\\test',
      externalModules: []
    }
  ],

symlinkを作成する

webpackCompileの返り値のoutputPathはfunctionの名前がついているフォルダになっていました。
そのため、最初にビルドしたものに対してシンボリックリンクを作成することで、作成したように見せかけられないか試してみました。
webpackの処理と並列でシンボリックリンクの作成処理が実行されてしまい、リンクのターゲットとなるフォルダの作成前にリンクを作成しようとしてエラーになってしまいました。存在確認しなくていいのに。。

webpackCompileのoutputに値を入れる

webpackCompileの結果を最初にビルドしたものと同じパスを利用することにしてみました。

コードとしてはこんな感じです。externalModulesはbuildの結果から作成していて、この関数にある情報では作成できなかったので、いったん空の配列にしています。

// 引数の配列ごとにwebpackCompileをして、flatにする
function webpackConcurrentCompile(configs, logStats, concurrency) {
+  const getConfigValues = (config) => Object.values(config.entry);
-  return BbPromise.map(configs, config => webpackCompile(config, logStats), { concurrency }).then(stats =>
+  return BbPromise.map(configs, (config, index) => {
+    const entryIndex = configs.findIndex(conf => getConfigValues(config).every(v => getConfigValues(conf).find(v2 => v === v2)))
+    if (index !== entryIndex) {
+       fs.symlinkSync()
+      return {
+        outputPath: configs[entryIndex].output.path,
+        externalModules: [] // FIXME
+      }
+    } else {
+      return webpackCompile(config, logStats);
+    }
+  }, { concurrency }).then(stats =>
-    _.flatten(stats)
-  );
}

このコードでnpx serverless webpackを実行したところ、意図した通りに複数のビルドが実行されることなく必要なもののみをビルドできました。
自分が利用している環境では2つのwebpackの処理が動いていて、それぞれ28秒、76秒かかっています。functionは現在15個あるので、上記の修正を入れないで動かした場合、単純に7倍の時間、10分以上かかる計算になります。元々でもそこそこ遅いのに恐ろしい。。

終わりに

今回はwebpackの処理を回避して、出力の結果を無理やり合わせる対応を行いました。
serverless-webpackの一部の構成しか理解していないので、今の実装方法が正しいアプローチなのかわかりません。また、node_externalの処理が入っていないので、buildの対象外とするファイルがあった場合にうまく動作できません。
この辺の障害をクリアできれば、PR送れるのだが、、先は長いです。

そもそも、自分のローカル環境でしか動作しないのは完全によくないので、esbuildを動作するようにするなど別のアプローチを考えてみようと思います。

Discussion