[EKS] [Terraform] external-dnsでマニフェストからレコード登録をする

## What's This

external-dnsを使用すればマニフェストに登録したいレコードを書くだけで、
自動でレコードとIngressのURLを紐づけてくれ、Route53にもレコードを登録してくれます。

今回はterraformを使用してAWS側で設定する部分を実現しました。
先人が残してくれた記事を大いに参考にしており、n番煎じかという内容かもしれませんが、先人たちと異なる実装部分もあるかと思い、まとめました。

## 構成

Serviceにexternal-dnsを使用している例も多いかと思いますが、今回の例ではIngressに対して使用しております。

## Terraform側で用意するもの

kubectl_manifest リソースを使用して、TF内でk8sのリソースを定義しました。
WAFやSecurity Group、ACMなどを使うのであれば、それを変数として渡す方が組み立てが簡単になるかと思います。
k8sとTFの作業の境界がここだけ曖昧になるのが難点ですが…

##### eks.tf
```terraform 
resource "kubectl_manifest" "ingress_yaml" {
  yaml_body = <<-EOF
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  namespace: default
  name: ${var.env}-ingress
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: instance
    alb.ingress.kubernetes.io/wafv2-acl-arn: ${data.terraform_remote_state.waf_acmのoutputs} // WAF-ARN
    alb.ingress.kubernetes.io/security-groups: ${aws_security_group.ingressのsg.id} // SG-ARN
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'
    alb.ingress.kubernetes.io/certificate-arn: ACMのARN
    alb.ingress.kubernetes.io/ssl-redirect: "443"
    external-dns.alpha.kubernetes.io/hostname: "${var.env}-domain name" // 使用したいレコードの名前
spec:
  rules:
    - host: ${var.env}-domain name　// 使用したいレコードの名前
      http:
        paths:
        - path: /
          pathType: Prefix
          backend:
            service:
              name: service
              port:
                number: 80

    EOF
}

// remote_stateを使っている場合、以下の記載も必要

data "terraform_remote_state" "waf_acm" {
  backend = "s3"
  config = {
    bucket = "バケット名"
    key    = "tfstateの名前"
    region = "リージョン"
  }
}
```

##### .tfvars

なお、別途tfvarsで以下のように変数を定義しています。
これで環境ごとに名前付けをしてくれるので、上記で${var.env}で分けています。

```
env          = "環境名"
```

##### external_dns.tf
```terraform
resource "kubernetes_service_account" "external_dns" {
  metadata {
    name      = "external-dns"
    namespace = "default"
    annotations = {
      "eks.amazonaws.com/role-arn" : "${aws_iam_role.external_dns_role.arn}"
    }
  }
}

resource "kubernetes_cluster_role" "external_dns" {
  metadata {
    name = "${var.env}-external-dns"
  }
  rule {
    api_groups = [""]
    resources  = ["services", "endpoints", "pods"]
    verbs      = ["get", "watch", "list"]
  }
  rule {
    api_groups = ["extensions", "networking.k8s.io"]
    resources  = ["ingresses"]
    verbs      = ["get", "watch", "list"]
  }
  rule {
    api_groups = [""]
    resources  = ["nodes"]
    verbs      = ["list", "watch"]
  }
}
resource "kubernetes_cluster_role_binding" "external_dns_viewer" {
  metadata {
    name = "${var.env}-external-dns-viewer"
  }
  role_ref {
    api_group = "rbac.authorization.k8s.io"
    kind      = "ClusterRole"
    name      = kubernetes_cluster_role.external_dns.metadata[0].name
  }
  subject {
    kind      = "ServiceAccount"
    name      = kubernetes_service_account.external_dns.metadata[0].name
    namespace = "default"
  }
}

data "aws_iam_policy_document" "external_dns_assume_role_policy" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    effect  = "Allow"

    condition {
      test     = "StringEquals"
      variable = "${replace(module.cluster.oidc_provider, "https://", "")}:sub"
      values   = ["system:serviceaccount:default:external-dns"]
    }

    principals {
      identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/${module.cluster.oidc_provider}"]
      type        = "Federated"
    }
  }
}

resource "aws_iam_role" "external_dns_role" {
  name               = "${var.env}-external-dns-role"
  assume_role_policy = data.aws_iam_policy_document.external_dns_assume_role_policy.json
}

resource "aws_iam_role_policy_attachment" "external_dns_policy_attachment" {
  policy_arn = aws_iam_policy.external_dns_policy.arn
  role       = aws_iam_role.external_dns_role.name
}

resource "aws_iam_policy" "external_dns_policy" {
  name   = "${var.env}-external-dns-policy"
  policy = file("external_dns.jsonのPATH")
}
```

IAM Policy 作成して、Roleにアタッチしています。PolicyのJsonは別ファイルで分けています。
Roleに環境名をつけて、それをサービスアカウントに紐づけています。

##### external_dns.json
```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "route53:ChangeResourceRecordSets"
            ],
            "Resource": [
                "arn:aws:route53:::hostedzone/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "route53:ListHostedZones",
                "route53:ListResourceRecordSets"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

```

## Kubernetes側で用意するもの

##### external_dns.yaml

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
spec:
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: external-dns
  template:
    metadata:
      labels:
        app: external-dns
    spec:
      serviceAccountName: external-dns
      containers:
      - name: external-dns
        image: k8s.gcr.io/external-dns/external-dns:v0.10.2
        args:
        - --source=ingress
        - --domain-filter=ホストゾーンの名前 # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones
        - --provider=aws
        - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization
        - --aws-zone-type=public 
        - --registry=txt
        - --txt-owner-id=ホストゾーンのID
      securityContext:
        fsGroup: 65534 # For ExternalDNS to be able to read Kubernetes and AWS token files

```

serviceAccountName:で、TFで作成したサービスアカウントの名前を記載しています。

## 適用の順序

今回の設計構成上、ちょっと面倒な感じですが一応書きます。

1.terraform applyする

2.external-dns.yamlをデプロイする

3.Ingressを削除し、もう一度デプロイする

4.external-dnsがこんなログを出していて、Route53上でレコードが登録されていたら成功

```
kubectl logs "external-dns Podの名前" -f

~中略~

time="hogehoge" level=info msg="Applying provider record filter for domains: [”ホストゾーンの名前"]"
time="hogehoge" level=info msg="Desired change: CREATE "使用したいレコードの名前" A [Id: /hostedzone/"ホストゾーンのID"]"
time="hogehoge" level=info msg="Desired change: CREATE "使用したいレコードの名前" TXT [Id: /hostedzone/"ホストゾーンのID"]"
time="hogehoge" level=info msg="2 record(s) in zone ”ホストゾーンの名前". [Id: /hostedzone/"ホストゾーンのID"] were successfully updated"

curl https://"使用したいレコードの名前

で期待通りの反応が返ってくるか確認
```

## 備考

遭遇したエラーと解決についても記載しておきます。

external-dns Podが正常に立ち上がらず、Restartを繰り返していました。

```
kubectl get pod | grep dns
external-dns-hogehoge                  0/1     CrashLoopBackOff   38         3h32m

kubectl logs external-dns-hogehoge -f

~中略~
time="hogehoge" level=fatal msg="failed to sync *v1.Ingress: context deadline exceeded"
```

以下参考にして、サービスアカウントとClusterRoleBindingが問題ありそうだなと考えました。

https://github.com/kubernetes-sigs/external-dns/issues/2407
https://stackoverflow.com/questions/71407994/kubernetes-error-context-deadline-exceeded

なので、両方が同じ名前空間にあることを修正、確認しました。

```terraform
resource "kubernetes_service_account" "external_dns" {
  metadata {
    name      = "external-dns"
    namespace = "default" // ココ
    annotations = {
      "eks.amazonaws.com/role-arn" : "${aws_iam_role.external_dns_role.arn}"
    }
  }
}

resource "kubernetes_cluster_role_binding" "external_dns_viewer" {
  metadata {
    name = "${var.env}-external-dns-viewer"
  }
  role_ref {
    api_group = "rbac.authorization.k8s.io"
    kind      = "ClusterRole"
    name      = kubernetes_cluster_role.external_dns.metadata[0].name
  }
  subject {
    kind      = "ServiceAccount"
    name      = kubernetes_service_account.external_dns.metadata[0].name
    namespace = "default" // ココ
  }
}
```

後は、--domain-filterを、ホストゾーンの名前ではなく、使用したいレコードの名前で記載していたので、修正しました。

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
spec:
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: external-dns
  template:
    metadata:
      labels:
        app: external-dns
    spec:
      serviceAccountName: external-dns
      containers:
      - name: external-dns
        image: k8s.gcr.io/external-dns/external-dns:v0.10.2
        args:
        - --source=ingress
        - --domain-filter=ホストゾーンの名前 // ココ
        - --provider=aws
        - --policy=upsert-only 
        - --aws-zone-type=public 
        - --registry=txt
        - --txt-owner-id=ホストゾーンのID
      securityContext:
        fsGroup: 65534 
```

すると解決しました。

## Reference 

・https://aws.amazon.com/jp/premiumsupport/knowledge-center/eks-set-up-externaldns/
・https://github.com/kubernetes-sigs/external-dns
・https://qiita.com/guile/items/d41399f579d1a62634c0
・https://developer.mamezou-tech.com/containers/k8s/tutorial/ingress/external-dns/
・https://tech.polyconseil.fr/external-dns-helm-terraform.html
・https://febc-yamamoto.hatenablog.jp/entry/2018/12/28/172830
・社内有識者

参考にさせていただきました。ありがとうございます。