ALBからLambda関数を呼び出して静的ページを表示する【サンプルあり】

先日のre:Invent 2018で発表された新機能「ALB Support for Lambda」を弊社サービスのGamerchで使用した事例を紹介します。メンテナンスを実施した際に表示する静的ページをALBからLambda関数を呼び出してNode.jsで表示してみました。

選定

要件

サービス断を伴うメンテナンスを行う必要が発生し、下記のような要件を満たす環境が必要になりました。

  • ALBに来たすべてのリクエストでに対して、静的なSorryページを返す
    • 長時間のロードや真っ白なページを避けたい
  • ステータスコードやレスポンスヘッダーの内容を操作したい
    • ステータスコードを「503」、「retry-after」のレスポンスヘッダーを付与する
  • メンテ開始・終了の切り替えを簡単にしたい

選択肢

  1. アプリケーション側で切り替える
    • アプリケーションのコード変更や環境変数によって、メンテナンス状態のON/OFFを切り替える
  2. DNSを切り替える
    • 一時的なSorryサーバを立てて、メンテナンス中はDNSをSorryサーバに向ける
  3. ALBから固定レスポンスを返答する
    • ほぼ要件は満たせるが、レスポンスヘッダーを変更することができない
  4. ALB Support for Lambdaを使用する
    • Lambda関数のターゲットグループを作成して、ALBからLambda関数を呼び出す

ALB Support for Lambdaの採用理由

  • 新しい技術を使ってみたい
    • ちょっと前に発表された機能をいち早く使うチャンス!
  • メンテ開始・終了の切り替えが簡単に素早く行える
    • 事前調査でALBリスナーのルールを1つ追加/削除するだけでメンテナンスの切り替えが可能と分かった
    • アプリケーションサーバやDNSの変更が不要 (メンテ明けの不具合のリスクが減る)

検証

アプリケーションロードバランサー(ALB)のターゲットにAWS Lambdaが選択可能になりましたを参考に、まずは静的ページを表示するところまで実装してみます。

ファイル構成

DBなど外部に依存するものがないのでシンプルな構成になりました。
index.jsにはlambdaのハンドラindex.handlerの関数があり、maintenance.htmlはレスポンスとして返すHTMLが記載されています。

┬ index.js
└ maintenance.html

サンプルコード

const fs = require("fs");

// 終了予定時刻
const endDate = new Date("2018-12-18T10:00:00");

exports.handler = async () => {
    const response = {
        statusCode: 200,
        statusDescription: "200 OK",
        isBase64Encoded: false,
        headers: {
            "Content-Type": "text/html; charset=utf-8",
        }
    };
    
    const dateStr = (endDate.getMonth() + 1) + "/" + endDate.getDate() + " " + endDate.getHours() + ":" + (("0".repeat(2)) + endDate.getMinutes()).slice(-2);
    const html =  fs.readFileSync("./maintenance.html", "utf-8", (err, data) => {
        if (err) {
            return "只今、メンテナンス中です";
        }

        return data;
    });
    response.body = html.replace("{endDate}", dateStr);

    return response;
};

ALB・ターゲットグループ設定

まず、Lambda関数を呼び出すターゲットグループを作成します。
ターゲットの種類に「Lambda関数」が追加されていました。
ターゲットグループの新規作成

ターゲットグループを作成すると、自動的にLambda関数へのアクセス権限を設定してくれました。
ターゲットグループの新規作成]

作成したターゲットグループのヘルスチェックを有効化します。
ターゲットグループのヘルスチェックを有効化

ターゲットグループをALBのリスナーに紐付けます。
検証のため、/maintenance/配下の場合にメンテページを表示する設定にしました。
※検証には開発環境を使用しています
ターゲットグループをALBのリスナーに紐付け

URLにアクセスして動作確認を行ったところ、
期待通りmaintenance.htmlの内容を表示して、静的ページを表示することに成功しました。
メンテナンスページ

メンテナンスページ実装

静的ページを返答する検証が終わったところで、いよいよ要件を満たすメンテナンスページの実装を行います。ファイル構成に関しては検証時と同一なので、割愛します。

ステータスコードとレスポンスヘッダーの設定

要件を満たすため、ステータスコードとレスポンスヘッダーの設定を入れました。

const response = {
    statusCode: 503,
    statusDescription: "503 Service Temporarily Unavailable",
    isBase64Encoded: false,
    headers: {
        "Content-Type": "text/html; charset=utf-8",
        "Retry-After": endDate.toGMTString(),
    }
};
メンテナンス中は「503」のステータスコードを用いると、Googleなどのクローラにメンテナンスを知らせることができます(メンテナンス中に「200」を返すとメンテナンスページがインデックスされることがあります)。
また、「503」のステータスコードと合わせてメンテナンス終了予定時間を「Retry-After」のレスポンスヘッダーとして付与すると、メンテナンス終了後に再度クローラが訪れやすくなると言われています。

ヘルスチェック失敗

上記の修正を行った後、ページを表示しようとすると……
レスポンスコードが変わったためヘルスチェックに失敗していました。
ヘルスチェック失敗

ヘルスチェックの成功コードを503に変更しようとしたところ、「200~499」で入力する必要があるとエラーになってしまいました。正常時が500番台なんてさすがに認められないようです。
ヘルスチェック失敗

考えた結果、特定のURLの場合だけステータスコードを200で返すように修正し、チェックするパスを変更することで、ヘルスチェックを通過するようにしました。

exports.handler = async ({path}) => {
    const response = {
        statusCode: 503,
        statusDescription: "503 Service Temporarily Unavailable",
        isBase64Encoded: false,
        headers: {
            "Content-Type": "text/html; charset=utf-8",
            "Retry-After": endDate.toGMTString(),
        }
    };

    if (path === "/maintenance/health-check") {
        response.statusCode = 200;
        response.statusDescription = "200 OK";
    }
    
    /* 中略 */
    
    return response;
}
のちに気づいたのですが、ヘルスチェックに失敗していてもALBリスナーのルールに一致したリクエストがあった際はLambda関数が呼ばれていて、ちゃんと動作していました。
また、ヘルスチェックを無効化するとステータスが「unavailable」になりますが、同様にリクエストがあればLambda関数が動作するようです。

【要注意】Lambda関数のタイムゾーン設定

ステータスコードとレスポンスヘッダーも設定できて準備完了!と思いきや、ふとレスポンスヘッダーを見てみると……
retry-afterの時間が「Tue Dec 18 2018 10:00:00 GMT」(日本時間で2018/12/18 19:00:00)になってるではないですか。
Retry-After

Lambda関数は東京リージョンでもデフォルトのタイムゾーンがUTCに設定されているようです。このままだとメンテが終わるのは10時なのに19時までクローラが来ない可能性があります(クローラが来ないのはメディアとしては致命傷になったりします)。
コード内の時間を書き換えると、メンテ画面に表示されている終了時間が狂ってしまうので、Lambda関数の環境変数でタイムゾーンを設定しました。
Lambda関数のタイムゾーン設定

改めてレスポンスヘッダーを確認して、正しくretry-afterの時間が本来の終了予定時間である「Tue Dec 18 2018 01:00:00 GMT」になっていることが確認できました。
Retry-After

メンテナンス開始・終了の切り替え

今回のメンテナンスではメンテナンス中のすべてのリクエストをメンテナンス画面に誘導したかったため、メンテナンス開始時はALBリスナーのルールでパスが「*」のとき、転送先を「Lambda関数のターゲットグループ」にするというを一番上に追加するだけで、すべてのリクエストをメンテナンスページに誘導することができます。

逆にメンテナンス終了時は上記で追加したルールを削除するだけで、通常のルーティングを行うことができます。

切り替えに掛かる時間は体感ですが数秒~十数秒程度なので、DNSを切り替えたり、サーバにログインして環境変数などを変更するのに比べて手早く切り替えが出来ると思います。

まとめ

事前の検証や準備を入念に行っていたので、メンテナンス当日も予定通りに進行することができました。
ALBリスナーのルール登録/削除を予め用意したCLIコマンドで実行するようにすれば、さらに人的ミスのリスクを減らせると思います。

思っていた以上に簡単にALBからLambda関数を呼び出してレスポンスを生成することができたので、また別の機会にも使ってみたくなりました。

コラム:ALB Support for Lambdaのリクエストとレスポンス

ALBからのリクエスト内容

アプリケーションロードバランサー(ALB)のターゲットにAWS Lambdaが選択可能になりましたにリクエストとレスポンスの例が載っていますがフォーマットが一部崩れている部分があるため、そのままテストに代入しても動作しません。
こちらの例を元に、私がテストを行っていた際の正しいフォーマットに直したリクエストを下記に記載します。

{
  "requestContext": {
    "elb": {
      "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:XXXXXXXXXXX:targetgroup/sample/6d0ecf831eec9f09"
    }
  },
  "httpMethod": "GET",
  "path": "/",
  "queryStringParameters": {},
  "headers": {
    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "accept-encoding": "gzip",
    "accept-language": "en-US,en;q=0.5",
    "connection": "keep-alive",
    "cookie": "name=value",
    "host": "lambda-YYYYYYYY.elb.amazonaws.com",
    "upgrade-insecure-requests": "1",
    "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:60.0) Gecko/20100101 Firefox/60.0",
    "x-amzn-trace-id": "Root=1-5bdb40ca-556d8b0c50dc66f0511bf520",
    "x-forwarded-for": "192.0.2.1",
    "x-forwarded-port": "80",
    "x-forwarded-proto": "http"
  },
  "body": "",
  "isBase64Encoded": false
}

Lambda関数のレスポンス

レスポンスオブジェクトには下記を含めることが出来ます。
必須項目と型に関しては、実際に試してみた結果です。最低限、statusCode, headers, isBase64Encoded が必須項目となっているようです。

キー 必須 内容
statusCode int 3桁のHTTPステータスコード
statusDescription string ステータスコードの説明
headers object レスポンスヘッダーのオブジェクト
isBase64Encoded bool 本文がBase64形式かのbool値
body string 本文

headersは空のオブジェクトでも問題はありませんでしたが、Content-Typeは指定した方が良いと思います。デフォルトだと「application/octet-stream」になるようです。

bodyは入力しないと空のレスポンスを返すだけなので、なにかしら値は入るのではないでしょうか。
画像などのバイナリデータを返答するときは、isBase64Encoded=trueにしてbase64エンコードした内容をbodyに入れる必要があるようです(未検証)。

ちなみに、レスポンスオブジェクトで必須項目が無かったり、ALBの「502 Bad Gateway」画面が表示されました。
bad_gateway