Cosnomi

プログラミングをする医学生。物を作ること、自動化すること、今まで知らなかったことを知ることが好き。TypeScript書いたり、Pythonで機械学習したりなど。

Twitter / GitHub / GPG key / Fediverse / My Page
TOP >

AWS Lambdaにおけるnode.jsのasyncとcallbackの競合について

AWS Lambdaは関数が非同期に応答を返すための方法を2つ用意しています。1つは引数で渡される callback() 関数を呼び出す方法で、もう1つはasync/awaitを用いる方法です。 どちらも難しくはないのですが、異なる方法をコード内に共存させると、一見不可解な Malformed Lambda proxy response が発生してしまいました。これに関連して、今回は複数の非同期応答の優先順位について調べたので書き残しておきたいと思います。

Lambdaのレスポンス

Lambdaで非同期に応答を返すための方法は2つあります。

  1. async/awaitを使う(=Promiseを返してresolveされた時点でレスポンスを送信する)
export async function lambdaHandler(event: APIGatewayEvent, _context: Context) {
    // ...
    // return ...
}
  1. lambdaHandler()関数でcallback引数を受け取り、それを呼び出すことでレスポンスを伝える
export function lambdaHandler(event: APIGatewayEvent,
    _context: Context, callback: Callback) {
    // ...
    // callback(..., ...)
}

どちらも公式ドキュメントで紹介されています。個人的には後者がスマートに見えるので好きですが、callbackを用いたほうが良い場合もあります。

callbackを引数に取るライブラリではcallbackが便利

ライブラリによってはasync/awaitに対応しておらず、第2引数にcallbackを取るような関数があります。この場合でも、 Promise をreturnして、ライブラリに渡すcallback関数の中で resolve() を呼べばlambdaHandlerをasyncにできます。しかし、素直にhandlerで callback を受け取ってそれを呼んだほうが自然だと思います。

return new Promise((resolve, _reject) => {
   oldLibraryFunction(
      param,
      (_err, _data) => {
        resolve(null, {
            statusCode: 500,
            body: JSON.stringify({
            message: "success",
            item_id: 15
            })
        });
      }
    );
});

両方同時使ったときはどうなるか

遅延のあるcallbackとreturn (async関数内)

上の2つの方法、asyncとcallbackを混在させるとどうなるでしょうか。例として、いくつかの簡単なコードを見てみましょう。 まずは、async関数内において、setTimeout()の待機後に callback() を呼ぶようにセットし、セット直後に return をした場合です。

exports.handler = async (event, context, callback) => {
    setTimeout(() => {
        callback(null, {
        statusCode: 200,
        body: JSON.stringify('Hello via callback!'),
    })
    }, 500);
    return {
        statusCode: 200,
        body: JSON.stringify('Hello via return!'),
    };;
};

レスポンスは次のとおりでした。

{
  "statusCode": 200,
  "body": "\"Hello via return!\""
}

(応答時間: 118.36 ms)

callbackが呼ばれるまでには500 msの待機が生じるのに対し、returnに達するまでには待機が生じないので、returnのほうが先に到達し、その時点で実行は中断され、応答が返されます。もし関数がnon-asyncであった場合はどうでしょうか?

遅延のあるcallbackとreturn (not-async関数内)

先程のコードとの違いは、handler関数がasyncでないことだけです。

exports.handler = (event, context, callback) => {
    // 以下省略
{
  "statusCode": 200,
  "body": "\"Hello via callback!\""
}

(応答時間: 579.90 ms)

今度はcallbackの勝ちです。応答時間の延長からも、callback()が呼ばれるまで待ってくれていることが分かります。また、実際のレスポンスは先に到達する return によるものではなく callback() によるものだと分かりました。先の例では callback() まで到達しなかったため、returnによる応答が優先されましたが、returncallback() の両方に到達した場合、常に callback() による応答が優先されるのでしょうか? 別のケースを見てみましょう。

遅延のないcallbackとreturn (async)

今度は、setTimeout()を設定しない場合を見ていきます。まずはasync関数の場合です。

exports.handler = async (event, context, callback) => {
    callback(null, {
        statusCode: 200,
        body: JSON.stringify('Hello via callback!'),
    });
    return {
        statusCode: 200,
        body: JSON.stringify('Hello via return!'),
    };
};
{
  "statusCode": 200,
  "body": "\"Hello via callback!\""
}

(応答時間: 15.93 ms) callback()が先に呼ばれ、returnのほうが後に到達するはずですが、応答の中身は callback() によるものであることが分かります。async関数でもreturn前(=Promiseのresolve前)に callback() が呼ばれた場合は、応答の中身はcallbackが優先されるのですね。

遅延のないcallbackとreturn (non-async)

上を踏まえれば自然なことですが、non-async関数でもcallbackが優先されます。

exports.handler = (event, context, callback) => {
    callback(null, {
        statusCode: 200,
        body: JSON.stringify('Hello via callback!'),
    });
    return {
        statusCode: 200,
        body: JSON.stringify('Hello via return!'),
    };
};
{
  "statusCode": 200,
  "body": "\"Hello via callback!\""
}

(応答時間: 93.94 ms)

callback同士の競争

callback()とreturnの両者が呼ばれた場合、handlerがasync関数であるかに関わらず、callbackが優先されます。では、callback()が2回以上呼ばれる場合はどうでしょうか。

exports.handler = (event, context, callback) => {
    setTimeout(() => {
        callback(null, {
        statusCode: 200,
        body: JSON.stringify('Hello via callback (500 ms)!'),
    })
    }, 500);
    setTimeout(() => {
        callback(null, {
        statusCode: 200,
        body: JSON.stringify('Hello via callback (1000 ms)!'),
    })
    }, 1000);
    return {
        statusCode: 200,
        body: JSON.stringify('Hello via return!'),
    };
};
{
  "statusCode": 200,
  "body": "\"Hello via callback (500 ms)!\""
}

(応答時間: 1042.68 ms)

応答時間が1000msを超えて延長していることから、どうやら1000 msのsetTimeoutを待機してくれていることは分かります。しかし、レスポンスの中身は、最初に呼ばれたcallback (500 ms待機した後で返されたもの) です。これに関しては、公式のドキュメントを読むことで理解できます。

Node.js での AWS Lambda Context オブジェクトcallbackWaitsForEmptyEventLoopが重要です。これはデフォルトで true なので、応答はevent loopが空になるまで待機されるのです。しかし、その中身は最初に呼ばれた callback() によるものとなります。

優先順位のまとめ

handlerに callback()return が共に存在するとき、一番最初に呼ばれた callback() が応答の中身となる。ただし、async関数の場合はPromiseがresolveされた時点(=returnに達した時点)で実行は終了し応答する。non-async関数の場合、イベントループが空になるまで待つので、returnに達した後でも終了は待機され、callback()が呼ばれればそちらが優先される。

Malformed Lambda proxy responseの原因

このような仕様は具体的にはどのような場面で問題を生じるでしょうか? 例えば次のようなLambda関数をAPI Gatewayのあるエンドポイントと紐付けたとしましょう。

exports.handler = async (event, context, callback) => {
    if (event.reqtype==='callback') {
        // こちらはcallbackにより応答する
        setTimeout(() => {
           callback(null, {
           statusCode: 200,
           body: JSON.stringify('Hello via callback (1000 ms)!'),
           })
        }, 200)
    }
    else {
        // こちらはasync/awaitを用いて応答する
        // await .... (詳細は省略)
        // return ...
    }
    // ここでreturn(=Promiseがresolve)される
};

このコードでは event.reqtype==='callback' のときに Malformed Lambda proxy response が発生します。このエラーはLambda関数からの応答がmalformed、つまり、不正なフォーマットであったことを意味します。その多くは、bodyJSON.stringify し忘れていることにより生じますが、今回の場合は違います。実際にLambda関数を実行してみると、この関数は null を返却します。なぜでしょうか。

ここで重要なのはasync関数ではevent loopが空になるまで待たず、Promiseのresolveをもって実行を終了してしまうということです。つまり、setTimeout()の中の callback() に到達するのは200 ms後ですが、その前に関数の最終行に達してしまうため、Promiseがresolveされてしまいます。今回の例では明示的に値を返してませんから、すなわち return; と同じ扱いになります。したがって、nullを返すわけですね。

もし、async/awaitによる応答を行わない、すなわち関数がnon-asyncであったのならば、callbackWaitsForEmptyEventLoopが働き、setTimeout()内の callback() まで待機されます。普通はcallbackを用いるときはnon-async関数内でしょうから、このような問題に気づきにくいのでしょう。

これを避けるには、callback()で応答を返すのかasync/awaitで応答を返すのかを揃えた方が良いように思われます。私の場合は、結局古いライブラリ(callbackが必要な方)の関数をpromisifyしてasync/awaitによる方法で揃えました。

まとめ

  • callback()とreturnではcallback()が優先される
  • callback()が複数あるときは一番最初のcallbackが優先される
  • ただし、async関数ではevent loopが空になるのを待機しない
  • non-async関数はevent loopが空になるのを待機する

Comments

記事一覧へ