external-dnsでAWS Route53フェイルオーバーを実現する方法

Kubernetesで動作するアプリケーションのDNS管理には、external-dnsが広く使われています。external-dnsを使うと、様々なDNSプロバイダーのレコード設定を統一的な方法で管理できるため、運用の手間を大幅に削減できます。

https://kubernetes-sigs.github.io/external-dns/latest/

しかし、external-dnsがDNSプロバイダー固有の機能にどこまで対応しているのかは気になるところです。特に、AWS Route53のフェイルオーバー機能への対応については、公式ドキュメントでも触れられているものの、本当にうまく統合できるのか気になったので試してみることにしました。

https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover.html

本記事では、external-dnsを使ってAWS Route53のフェイルオーバーを設定する方法を、実際の検証を通じて詳しく解説します。

## TL;DR

- external-dnsはAWS Route53のフェイルオーバーに完全対応しています
- ただし、Health Checkの作成は自分で行う必要があります（external-dnsの責務外）
- ヘルスチェックIDをKubernetesのオブジェクトにセットすることで、external-dnsがフェイルオーバー設定を含むレコードを作成できます

## 検証環境の構成

今回は以下のような構成で検証を行います：

1. AWS側の構成
   - 2つのALB（Primary/Secondary）を配置
   - Route53でフェイルオーバー設定
   - Primary ALBにヘルスチェックを設定

2. Kubernetes側の構成
   - ローカルのKubernetesクラスター上でexternal-dnsを実行
   - CRDを使ってDNSレコードを管理

```mermaid
graph TD
subgraph Local Kubernetes Cluster
  external-dns[external-dns]
end

external-dns -->|Reconciles| primary_record
external-dns -->|Reconciles| secondary_record

  %% DNS
  subgraph Route53[Route 53]
    subgraph Zone[Zone failover.test]
      primary_record["<i>Record 1</i><br>www"]
      secondary_record["<i>Record 2</i><br>www"]
    end
    HealthCheck
  end

  %% VPC Resources
  subgraph VPC
    %% Primary Path
    Primary_ALB[Primary ALB] --> Primary_Listener[HTTP Listener<br/>Port 80<br/>Fixed Response]
    
    %% Secondary Path
    Secondary_ALB[Secondary ALB] --> Secondary_Listener[HTTP Listener<br/>Port 80<br/>Fixed Response]
  end

  %% Health Check Connection
  primary_record --> Primary_ALB
  secondary_record --> Secondary_ALB
  primary_record --> HealthCheck
  HealthCheck --> Primary_ALB
```

### なぜこの構成なのか？

実務では、ALBの後ろにEC2インスタンスやECSタスクなどのアプリケーションを配置するのが一般的です。しかし、今回は検証をシンプルにするために、ALBのFixed Response機能を使って、アプリケーション層を省略しています。

Fixed Responseとは、ALBのリスナーに設定できるアクションの1つで、リクエストに対して固定のレスポンスを返す機能です。この機能を使うことで：

- Primary ALBは通常時に200 HTTPステータスを返す
- 障害時は503 HTTPステータスを返すように変更

という形で、フェイルオーバーのシミュレーションが簡単にできます。

### フェイルオーバーの仕組み

この構成では、以下のようにフェイルオーバーが動作します：

1. 通常時
   - クライアントからのリクエストはPrimary ALBに転送
   - Primary ALBは200 OKを返す

2. 障害発生時
   - Primary ALBが503を返すようになる
   - Route53のヘルスチェックがPrimary ALBを「Unhealthy」と判定
   - Route53がDNSレコードを自動的に切り替え
   - クライアントからのリクエストがSecondary ALBに転送

## 必要な準備

### 必要なリソース

- AWSアカウント
- AWSの認証情報（アクセスキー/シークレットキー）
- ローカルのKubernetesクラスター（k3dなど）

### 必要なツール

- awscli: AWSリソースの操作用
- terraform: インフラ構築用
- httpie: APIテスト用（curlでも代用可）
- dig: DNSレコードの確認用

### AWS認証情報の設定

```bash
export AWS_ACCESS_KEY_ID="your_access_key"
export AWS_SECRET_ACCESS_KEY="your_secret_key"
export AWS_REGION="ap-northeast-1"
```

認証情報が正しく設定されているか確認：

```bash
aws sts get-caller-identity
```

## 検証環境の構築

### 重要：external-dnsの責務範囲について

external-dnsは「DNSレコードの管理」に特化したツールです。そのため、以下のリソースはexternal-dnsの責務範囲外となり、別途作成する必要があります：

- Route53ゾーン
- Route53 Health Check
- ALBなどのインフラリソース

今回はこれらのリソースをTerraformで作成します。

### 1. AWSリソースの作成

`main.tf`を作成し、必要なリソースを定義します。

```bash
# Terraformの初期化
terraform init

# リソースの作成
terraform apply
```

<details>
<summary>main.tfを見る</summary>

```hcl:main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1" # Tokyo region
}

# Common tags for all resources
locals {
  common_tags = {
    Environment = "demo"
    Project     = "route53-failover"
    Purpose     = "external-dns-and-failover-testing"
    Terraform   = "true"
  }
}

# Use default VPC and its public subnets
data "aws_vpc" "default" {
  default = true
}

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

# Security Group for ALBs
resource "aws_security_group" "alb" {
  name        = "alb-failover-demo"
  description = "Allow HTTP inbound traffic for failover demo"
  vpc_id      = data.aws_vpc.default.id

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  
  tags = local.common_tags
}

# Primary ALB
resource "aws_lb" "primary" {
  name               = "alb-failover-demo-primary"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets           = slice(data.aws_subnets.public.ids, 0, 2)

  tags = local.common_tags
}

# Secondary ALB
resource "aws_lb" "secondary" {
  name               = "alb-failover-demo-secondary"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets           = slice(data.aws_subnets.public.ids, 0, 2)

  tags = local.common_tags
}

# Primary ALB Listener with Fixed Response
resource "aws_lb_listener" "primary" {
  load_balancer_arn = aws_lb.primary.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "fixed-response"
    fixed_response {
      content_type = "text/plain"
      message_body = "This is PRIMARY ALB"
      status_code  = "200"
    }
  }

  tags = local.common_tags
}

# Secondary ALB Listener with Fixed Response
resource "aws_lb_listener" "secondary" {
  load_balancer_arn = aws_lb.secondary.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "fixed-response"
    fixed_response {
      content_type = "text/plain"
      message_body = "This is SECONDARY ALB"
      status_code  = "200"
    }
  }

  tags = local.common_tags
}

# Route53 Hosted Zone
resource "aws_route53_zone" "main" {
  name = "failover.test"

  tags = local.common_tags
}

# Health Check for Primary ALB
resource "aws_route53_health_check" "primary" {
  fqdn              = aws_lb.primary.dns_name
  port              = 80
  type              = "HTTP"
  resource_path     = "/"
  failure_threshold = "3"
  request_interval  = "10"

  tags = local.common_tags
}

# Primary Record (Commented out for now because it is created by external-dns)
# resource "aws_route53_record" "primary" {
#   zone_id = aws_route53_zone.main.zone_id
#   name    = "www"
#   type    = "A"

#   failover_routing_policy {
#     type = "PRIMARY"
#   }

#   set_identifier = "primary"
#   health_check_id = aws_route53_health_check.primary.id

#   alias {
#     name                   = aws_lb.primary.dns_name
#     zone_id                = aws_lb.primary.zone_id
#     evaluate_target_health = true
#   }
# }

# Secondary Record (Commented out for now because it is created by external-dns)
# resource "aws_route53_record" "secondary" {
#   zone_id = aws_route53_zone.main.zone_id
#   name    = "www"
#   type    = "A"

#   failover_routing_policy {
#     type = "SECONDARY"
#   }

#   set_identifier = "secondary"

#   alias {
#     name                   = aws_lb.secondary.dns_name
#     zone_id                = aws_lb.secondary.zone_id
#     evaluate_target_health = true
#   }
# }

# Outputs
output "primary_alb_dns" {
  value = aws_lb.primary.dns_name
}

output "primary_alb_arn" {
  value = aws_lb.primary.arn
}

output "primary_listener_arn" {
  value = aws_lb_listener.primary.arn
}

output "secondary_alb_dns" {
  value = aws_lb.secondary.dns_name
}

output "zone_id" {
  value = aws_route53_zone.main.zone_id
}

output "health_check_id" {
  value = aws_route53_health_check.primary.id
}

output "nameservers" {
  value = aws_route53_zone.main.name_servers
  description = "Nameservers for the Route53 zone"
}
```

</details>

### 2. ALBの動作確認

両方のALBに直接アクセスして、正常に応答することを確認します：

```bash
# Primary ALBの確認
http $(terraform output -raw primary_alb_dns)
```

```http
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 19

This is PRIMARY ALB
```

```bash
http $(terraform output -raw secondary_alb_dns)
```

```http
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 21

This is SECONDARY ALB
```

このように、それぞれ別のレスポンスが返ってくることを確認します。


### 3. external-dnsのインストール

今回はBitnamiが提供するHelmチャートを使用します。このチャートを選んだ理由は：

- より多くの設定オプションが用意されている
- AWS Route53との連携が容易
- コミュニティでの利用実績が豊富

https://github.com/bitnami/charts

また、今回はCRDを使ってDNSレコードを管理します。これにより：

- IngressやGatewayなどのリソースが不要
- external-dnsの動作を純粋に検証可能
- 設定がシンプルで理解しやすい

```bash
helm install external-dns \
  --set provider=aws \
  --set aws.zoneType=public \
  --set aws.credentials.accessKey="$AWS_ACCESS_KEY_ID" \
  --set aws.credentials.secretKey="$AWS_SECRET_ACCESS_KEY" \
  --set txtOwnerId=external-dns-and-failover-testing \
  --set "domainFilters[0]=failover.test" \
  --set policy=sync \
  --set "sources[0]=crd" \
  --set crd.create=true \
  --set crd.apiversion=externaldns.k8s.io/v1alpha1 \
  --set crd.kind=DNSEndpoint \
  oci://registry-1.docker.io/bitnamicharts/external-dns
```

#### 各オプションの説明

| オプション | 説明 | なぜ必要か |
|------------|------|------------|
| provider=aws | AWS Route53を使用 | DNSプロバイダーの指定 |
| aws.zoneType=public | パブリックゾーンを使用 | ゾーンタイプの指定 |
| aws.credentials.* | AWS認証情報 | Route53への認証用 |
| txtOwnerId | TXTレコードの所有者ID | レコード管理の識別用(なんでもいい) |
| domainFilters | 監視対象ドメイン | 範囲限定のため |
| policy=sync | 同期ポリシー | レコード同期方式の指定 |
| sources[0]=crd | CRDをソースとして使用 | レコード定義方法の指定 |
| crd.* | CRD関連の設定 | CRD作成・設定用 |

### 4. フェイルオーバーレコードの作成

`www.failover.test.yaml`を作成します：

```yaml
apiVersion: externaldns.k8s.io/v1alpha1
kind: DNSEndpoint
metadata:
  name: www.failover.test
  namespace: default
spec:
  endpoints:
    # Primaryレコード
    - dnsName: www.failover.test
      recordType: A
      recordTTL: 60
      providerSpecific:
        - name: alias
          value: "true"
        - name: aws/failover
          value: PRIMARY
        - name: aws/health-check-id
          value: b0d7bb6b-d5aa-44a2-9f33-53f550df7f96  # terraform output -raw primary_health_check_id
        - name: aws/evaluate-target-health
          value: "true"
      setIdentifier: www-primary
      targets:
        - alb-failover-demo-primary-88520931.ap-northeast-1.elb.amazonaws.com  # terraform output -raw primary_alb_dns

    # Secondaryレコード
    - dnsName: www.failover.test
      recordType: A
      recordTTL: 60
      providerSpecific:
        - name: alias
          value: "true"
        - name: aws/failover
          value: SECONDARY
      setIdentifier: www-secondary
      targets:
        - alb-failover-demo-secondary-1897280663.ap-northeast-1.elb.amazonaws.com  # terraform output -raw secondary_alb_dns
```

#### 設定のポイント

1. **フェイルオーバー設定**
   - `aws/failover`: PRIMARY/SECONDARYの指定
   - `aws/health-check-id`: ヘルスチェックの関連付け
   - `aws/evaluate-target-health`: ヘルスチェック評価の有効化

2. **レコード識別子**
   - `setIdentifier`: 同じドメインの複数レコードを区別。Route53では「Record ID」と呼ばれるもの。

3. **エイリアス設定**
   - `alias: "true"`: ALBへのエイリアスレコードとして設定

この設定を適用します:

```bash
kubectl apply -f www.failover.test.yaml
```

external-dnsのログを確認して、レコードが作成されたことを確認します。

```bash
time="2024-11-07T08:33:55Z" level=info msg="Desired change: CREATE cname-www.failover.test TXT" profile=default zoneID=/hostedzone/Z10116603TTQ6VNVFWSZL zoneName=failover.test.
time="2024-11-07T08:33:55Z" level=info msg="Desired change: CREATE cname-www.failover.test TXT" profile=default zoneID=/hostedzone/Z10116603TTQ6VNVFWSZL zoneName=failover.test.
time="2024-11-07T08:33:55Z" level=info msg="Desired change: CREATE www.failover.test A" profile=default zoneID=/hostedzone/Z10116603TTQ6VNVFWSZL zoneName=failover.test.
time="2024-11-07T08:33:55Z" level=info msg="Desired change: CREATE www.failover.test A" profile=default zoneID=/hostedzone/Z10116603TTQ6VNVFWSZL zoneName=failover.test.
time="2024-11-07T08:33:55Z" level=info msg="Desired change: CREATE www.failover.test TXT" profile=default zoneID=/hostedzone/Z10116603TTQ6VNVFWSZL zoneName=failover.test.
time="2024-11-07T08:33:55Z" level=info msg="Desired change: CREATE www.failover.test TXT" profile=default zoneID=/hostedzone/Z10116603TTQ6VNVFWSZL zoneName=failover.test.
time="2024-11-07T08:33:55Z" level=info msg="6 record(s) were successfully updated" profile=default zoneID=/hostedzone/Z10116603TTQ6VNVFWSZL zoneName=failover.test.
```

## フェイルオーバーのテスト

### 1. 通常時の動作確認

まず、`dig`で`www.failover.test`のIPアドレスを確認します：

```bash
dig @ns-1413.awsdns-48.org +noall +answer -t A www.failover.test
#   ^^^^^^^^^^^^^^^^^^^^^^ この部分はRoute53のネームサーバーに合わせて変更してください
```

上で確認したIPアドレスにアクセスして、Primary ALBにアクセスできることを確認します：

```bash
http 18.178.70.206  # digで確認したIPアドレスを使用
```

次のように「This is PRIMARY ALB」というレスポンスが返ってくるはずです：

```http
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 19

This is PRIMARY ALB
```

### 2. フェイルオーバーのテスト

フェイルオーバーをテストするためにPrimary ALBを意図的に異常状態にします：

```bash
# Primary ALBのステータスを503に変更
aws elbv2 modify-listener \
  --listener-arn $(terraform output -raw primary_listener_arn) \
  --default-actions '[{"Type": "fixed-response","FixedResponseConfig": {"MessageBody": "Service Unavailable","StatusCode": "503","ContentType": "text/plain"}}]'
```

```bash
http $(terraform output -raw primary_alb_dns)
```

次のように「Service Unavailable」というレスポンスが返ってくるはずです：

```http
HTTP/1.1 503 Service Temporarily Unavailable
Content-Type: text/plain
Content-Length: 19

Service Unavailable
```

### 3. フェイルオーバーの確認

AWSのコンソールでRoute 53の「Health checks」を確認して、「Status」が「Unhealthy」になっていることを確認します。Unhealthyになるまで数十秒かかることがあります。

Unhealthyになったら、`dig`で`www.failover.test`のIPアドレスを確認します。

```bash
dig @ns-1413.awsdns-48.org +noall +answer -t A www.failover.test
#   ^^^^^^^^^^^^^^^^^^^^^^ この部分はRoute53のネームサーバーに合わせて変更してください
```

出力結果の例:

```
www.failover.test.      60      IN      A       54.150.184.236
www.failover.test.      60      IN      A       54.64.199.152
```

さっきとは違うIPアドレスが返ってくることを確認します。キャッシュが効いていて、古いIPアドレスが返ってくることがあるので、時間を置いてから確認してください。

続いて、そのIPアドレスにアクセスして、Secondary ALBにアクセスできることを確認します。

```bash
http 54.150.184.236  # digで確認した新しいIPアドレス
```

次のようにSecondary ALBからのレスポンスが返ってれば、フェイルオーバーが成功しています：

```http
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 21

This is SECONDARY ALB
```

### 4. 復旧テスト

Primary ALBを復旧させるために、`fixed-response`を元に戻します。

```bash
# Primary ALBを正常状態に戻す
aws elbv2 modify-listener \
  --listener-arn $(terraform output -raw primary_listener_arn) \
  --default-actions '[{"Type": "fixed-response","FixedResponseConfig": {"MessageBody": "This is PRIMARY ALB","StatusCode": "200","ContentType": "text/plain"}}]'
```

AWSのコンソールでRoute 53の「Health checks」を確認して、「Status」が「Healthy」になっていることを確認したら、`dig`で`www.failover.test`のIPアドレスを確認します。

```bash
dig @ns-1413.awsdns-48.org +noall +answer -t A www.failover.test
#   ^^^^^^^^^^^^^^^^^^^^^^ この部分はRoute53のネームサーバーに合わせて変更してください
```

Primary ALBのIPアドレスに戻っているはずです。


## クリーンアップ

検証が終わったら、以下の順序でリソースを削除します：

```bash
# 1. DNSレコードの削除
kubectl delete -f www.failover.test.yaml

# 2. external-dnsの削除
helm uninstall external-dns

# 3. AWSリソースの削除
terraform destroy
```

## まとめ

- external-dnsはAWS Route53のフェイルオーバーに完全対応
- 適切な設定で信頼性の高いフェイルオーバーが実現可能
- ヘルスチェックの作成は別途必要だが、運用は容易