ALBとTerraformで作るHTTPリダイレクタ

2023/08/06に公開

なにこれ

LAPRAS株式会社でSREをしていますyktakaha4と申します🐧
実務で最近おこなった対応について、いい感じに実装できたな~と思ったため備忘録として書き残すこととしました📝

作った理由

Webサービス運用に長く携わっていると、過去作ったWebアプリやLPなどのクローズに立ち会うことがままあります
この時、削除対象のサービスに関連するリソースを消していくことになりますが、サービスのドメイン(DNSレコード)を単純に削除すると、以下のようなエラー画面がユーザーに表示されます


削除されたドメインにアクセス

ユーザーにしてみれば、この表示だとサービスが終了したのか、一時的なシステムエラー等が生じているのか判断ができず、望ましい挙動とは言えないものと思います
例えば、 サービスは終了しました といった内容の簡易なHTMLページを表示したり、コーポレートサイト等へリダイレクトできるとよいですが、
そのためにはなにかしらのWebサービスを常時立ち上げておく必要があり、またサービス終了のたびにそうしたものを都度作るのも、運用担当者としては避けたいところです

弊社ではサービスのインフラにAWSを利用しているのですが、ロードバランサーのマネージドサービスであるApplication Load Balancerは柔軟な設定が可能でありつつも管理コストがほとんどかからず、今回のユースケースにちょうどよさそうです
また、実装については、弊社でも利用しているTerraformを使ってIaC管理したいです

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/introduction.html

ということで、ALBとTerraformを使ってシンプルなリダイレクタを構築することとしました☕

どのようなものを作るか

実装にあたって必要そうな仕様を整理し、以下のような機能が実現できるとよいと考えました

  • あるドメインにアクセスした際に、別のドメインにリダイレクトさせる
    • リダイレクトの際に、パスやクエリパラメータを維持or破棄するかを選択できる
    • 要件に応じて任意のステータスコードを返却できる
  • 特定のドメインについては、デザイン付きの簡易なHTMLファイルを返却できる
  • HTTP / HTTPS共に同一のレスポンスを返却する
    • 昨今はHTTPSによるサービス構築が前提となっているものの、ユーザーがアドレスバーに http://~ と手入力してアクセスして来るケースも考えられます

また、システム管理上の観点からは、以下の仕様を満たせるとよさそうです

  • リダイレクト対象のドメインが増えてもコストのかかるリソース(ALB本体など)を追加作成しなくてよくする
  • OSやミドルウェアのバージョンアップなどのメンテナンスコストを極力排する
    • これは、ALBだけで実装すれば概ね達成することができそうです💡
  • アクセスログを取得する
    • 今回の本筋とは離れますが、作成するS3はサーバサイド暗号化やライフサイクルルールの設定といった一般的な内容を踏襲するものとします

上記の仕様を考えたうえで、今回の検証に用いるドメイン test-penguin.net を取得し、以下に示すようなビジネス要求を想定します

  • test-penguin.net ドメインにアクセスした際は、 https://example.com/ にリダイレクトする
    • パスやクエリパラメータは維持する
  • subdomain.test-penguin.net ドメインにアクセスした際は、 https://example.net/ にリダイレクトする
    • パスやクエリパラメータは維持しない
  • fixed.subdomain.test-penguin.net ドメインにアクセスした際は、簡易なHTMLページを表示する
    • CSSによるスタイリングを可能とする

前述の仕様を簡単なアーキテクチャ図に起こしたものが以下です


今回作るもののアーキテクチャ図

それではやっていきましょう🍮

作ったもの

今回作成したものをGitHubに公開しました🐙

https://github.com/yktakaha4/aws-alb-redirector

利用にあたっては、 terraform apply の過程で作成される aws_route53_zone のネームサーバーをご自身の持っているドメインに紐づけてください
また、 variable.tf についても各自で変更をお願いします


https://us-east-1.console.aws.amazon.com/route53/domains/home#/

いくつか、ポイントになる部分を見ていきます

今回作成されるALBは aws_lb.main ひとつだけです

aws_lb.tf
resource "aws_lb" "main" {
  name               = "${var.service}-${var.env}-main"
  load_balancer_type = "application"

  internal = false

  security_groups = [
    aws_security_group.main.id,
  ]

  subnets = data.aws_subnets.default.ids

  access_logs {
    enabled = true
    bucket  = aws_s3_bucket.logs.bucket
    prefix  = "access-log"
  }

  tags = {
    "Name" = "${var.service}-${var.env}-main"
  }
}

本稿執筆時点でのALBのコストは 0.0243USD/h + LCUによる従量課金なので、月額としては2500円~程度になります
これをサービス終了のたびに作成していたら結構なコストになってしまいますが、今回作るものは単一のALBで複数ドメインを処理するため、コスト増を抑えられるのでないかと思います

https://aws.amazon.com/jp/elasticloadbalancing/pricing/

ALBに対して、HTTPおよびHTTPSのリスナーを設定します
リスナーはデフォルトルールをひとつ指定する必要があるので、実務ではコーポレートサイトへのリダイレクトなど、汎用的に利用されそうなものをデフォルトとしておくとよいでしょう
また、HTTPについてはHTTPSとしてリダイレクトされるように redirect ブロックを設定します

aws_lb_listener.tf
resource "aws_lb_listener" "main_http" {
  load_balancer_arn = aws_lb.main.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      host        = var.redirector_default_destination_domain_name
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

resource "aws_lb_listener" "main_https" {
  load_balancer_arn = aws_lb.main.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"

  certificate_arn = aws_acm_certificate.source.arn

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      host        = var.redirector_default_destination_domain_name
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

リスナールールは、HTTP / HTTPS両方のものを作成する必要があるため、for_eachを利用します

https://developer.hashicorp.com/terraform/language/meta-arguments/for_each

今回は host_header を利用して、どのドメイン名からアクセスしてきたかによって同一ALBでリダイレクト先を変更します
加えて、簡易HTMLページ用のCSSを fixed.subdomain.test-penguin.net/fixed.css で配信するために path_pattern を利用します
ALBでリダイレクタを作成するメリットとして、上記以外にもかなり柔軟な条件判定ができますので、詳細は以下を確認してください

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/load-balancer-listeners.html#rule-condition-types

条件にマッチした際のアクションについても、パス・クエリパラメータの維持だけでなく、追加の内容で上書きといった操作も事由に行えます

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/load-balancer-listeners.html#rule-action-types

aws_lb_listener_rule.tf
resource "aws_lb_listener_rule" "main_subdomain" {
  for_each = toset([
    aws_lb_listener.main_https.arn,
    aws_lb_listener.main_http.arn,
  ])

  listener_arn = each.value
  priority     = 10

  condition {
    host_header {
      values = [local.redirector_source_subdomain_name]
    }
  }

  action {
    type = "redirect"

    redirect {
      port        = "443"
      host        = var.redirector_subdomain_destination_domain_name
      protocol    = "HTTPS"
      status_code = "HTTP_301"

      path  = "/"
      query = ""
    }
  }
}

resource "aws_lb_listener_rule" "main_fixed_style" {
  for_each = toset([
    aws_lb_listener.main_https.arn,
    aws_lb_listener.main_http.arn,
  ])

  listener_arn = each.value
  priority     = 20

  condition {
    host_header {
      values = [local.redirector_fixed_domain_name]
    }
  }

  condition {
    path_pattern {
      values = ["/fixed.css"]
    }
  }

  action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/css"
      status_code  = "200"
      message_body = templatefile("${path.module}/templates/fixed.css", {})
    }
  }
}

resource "aws_lb_listener_rule" "main_fixed" {
  for_each = toset([
    aws_lb_listener.main_https.arn,
    aws_lb_listener.main_http.arn,
  ])

  listener_arn = each.value
  priority     = 25

  condition {
    host_header {
      values = [local.redirector_fixed_domain_name]
    }
  }

  action {
    type = "fixed-response"

    fixed_response {
      content_type = "text/html"
      status_code  = "410"
      message_body = templatefile(
        "${path.module}/templates/fixed.html",
        {
          domain_name = local.redirector_fixed_domain_name
        }
      )
    }
  }
}

HTML返却時の小技として、templatefile関数を利用することで、HTMLファイルにTerraformのレイヤから任意の文字列を注入することができます

https://developer.hashicorp.com/terraform/language/functions/templatefile

実務においては、サービス名やURLの情報をmapに持たせておいて for_each で参照させて、同一HTMLテンプレートで複数のサービス終了ページを実装する…といったこともできるものと思います

templates/fixed.html
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>${domain_name} has been permanently removed.</title>
    <link rel="stylesheet" href="fixed.css">
</head>
<body>
    <span class="domain_name">${domain_name}</span> has been permanently removed.
</body>
</html>
templates/fixed.css
.domain_name {
    font-weight: bold;
    color: red;
}

静的ファイル返却の注意点としては、 レスポンスできるコンテンツのサイズは1024文字以下に収める 必要がある…というものがあります
比較的厳しい制限ではありますが、内容を工夫すればそれなりのデザインのものを最小限の維持コストで配信できるので、デザイナーの方と相談しながら実装できるとよいものと思います

https://zenn.dev/yktakaha4/articles/how_to_make_sorry_page#案2%3Aalbで固定レスポンス返却

今回、複数のサーバー証明書を同一ALBに設定するサンプルを作るため、 test-penguin.net および *.subdomain.test-penguin.net の2種類の証明書をACMで作成しています
ALBに紐づける際は、ひとつ目の証明書を aws_lb_listener に設定し、2つ目以降は aws_lb_listener_certificate を追加で作成して紐づけることとなります

aws_lb_listener_certificate.tf
resource "aws_lb_listener_certificate" "source_subdomain" {
  listener_arn    = aws_lb_listener.main_https.arn
  certificate_arn = aws_acm_certificate.source_subdomain.arn
}

最後に小ネタですが、今回ALBをデフォルトVPCおよびデフォルトサブネットに作成していますが、これらのIDを variable.tf に記載するとサンプルコードとして使い勝手が悪くなります
これを動的に取得するには、以下のようにaws_vpcaws_subnetsといったデータソースが活用できます
同名のリソースもあるので利用に際しては注意してください

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/vpc

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/subnets

また、ALBから自己所有しているS3バケットにアクセスログ書き込みを許可するために、リージョンごとのELBのサービスアカウントのIDを指定する必要があったのですが、これについてはaws_elb_service_accountを使うと、適用先のリージョンに合わせて動的に設定値を決定出来て大変便利です

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/elb_service_account

data.tf
data "aws_elb_service_account" "current" {}

data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

動作確認

最後に、作ったものを簡単に動作確認してみます

test-penguin.net にアクセスすると、ちゃんと example.com にリダイレクトされています
パスやクエリパラメータも維持されておりよさそうです🍎

$ curl --include "https://test-penguin.net/path?query=abc"
HTTP/2 301
server: awselb/2.0
date: Sun, 06 Aug 2023 06:27:43 GMT
content-type: text/html
content-length: 134
location: https://example.com:443/path?query=abc

<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
</body>
</html>

対して、 subdomain.test-penguin.net へのアクセスは、 example.net にリダイレクトされます
こちらはパスやクエリパラメータは破棄します
また、HTTPアクセスした場合も Location ヘッダはHTTPSにアクセスするよう指示されておりよさそうです

$ curl --include "http://subdomain.test-penguin.net/path?query=abc"
HTTP/1.1 301 Moved Permanently
Server: awselb/2.0
Date: Sun, 06 Aug 2023 06:28:54 GMT
Content-Type: text/html
Content-Length: 134
Connection: close
Location: https://example.net:443/

<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
</body>
</html>

最後に、 fixed.subdomain.test-penguin.net にアクセスしてみます
fixed.html では ${domain_name} となっていたパラメータ部分に実際のドメイン名が埋め込まれています

$ curl --include "https://fixed.subdomain.test-penguin.net"
HTTP/2 410
server: awselb/2.0
date: Sun, 06 Aug 2023 06:31:56 GMT
content-type: text/html; charset=utf-8
content-length: 309

<html lang="en">
<head>
    <meta charset="utf-8">
    <title>fixed.subdomain.test-penguin.net has been permanently removed.</title>
    <link rel="stylesheet" href="fixed.css">
</head>
<body>
    <span class="domain_name">fixed.subdomain.test-penguin.net</span> has been permanently removed.
</body>
</html>

HTMLだけでなくCSSも固定レスポンスで配信しているため、EC2やCloudFrontといった配信の仕組みを追加で持つ必要がなくなり、可用性の向上も期待できそうです


サービス終了ページのサンプル

おわりに

サービスのクローズは残念な出来事ではありますが、いい加減な対応で負債を積み増さないようにしていきたいですね🐕

Discussion