Ingress設定のみでメンテナンス用レスポンス応答に切り替える方法

# はじめに
最近寒くて趣味の自転車に乗るのが億劫になり、運動不足が深刻になってしまったので室内でも自転車を漕げるように[zwifter](https://www.zwift.com/ja)になりました。冬なのに窓全開で汗だらだら流しながら頑張ってます！

ZOZOTOWNは基本的にダウンタイム無しで日々更新していますが、やんごとなき事情により年1回あるかないかくらいの頻度でダウンタイムを伴うメンテナンスを行うことがあります。
本記事では、今年のメンテナンス時にIngress設定変更のみでメンテナンス用レスポンス応答に切り替えた方法を紹介します。

# ALB周りのインフラ構成
ユーザからのリクエストを受け付けるALBに関するインフラ構成技術は投稿時点で以下のようになっております。本記事の内容はこちらの構成での事例になります。

- Amazon EKS(v1.19)
- Ingress(apiVersion: networking.k8s.io/v1)
- AWS Load Balancer Controller(v2.2.0)

上記構成により、Ingressに設定した内容に基づきAWS Load Balancer ControllerがAWS Elastic Load Balancer(以下、ALBと記載)を構築します。　

# やりたいこと
実運用であったり、安全にメンテナンスを進めることを考えると、メンテナンス用レスポンスを返す以外にも制御すべきことはあり、やりたいこととしては以下の3点になりました。

- １点目。主目的であるステータスコード503(Service Unavailable)でメンテナンス用のレスポンスを返すことです。
- ２点目。メンテナンス後にサイトを開放して問題がないことをチェックするために、社内メンバーは事前に動作確認ができるとより安心して解放できますよね。動作確認用に社内VPNからのアクセスであれば正常なレスポンスを返せるようにしたいです。
- ３点目。外形監視[^1]のための定期的なリクエストも存在しますので、これに対しメンテナンス用レスポンスを返すと監視のアラートが鳴り響き、よろしくありませんのでこれもなんとかしたいです。メンテナンス期間中は外形監視をオフにすることも考えられますが、弊社の場合はその設定箇所がマイクロサービス別に分かれており、IaC化されているとはいえ個別にオフにするのが面倒でしたので、送信元が外形監視の環境で利用するNATGWである場合は正常なレスポンスを返してあげるのが良さそうです。

上記はいずれも送信元で振り分ける方針ですが、弊社はCDNを利用しておりますので送信元IPは必然的にCDNになってしまうため、判断情報としては送信元IPではなくX-Forwarded_Forヘッダーを参照する必要があります。但し、外形監視に関してはCDNに問題があるケースと、originサーバに問題があるケースの2つを切り分けるために、CDNへのリクエストとoriginサーバへのリクエストの2つの外形監視を行なってますのでそれぞれに対応する必要があります。

纏めると、要件は以下になります。

| 送信元 | 判定対象 | 対応内容 |
| ---- | ---- | ---- |
| 社内VPN | X-Forwarded_Forヘッダーの先頭IP | 正常なレスポンスを返す　(=通常時と同様にバックエンドに流す) |
| 外形監視環境のNATGW |  X-Forwarded_Forヘッダーの先頭IP  | 正常なレスポンスを返す　(=通常時と同様にバックエンドに流す) |
| 外形監視環境のNATGW |  送信元IP  | 正常なレスポンスを返す　(=通常時と同様にバックエンドに流す) |
| 上記以外 | X-Forwarded_Forヘッダーの先頭IP | ステータスコード503でメンテナンス用レスポンスを返す |

なお、弊社の場合バックエンドへの入り口はingress-gatewayになりますので、バックエンドに流すと記載している箇所はingress-gatewayに転送することを意味しております。

# どうやるのか
上記の要件をALBのリスナールールで対応します。

リスナールールは上から順に判定され、条件に一致した処理を行います。
先述した要件をこのリスナールールに落とし込むと下記の表のようになります。

| 優先度 | ルール名(service name) | 条件(condition) | 処理(action) |
| ---- | ---- | ---- | ---- |
| 1 | allow_vpn | X-Forwarded_Forヘッダーが<社内VPN>* | ingress-gateway:80へ転送 |
| 2 | allow-synthetics-external | X-Forwarded_Forヘッダーが<外形監視環境のNATGW>* | ingress-gateway:80へ転送 |
| 3 | allow-synthetics-origin | 送信元IPが<外形監視環境のNATGW>* | ingress-gateway:80へ転送 |
| 4 | response-maintenance | 条件無し | ステータスコード503でメンテナンス用レスポンスを返す |

## Ingressで設定する
ALBは先述した通り、Ingressの設定内容に基づきAWS Load Balancer Controllerが構築する仕掛けになっておりますので、リスナールールを追加するための設定はIngressで行います。

Ingressの設定内容についてはこちらを参考にしました。
https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.2/guide/ingress/annotations/#traffic-routing
なお、投稿時点で上記サイトの記載例はIngressのapiVersionはextensions/v1beta1で、これはv1.19のKubernetesで非推奨になりました。本記事では`apiVersion: networking.k8s.io/v1`での記載例になります。

apiVersionによる構成の違いはこちらを参考にしました。
https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#ingress-v1-networking-k8s-io

設定ポイントをざっくり書くと以下になります。

- リスナールールのcondition, actionをどこで定義するのか？ -> `metadata.annotations`にてアノテーションで設定
- リスナールールのconditionとactionの紐付けは？ -> アノテーションの以下の命名規則による紐付け
  - actionの場合、alb.ingress.kubernetes.io/actions.`<service name>`
  - condition場合、alb.ingress.kubernetes.io/conditions.`<service name>`
- リスナールールの優先順は？ -> `spec.rules.http.paths`内のpath記載順
  - conditionとactionを紐付ける`service name`はこのpath内で指定する

### Ingressの設定例
例えば、以下のリスナールールを設定したい場合、Ingressの設定は次のようになります。
（関連箇所のみ抜粋）

| ルール名(service name) | 条件(condition) | 処理(action) |
| ---- | ---- | ---- |
| allow_vpn | X-Forwarded_Forヘッダーが<社内VPN>* | ingress-gateway:80へ転送 |

```
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    alb.ingress.kubernetes.io/actions.allow-vpn: >
      {"type":"forward","forwardConfig":{"targetGroups":[{"serviceName":"<ingress-gatewayのサービス名>","servicePort":"80","weight":100}]}}
    alb.ingress.kubernetes.io/conditions.allow-vpn: >
      [{"field":"http-header","httpHeaderConfig":{"httpHeaderName": "X-Forwarded-For", "values":["<社内VPNのIP１つ目>*", "<社内VPNのIP2つ目>*"]}}]
spec:
  ingressClassName: alb
  rules:
  - http:
      paths:
      - pathType: ImplementationSpecific
        path: /*
        backend:
          service:
            name: allow-vpn
            port:
              name: use-annotation
```
注意点として、ALB側の制約で１つのリスナールールで指定可能なワイルドカード(`*`)の数は５つまでという制限があります。
今回のようにX-Forwarded_Forヘッダーの先頭IPで判定したい場合、先頭以外のIPをワイルドカードで判定する必要があり、上記の例では社内VPNのIPアドレスが２つあったため、それぞれで利用するとそれだけで２つ消費することになります。また、適用するパスの指定でも`/*`のように記載したい場合は１つとしてカウントされるためご注意ください。

### メンテナンス用のIngress設定内容
改めて、以下のメンテナンス用のリスナールールを適用する場合、Ingressの設定は次のようになります。
（関連箇所のみ抜粋）

| 優先度 | ルール名(service name) | 条件(condition) | 処理(action) |
| ---- | ---- | ---- | ---- |
| 1 | allow_vpn | X-Forwarded_Forヘッダーが<社内VPN>* | ingress-gateway:80へ転送 |
| 2 | allow-synthetics-external | X-Forwarded_Forヘッダーが<外形監視環境のNATGW>* | ingress-gateway:80へ転送 |
| 3 | allow-synthetics-origin | 送信元IPが<外形監視環境のNATGW>* | ingress-gateway:80へ転送 |
| 4 | response-maintenance | 条件無し | ステータスコード503でメンテナンス用レスポンスを返す |

```
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:

    ### allow_vpn
    alb.ingress.kubernetes.io/actions.allow-vpn: >
      {"type":"forward","forwardConfig":{"targetGroups":[{"serviceName":"<ingress-gatewayのサービス名>","servicePort":"80","weight":100}]}}
    alb.ingress.kubernetes.io/conditions.allow-vpn: >
      [{"field":"http-header","httpHeaderConfig":{"httpHeaderName": "X-Forwarded-For", "values":["<社内VPNのIP１つ目>*", "<社内VPNのIP2つ目>*"]}}]

    ### allow-synthetics-external
    alb.ingress.kubernetes.io/actions.allow-synthetics-external: >
      {"type":"forward","forwardConfig":{"targetGroups":[{"serviceName":"<ingress-gatewayのサービス名>","servicePort":"80","weight":100}]}}
    alb.ingress.kubernetes.io/conditions.allow-synthetics-external: >
      [{"field":"http-header","httpHeaderConfig":{"httpHeaderName": "X-Forwarded-For", "values":["<外形監視環境のNATGWのIP１つ目>*", "<外形監視環境のNATGWのIP2つ目>*", "<外形監視環境のNATGWのIP3つ目>*"]}}]

    ### allow-synthetics-origin
    alb.ingress.kubernetes.io/actions.allow-synthetics-origin: >
      {"type":"forward","forwardConfig":{"targetGroups":[{"serviceName":"<ingress-gatewayのサービス名>","servicePort":"80","weight":100}]}}
    alb.ingress.kubernetes.io/conditions.allow-synthetics-origin: >
      [{"field":"source-ip","sourceIpConfig":{"values":["<外形監視環境のNATGWのIP１つ目>/32", "<外形監視環境のNATGWのIP2つ目>/32", "<外形監視環境のNATGWのIP3つ目>/32"]}}]

    ### response-maintenance
    alb.ingress.kubernetes.io/actions.response-maintenance: >
      {"type":"fixed-response","fixedResponseConfig":{"contentType":"application/json","statusCode":"503","messageBody":"{\"code\": 503, \"message\": \"メンテナンス中です\"}"}}

spec:
  ingressClassName: alb
  rules:
  - http:
      paths:
      - pathType: ImplementationSpecific
        path: /*
        backend:
          service:
            name: allow-vpn
            port:
              name: use-annotation
      - pathType: ImplementationSpecific
        path: /*
        backend:
          service:
            name: allow-synthetics-external
            port:
              name: use-annotation
      - pathType: ImplementationSpecific
        path: /*
        backend:
          service:
            name: allow-synthetics-origin
            port:
              name: use-annotation
      - pathType: ImplementationSpecific
        path: /*
        backend:
          service:
            name: response-maintenance
            port:
              name: use-annotation
```

## メンテナンス当日の切り替え
上記のメンテナンス用のIngressマニフェストファイルを事前に準備しておくことで、メンテナンス当日に切り替えとしてやることは以下の2点のみになります。

- メンテナンス開始時、`メンテナンス用Ingressマニフェストファイル`をapplyする
- メンテナンス終了時、`元のIngressマニフェストファイル`をapplyする

# おわりに
今回はメンテナンスの事例を紹介しましたが、送信元で処理を切り替える仕組みは他のことにも応用できると思います。本記事を見ていただいた誰かのお役に立てれば幸いです。

[^1]: 外形監視にはAmazon CloudWatch Syntheticsを利用