Slack + AWS WAF でサービスをメンテナンス画面 (Sorry Page) に切り替える

この記事は [GLOBIS Advent Calendar 2021](https://qiita.com/advent-calendar/2021/globis) の8日目です。

ウェブサービスを更新する際、更新内容の特性上、サービスを一時的に「メンテナンス画面」に切り替えて閉塞することがあります。弊社ではこの状態を主に「メンテナンスモード」と呼んでおり、この記事でもそう呼ぶこととします（他では「Sorry Page」と呼ぶ場合も多いと思います）。

このメンテナンスモード、弊社においてはサービスごとに実装がまちまちの状況でした。そして GLOBIS SRE チームは、サービス横断のインフラ兼 ops チームから変化したチームだという経緯もあり、メンテナンスモードの切り替え作業は長らく SRE の担当作業になっていました。しかしサービスが徐々に増える中、アプリケーションごとに個別実装された運用を、 SRE が一手に引き受けるのは徐々に難しくなってきていましたし、リリースの際、わざわざメンテナンスモードのためだけに SRE を招聘してもらうのも不健全だなという気持ちが強くなってきていました。

そこで認知負荷や運用負荷を下げつつ、統一的な形でメンテナンスモードを実現できるよう、今年ブラッシュアップを図ったので、そのあたりの話を書きます。

最初に話の前提として、構成情報をいくつか書いておきます。実際はサービスごとに細かな差分はありますが、話を簡単にするために、統一した前提を設けることにします。

* 弊社のサービスは基本的に AWS で構築しています。
* メンテナンスモードの対象となるのは Rails サーバです。
* Rails が直接エンドユーザーへ HTML を返す場合もあれば、 SPA から呼ばれる API サーバとして動作している場合もあります。
* Rails サーバの構成はエンドユーザーに近い側から CloudFront -> ALB (+ WAF) -> Kubernetes Service (ClusterIP) -> Kubernetes Pod (Rails) という形とします。

## 要件

ブラッシュアップするにあたり、現状の何が問題なのか、理想的なメンテナンスモードとはどういう状態なのか、要件を洗い出してみます。

### 切り替え手順は簡易でありながら、権限は限定する

先述したような、サービスごとに切り替え手順がまちまちという状態を避け、統一的かつよりシンプルな方法で切り替えをしたい、というのがこの話の発端です。

ただ、「シンプルで簡単で **誰でも** 使える」というところまで行ってしまうと行き過ぎです。メンテナンスモードへの切り替えは、サービスの死活を左右する極めて重要な機能ですので、シンプルでありながら、実行可能なメンバーは絞れる仕組みになっている必要があります。

### SRE を介さず完結する

1つ前の項目の、手順をシンプルにする、という点と若干重複しますが、メンテナンス作業は基本的に開発ドリブンで発生するものですので、本来 SRE が介在する必要はないですし、しない形が望ましいと思っています。従来は SRE が踏み台サーバに入ってコマンドを打つパターンが多かったのですが、これをやめて簡単に権限委譲できるようにします。

また、単に「切り替えを行う」という点だけではなく、メンテナンス画面にメンテナンス予定日時を書き入れるなどの作業も含め、 SRE が介さず完結する形とします。

### よりクライアントに近いところで切り替える

従来のメンテナンスモードは、 Rails や nginx で処理している場合がありましたが、バックエンドに処理を持たせると、そのバックエンド自体が動かない場合にメンテナンスモードが稼働しないという問題があります。 Kubernetes 上でアプリケーションサーバーを動かしているので、 k8s cluster の障害も想定したいところです。従ってメンテナンスモードの処理は CDN や Load balancer など、よりクライアントに近いところで実装するのがベターと考えました。

### SEO やユーザビリティを考慮する

具体的には、メンテナンス中は 503 を返し、一時的な unavailable であると明示することがひとつ。もうひとつは 302 などでメンテナンスページへ redirect するのではなく、 URL のパスはそのままに 503 とすることで、メンテナンスを終えた際にユーザーがリロードすれば済むようにすることです。

### 予定メンテナンスだけではなく障害時にも応用可能とする

メンテナンスモードは予定メンテナンスで使うものですが、 Rails が何かしらの不具合で応答できなくなった場合にも、仕組みを応用してメンテナンス画面を返せるような構成とします。

## 構成

結論としては以下のような構成となりました。

![advent_calendar_2021_numata.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19465/9cb3fbdf-f9e8-8c3d-0663-b1d1bbb1f411.png)


1. 作業者は Slack から ChatOps により、対象 WAF を選択してメンテナンスモード切り替えを実行します。
1. Slack App が WAF Web ACL の default rule を `allow` から `block` に切り替え、さらにカスタムレスポンスで `503` を返すよう設定します。
1. Rails がエンドユーザーへ直接 HTML を返すサービスの場合、これを CloudFront が受け、 `503` に対するカスタムエラーレスポンスで、 S3 に用意したメンテナンス画面用 HTML を返します。
1. SPA が Rails の API を call している構成の場合、 SPA が `503` を受けたときにメンテナンス画面を表示するようあらかじめ設定しておきます。


以下、それぞれ詳しく見て行きます。

### AWS WAF で柔軟にメンテナンスモードを制御

制御の要は AWS WAF です。Kubernetes よりも外、 AWS managed なところで制御を行っているので、バックエンドの障害にメンテナンスモードも引きずられてしまうということがなくなりました。 WAF や ALB 自体の障害などがない限りはメンテナンスモードを活用できます。

AWS WAF は、ソース IP や Request header などを条件とした柔軟なアクセス制御を行いつつ、マネージドサービスなので統一的な API で扱えるのが魅力です。例えばメンテナンスモードへの切り替えを行いつつ、社内からのアクセスだけは作業の確認用に許可しておく、といったことも WAF rules を工夫することで実現できます。特定のパスだけをメンテナンスモードにする、といったことも技術的には可能です。

これによりメンテナンスモードの切り替えが弊社アプリケーションの実装に依存せず、 AWS API だけで制御できることになりました。これは手順を整備したり、切り替えツールを実装する上では大きなメリットで、後述する Slack App の実現にも繋がりました。

ちなみに ALB 単体でも似たことは実現できますが、 WAF であれば「default rule の allow/block 切り替え」というシンプルな運用だけを考えれば良く、採用の決め手になりました。

### 画面表示の責務はクライアント側に寄せる

AWS WAF でメンテナンスモードを制御することにより、メンテナンスモードとは「バックエンドが 503 を返している状態」と定義できるようになりました。メンテナンス画面を表示するのは、バックエンドの 503 を受け取る SPA や CloudFront の責務となり、役割が明確に整理できました。

メンテナンスの日時を書き入れるなど、メンテナンス画面の表示内容を書き換えたい場合は、 SPA もしくは S3 上の静的ページを書き換えれば OK です。これらは開発チームのレポジトリで管理、デプロイされるので、 SRE が関与する必要がありません。

### 切り替え実行は slack

![スクリーンショット 2021-12-06 19.01.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19465/b895ffdd-440d-7f84-c253-f94ef60b669f.png)

切り替えには Slack App を用意し、スラッシュコマンドで直感的に扱えるようにしました。これは僕が作ったのですが、メンバーからは「『！！』とか、やたらテンションたけーな」と言われました。仕様です。

実行した際は、誰がどの WAF について操作したかを Slack 上に残すようになっているので、 message を削除しない限りは実行の記録も残せます。

![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f31393436352f62636531333563372d643239352d333333342d373436632d3663373239643636373737382e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19465/a052291f-badc-101f-c8ec-440122b8f2fe.png)


権限制御のため、 Slack App 側で実行元の channel ID をチェックし、許可された channel からの実行のみを受け付けるようにしています。許可 channel を特定の private channel だけにすることにより、権限の限定を実現しています。

なお、現状はまだ SRE だけしか操作できない形に限定しています。各サービス開発サイドへの権限委譲は今後進めていく予定です。

## 没構成

上記構成に至るまでにいくつか没になった構成がありました。

### CloudFront + Lambda@Edge

先述の通り、 WAF が block する際にはステータスコードを 503 に変更していますが、[これが出来るようになったのは2021年3月](https://aws.amazon.com/jp/about-aws/whats-new/2021/03/aws-waf-adds-support-custom-responses/)とかなり最近のことです。メンテナンスモードの構想を始めたのは2021年1月頃で、その頃、 WAF が block した際のステータスコードは 403 で固定されていました。これが困るのは、 CloudFront のカスタムエラーレスポンスはステータスコードしか条件としていないので、 WAF による 403 と、 API の純粋な Forbidden による 403 とを区別できないということです。もしもこの当時、 WAF でメンテナンスモードを実装した場合、 CloudFront は 403 を受けたらメンテナンス画面を表示する形になりますが、これでは単なる未ログインユーザーなどにもメンテナンス画面が表示されてしまいます。

従って構想当初は WAF による実装を断念しました。その代わりとしていたのが CloudFront のオリジンレスポンスに Lambda@Edge を設定し、その中でメンテナンスモードの処理を行う実装です。このときは slack コマンドでメンテナンスモードを有効化すると、 SSM Parameter Store に対象 CloudFront の ID をセットし、 Lambda@Edge はそれをチェックして、メンテナンスモード有効の場合には 503 を返す、ということを行っていました。

```js
'use strict';

const AWS = require('aws-sdk');
const ssm = new AWS.SSM({ region: 'ap-northeast-1' });
const timeoutSec = 300;
const cache = {
  in_maintenance_cfs: null,
  timeout_at: null,
};


exports.handler = async event => {
  const request = event.Records[0].cf.request;
  const distributionId = event.Records[0].cf.config.distributionId;

  const now = new Date().getTime();
  // cache の存在判定
  if (cache['in_maintenance_cfs'] === null || now > cache['timeout_at']) {
    console.log('cache does not exist.');
    const ssmRequest = {
      Name: '/hoge/fuga/maintenance',
      WithDecryption: false
    };
    const ssmResponse = await ssm.getParameter(ssmRequest).promise();
    cache['in_maintenance_cfs'] = ssmResponse.Parameter.Value;
    cache['timeout_at'] = now + timeoutSec * 1000;
  }
  const inMaintenanceCfs = cache['in_maintenance_cfs'];

  if inMaintenanceCfs.indexOf(distributionId) != -1) {
    console.log(distributionId + ' in maintenace.');
    return {
      status: '503',
      statusDescription: 'Service Temporarily Unavailable',
      headers: {
        "content-type": [{ key: "Content-Type", value: "text/plain; charset=utf-8" }]
      },
      body: "503 Service Temporarily Unavailable"
    };
  }

  return request;
};
```

一度取得した SSM Parameter をキャッシュする仕組みは入れてあるとはいえ、 Lambda@Edge で AWS API を叩くというのはレスポンスへの影響を考えるとなかなかにアグレッシブな構成で、本音を言えばやりたくありませんでした。 AWS WAF のカスタムレスポンス対応が、弊社からすると本当に神がかったタイミングで来てくれました。

### CloudFront + WAF

もう一つ検討に挙がっていたのが、 ALB ではなく CloudFront に連携させた WAF を用いる構成です。ALB + WAF の場合とそれほど変わりがないのではと思われるかもしれませんが、例えば同じ ALB が提供する API に対して、複数の CloudFront を連携させ、それぞれ利用者用と管理者用といった形で別のサブドメインを振ることがあります。このとき、 CloudFront 連携の WAF で制御を行えば、利用者用サブドメインだけを閉塞する、といったケースに対応できます。

しかし、この構成については技術的に不可能であることがわかりました。というのも、 CloudFront に連携させた WAF によるカスタムレスポンスを、 CloudFront のカスタムエラーレスポンスで上書きすることができないためです（ちなみに WAF のデフォルト 403 のレスポンスは、カスタムエラーレスポンスで上書きできます）。したがって WAF からのレスポンスが直接利用者側に見えてしまうことになります。 WAF のカスタムレスポンスで body を書き換えることもできるので、その中でメンテナンス画面の HTML を描画することもできなくはありませんが、ちょっと実装が複雑になりそうなのでやめました。サブドメイン別でのアクセス制御を行いたければ、 WAF の rule を用いることとしています。

## Slack App の実装

Slack App のソースコード全文はここには貼れない量なので割愛します。インフラとしては AWS Lambda + API Gateway を活用しており、 Serverless Framework でデプロイしています。

実装はなるべく Slack の現時点での Best Practice に則るようにしました。気をつけたのは以下のような点です。

* 馴染み深い [Secondary message attachment](https://api.slack.com/reference/messaging/attachments) による装飾は Legacy とされているので、推奨に従い [Layout Blocks](https://api.slack.com/reference/block-kit/blocks) を使う。
* [Verifying requests from Slack](https://api.slack.com/authentication/verifying-requests-from-slack) に則り、アプリケーション側で Slack App からのリクエストを verifying する。
* Slack App からのリクエストをハンドリングする上では [Handling user interaction in your Slack apps | Slack](https://api.slack.com/interactivity/handling) に従う。
  * 例えばリクエストを受けたらまず 200 を返し、メッセージの表示などは別途非同期で行う。

## まとめ

実際のところ、1つのサービスにつき、メンテナンスモードを使う機会は年に数回程度です。しかし、弊社では年々サービス数が増えており、そのたびにオリジナルのメンテナンスモードを作っていては、徐々に侮れないトイル、負債になっていくおそれがあります。こういったところを早めに見直し、なるべく簡潔かつ権限委譲が可能な形へ組み替えることで開発組織のスケールを支えるのは、 SRE として重要な活動だと捉えています。