AWS Lambda+Node.jsのコンテナ並列起動(同期・非同期)・コンテナ再利用の動作検証

2020-08-28
山下 徳光
#
AWS
#
Lambda
#

こんにちは、山下です。

随分前に検証したことがあるのですが、記憶を頼りに説明するのに自信がなくなってきたので、改めて検証してメモに残すことにしました。

検証コードは GitHub にUPしてあるので、再検証など自由にご利用ください!

追記: 2020.9.12
タイムアウト・メモリエラー時の動作を追加調査しました。
AWS Lambda+Node.jsのタイムアウト・メモリエラー時のコンテナ再利用の動作 | グランドリーム

概要

AWS LambdaでNode.jsを動作させた際の下記パターンの動作を検証します。

  • コンテナの並列起動(同期処理)
  • コンテナの並列起動(非同期処理)
  • コンテナの再利用

結論

長いので先に結論を書いておきます。

  • Lambdaは1実行で1プロセスを占有する。
  • 同期処理を実行しても他のLambda(プロセス)実行に影響しない。
  • 非同期処理を実行しても実行中のLambda(プロセス)で他のLambda実行が処理されることはない。
  • コンテナ同時実行数の上限に達すると実行中のコンテナが終了されるまで特定回数リトライされる。
  • コンテナ実行後一定時間内に同じ関数を再実行するとコンテナが再利用される。
  • Lambda実行毎にプロセスが完全に分離しているため、コンテナ再利用+グローバル変数を変化させるコードを書いても問題なし。

前提知識

コンテナの動作を検証する前に、少しだけNode.jsの同期処理・非同期処理の話をします。

同期処理

Node.jsはシングルスレッドなので、CPUに負荷がかかる同期処理を長時間実行すると、その処理を実行している間、他の処理ができなくなります。 例えば、Expressで下記のようなCPU負荷の高い処理がある場合、後続のリクエストは前段のリクエストが終了するまで待たされます。

app.use('/case1', (req, res) => {
  console.log("START")
  const maxCount = 3000000000
  for (let i = 0; i < maxCount; i++) {
  }
  console.log("END")

  res.send('{}')
});

試してみます。

まずは1回リクエストして処理時間を計測します。

$ time curl localhost:3000/case1
{}

curl localhost:3000/case1  0.00s user 0.01s system 0% cpu 1.542 total

約1.5秒かかりました。

次に、リクエストを二回並列に実行して処理時間を計測します。

$ time (curl localhost:3000/case1 &; curl localhost:3000/case1 &; wait)
{}{}

( curl localhost:3000/case1 & curl localhost:3000/case1 & wait; )  0.01s user 0.01s system 0% cpu 2.895 total

約3秒かかりました。

ログは下記です。

START
END
START
END

START/ENDが交互に表示されていますね。

つまり、1回目のリクエストが終了するまで待ってから2回目のリクエストを開始しています。

非同期処理

ただし、非同期処理(外部WebAPI実行やファイルIOなど)の場合は処理を実行している間に他の処理を開始できるという特性があります。

例えば、下記のような非同期処理があった場合、後続のリクエストは前段のリクエストが終了するのを待たずに開始されます。

app.use('/case2', async (req, res) => {
  console.log("START")
  await new Promise((resolve) => setTimeout(resolve, 3000))
  console.log("END")

  res.send('{}')
});

試してみます。

まずは1回リクエストして処理時間を計測します。

$ time curl localhost:3000/case2
{}

curl localhost:3000/case2  0.00s user 0.01s system 0% cpu 3.025 total

約3秒かかりました。

次に、リクエストを二回並列に実行して処理時間を計測します。

$ time (curl localhost:3000/case2 &; curl localhost:3000/case2 &; wait)
{}{}

( curl localhost:3000/case2 & curl localhost:3000/case2 & wait; )  0.01s user 0.02s system 0% cpu 3.019 total

同じく、約3秒かかりました。

ログは下記です。

START
START
END
END

START直後にまたSTARTが表示され、その後にENDが二つ表示されていますね。

つまり、1回目のリクエストが終了するのを待たずして、2回目のリクエストを開始しています。

検証

ここからが検証した内容です。

コンテナの並列起動(同期処理)

Lambdaを並列実行して、内部で後続の処理をブロックする可能性がある同期処理が実行されている場合、コンテナの動作はどうなるでしょうか。

検証します。 以下のように、CPUに負荷がかかる同期処理を実行するLambda関数を作成します。

"use strict";

module.exports.handle = async (event) => {
  console.log("START");
  const maxCount = 100000000;
  for (let i = 0; i < maxCount; i++) {}
  console.log("END");

  return { pid: process.pid };
};

まずは1回リクエストして処理時間を計測します。

$ time sls invoke -f case1
{
    "pid": 8
}

sls invoke -f case1  1.40s user 0.41s system 42% cpu 4.271 total

約4秒かかりました。

次に、リクエストを二回並列に実行して処理時間を計測します。

$ time (sls invoke -f case1 &; sls invoke -f case1 &; wait;)
{
    "pid": 8
}
{
    "pid": 7
}

( sls invoke -f case1 & sls invoke -f case1 & wait; )  2.95s user 1.10s system 86% cpu 4.687 total

同じく、約4秒かかり 異なるプロセスID になっています。

つまり、1回目のリクエストが終了するのを待たずして、2回目のリクエストを開始しています。

このことから、Lambdaは1実行で1コンテナを占有するため、後続の処理はブロックされず、別のコンテナ・プロセスで動作することがわかりました。

では、同時実行数を1に設定した場合の動作はどうなるでしょうか? Lambdaの同時実行数のデフォルトは1,000に設定されているので、マネジメントコンソールから1に変更します。

では、再度試してみましょう。

まずは1回リクエストして処理時間を計測します。

$ time sls invoke -f case1
{
    "pid": 7
}

sls invoke -f case1  1.51s user 0.58s system 44% cpu 4.697 total

コード変更していないので当然ですが、約4秒かかりました。

次に、リクエストを二回並列に実行して処理時間を計測します。

$ time (sls invoke -f case1 &; sls invoke -f case1 &; wait;)
Serverless: Recoverable error occurred (Rate Exceeded.), sleeping for ~4 seconds. Try 1 of 4
{
    "pid": 7
}
{
    "pid": 7
}

( sls invoke -f case1 & sls invoke -f case1 & wait; )  3.03s user 1.07s system 46% cpu 8.726 total

約8秒かかり、同じプロセスIDになっています。

ログから、同時実行数を超えるLambda関数を実行した場合 Rate Exeeded エラーが表示され、前段のコンテナプロセスが解放されるまでリトライする ことがわかりました。

コンテナの並列起動(非同期処理)

では、非同期処理の場合はどうなるのでしょうか。

検証します。 以下のように、非同期処理を実行するLambda関数を作成します。

"use strict";

module.exports.handle = async (event) => {
  console.log("START");
  await new Promise((resolve) => setTimeout(resolve, 3000));
  console.log("END");

  return { pid: process.pid };
};

まずは1回リクエストして処理時間を計測します。

$ time sls invoke -f case2
{
    "pid": 7
}

sls invoke -f case2  1.43s user 0.58s system 29% cpu 6.766 total

約6秒かかりました。

次に、リクエストを二回並列に実行して処理時間を計測します。

$ time (sls invoke -f case2 &; sls invoke -f case2 &; wait;)
{
    "pid": 7
}
{
    "pid": 8
}

( sls invoke -f case2 & sls invoke -f case2 & wait; )  2.97s user 1.04s system 63% cpu 6.320 total

同じく、約6秒かかり、異なるプロセスIDになっています。

このことから、同期処理の場合と同じように、Lambdaを並列で実行した場合はコンテナも並列で起動され、それぞれ別のプロセスで動作していることがわかりました。

では同時実行数を1に変更して再度二回並列実行を試してみます。

$ time (sls invoke -f case2 &; sls invoke -f case2 &; wait;)
Serverless: Recoverable error occurred (Rate Exceeded.), sleeping for ~5 seconds. Try 1 of 4
{
    "pid": 7
}
{
    "pid": 7
}

( sls invoke -f case2 & sls invoke -f case2 & wait; )  3.22s user 1.13s system 37% cpu 11.505 total

約11秒かかり、同じプロセスIDになり、リトライメッセージが表示されました。

つまり、非同期処理を実行していたとしても、Lambdaは1実行で1コンテナを占有するため、1コンテナを再利用せずに別のコンテナ・プロセスで動作することがわかりました。

コンテナの再利用

では、どのような時にコンテナは再利用されるのでしょうか?

検証します。 以下のように、実行時にグローバル変数の値を操作するLambda関数を作成します。

"use strict";

let count = 0;
module.exports.handle = async (event) => {
  console.log("START");
  count++;
  console.log("END");

  return { pid: process.pid, count };
};

1回目実行します。

$ sls invoke -f case3
{
    "pid": 7,
    "count": 1
}

2回目実行します。

$ sls invoke -f case3
{
    "pid": 7,
    "count": 2
}

1回目で変更した値がインクリメントされ2が返却されました。コンテナが再利用されていますね。

では、次は並列実行してみます。

$ sls invoke -f case3 &; sls invoke -f case3 &; sls invoke -f case3 &; sls invoke -f case3 &; sls invoke -f case3 &; wait;
{
    "pid": 7,
    "count": 3
}
{
    "pid": 7,
    "count": 4
}
{
    "pid": 7,
    "count": 5
}
{
    "pid": 7,
    "count": 6
}
{
    "pid": 7,
    "count": 7
}

2回目で実行した値がインクリメントされて返却されました。 コンテナが再利用されていますね。

これはちょっと予想外です。これまでの検証結果から、並列実行では別コンテナが起動してコンテナは再利用されないと予想していました。 実行時間が短すぎて直列実行されたのかもしれません。コードに1000msスリープを入れてみます。

"use strict";

let count = 0;
module.exports.handle = async (event) => {
  console.log("START");
  await new Promise((resolve) => setTimeout(resolve, 1000));
  count++;
  console.log("END");

  return { pid: process.pid, count };
};

再度並列実行します。

$ sls invoke -f case4 &; sls invoke -f case4 &; sls invoke -f case4 &; sls invoke -f case4 &; sls invoke -f case4 &; wait;
[1] 46006
[2] 46008
[3] 46009
[4] 46010
[5] 46012
{
    "pid": 6,
    "count": 1
}
{
    "pid": 7,
    "count": 1
}
{
    "pid": 8,
    "count": 1
}
{
    "pid": 8,
    "count": 1
}
{
    "pid": 6,
    "count": 2
}
[1]    done       sls invoke -f case4
[3]    done       sls invoke -f case4
[2]    done       sls invoke -f case4
[4]  - done       sls invoke -f case4
[5]  + done       sls invoke -f case4

おや、今度は並列実行されていますが、同じPIDで同じcountが返却されていて、不正な変数の状態になっているように見えますね。

Node.jsはシングルスレッドなので変数の排他制御は不要なはずなので、もしかすると、1実行1コンテナではなく、コンテナ内に複数のプロセスが立ち上がる構造なのかもしれません。

検証します。 以下のように、コンテナ初期化時にUUIDでコンテナIDを作成して、コンテナID・プロセスID・カウントを戻り値とするLambda関数を作成します。

"use strict";

const uuid = require("uuid");
const cid = uuid.v4();

let count = 0;
module.exports.handle = async (event) => {
  console.log("START");
  count++;
  console.log("END");

  return { cid, pid: process.pid, count };
};

再度並列実行します。

$ sls invoke -f case5 &; sls invoke -f case5 &; sls invoke -f case5 &; sls invoke -f case5 &; sls invoke -f case5 &; wait;
[1] 95990
[2] 95991
[3] 95993
[4] 95994
[5] 95995
{
    "cid": "2efcc99b-d949-4863-a0c8-f741c3925393",
    "pid": 7,
    "count": 1
}
{
    "cid": "745f2568-13ec-4df5-90e9-20b349add12a",
    "pid": 8,
    "count": 1
}
{
    "cid": "51adde7b-f149-4bdf-bf05-2ba1836a82c4",
    "pid": 8,
    "count": 1
}
{
    "cid": "bd83298d-2aee-492d-ac8b-c39980ab4e04",
    "pid": 8,
    "count": 1
}
{
    "cid": "282f0531-af3d-4219-9632-48b1b68527fc",
    "pid": 8,
    "count": 1
}
[4]  - done       sls invoke -f case5
[3]  - done       sls invoke -f case5
[5]  + done       sls invoke -f case5
[1]  - done       sls invoke -f case5
[2]  + done       sls invoke -f case5

$ sls invoke -f case5 &; sls invoke -f case5 &; sls invoke -f case5 &; sls invoke -f case5 &; sls invoke -f case5 &; wait;
[1] 96174
[2] 96175
[3] 96177
[4] 96178
[5] 96179
{
    "cid": "282f0531-af3d-4219-9632-48b1b68527fc",
    "pid": 8,
    "count": 2
}
{
    "cid": "282f0531-af3d-4219-9632-48b1b68527fc",
    "pid": 8,
    "count": 3
}
{
    "cid": "282f0531-af3d-4219-9632-48b1b68527fc",
    "pid": 8,
    "count": 4
}
{
    "cid": "bd83298d-2aee-492d-ac8b-c39980ab4e04",
    "pid": 8,
    "count": 2
}
{
    "cid": "bd83298d-2aee-492d-ac8b-c39980ab4e04",
    "pid": 8,
    "count": 3
}
[1]    done       sls invoke -f case5
[5]  + done       sls invoke -f case5
[3]  - done       sls invoke -f case5
[2]  - done       sls invoke -f case5
[4]  + done       sls invoke -f case5

コンテナID+プロセスID単位でカウントされていることがわかりました。 推測ですが、Lambdaコンテナのアーキテクチャは下記のようになっているみたいですね。

この検証を通して、厳密には1実行で1コンテナを占有するのではなく、1実行で1プロセスを占有するということがわかりました。 他の処理が割り込むことがないので、1実行ごとにグローバル変数に値を格納して全関数から参照することも問題なさそうですね。具体的には、リクエスト単位でUUIDを設定するなどに利用できそうです。

また、Lambdaのパフォーマンスチューニングで紹介されるような重い初期化処理をグローバル変数に入れるもアーキテクチャ上問題ないことがわかりました。

おわりに

これらの特性を理解した上で設計すると、より効果的な実装が出来そうですね。

株式会社グランドリームでは、AWSを駆使した開発からUI/UXデザインまで、Webアプリケーションに関するすべての要望に応えます。
まずは一度お気軽にご相談ください。

お問い合わせはこちら