Cosnomi

医学生 / Web系や機械学習を勉強中。医療におけるIT活用に興味があります。

Twitter / GitHub / Keybase
TOP >

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

導入

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

Lambdaのレスポンス

冒頭で述べたとおり、Lambdaで非同期に応答を返すための方法は2つあります。

  1. lambdaHandler()関数でcallback引数を受け取り、それを呼び出すことでレスポンスを伝える
  2. async/awaitを使う(=Promiseを返してresolveされた時点でレスポンスを送信する)

前者の方法は、

export async function lambdaHandler(event: APIGatewayEvent, _context: Context) {
    // ...
    // return ...
}

後者は、

export function lambdaHandler(event: APIGatewayEvent,
    _context: Context, callback: Callback) {
    // ...
    // callback(..., ...)
}

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

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

ライブラリによってはasync/awaitに対応しておらず、第2引数にcallbackを取るような関数があります。もちろん、この場合でも自分でPromisereturnして、ライブラリに渡すcallback関数の中でresolve()を呼べばlambdaHandlerをasyncにできますが、自分でresolveを作るくらいなら、素直に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) => {
    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 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が空になるのをを待機する

記事一覧へ
コメントフォームは設置していませんので、ご意見・ご感想などはTwitter(@cosnomi)などへお願いします。