Ingress - カスタムドメイン管理(external-dns)

| 18 min read
Author: noboru-kudo noboru-kudoの画像

前回までは、Ingressに登録したホスト名をHostヘッダを直接指定することで、DNSで名前解決がされた体で確認ました。
当然ですが、実運用でこのようなことをすることはなく、DNSサーバにIngressとのマッピングを追加する必要があります。
これを手動で実施すると、Ingressに新しいホストを追加する度に別途DNSで作業する必要が出てきます。DNSは設定ミスがあるとその被害は大きくなるのが通例です(それ故にネットワーク管理者のみがDNSにアクセス可能な組織が多いでしょう)。

今回はこれを自動化してしまいましょう。
このためのツールとしてKubernetesコミュニティ(Special Interest Groups:SIGs)で開発・運用されているexternal-dnsを利用します。

Alphaステータスのものが多いですが、対応するDNSプロバイダとしてはこちらで確認できるように様々なものがあります。
今回はAWSのDNSサービスであるRoute53を使用します。

また、今回は前提としてカスタムドメインを事前に用意する必要があります。
自分のドメインを持っていない場合は任意のものを準備してください。安いものであれば年間数百円で購入可能です。
本チュートリアルはAWSで実施するのでRoute53での取得をオススメしますが、以下のようなドメインレジストラでも構いません(動作は未検証です)。

Route53でのドメイン取得についてはこちらの公式ドキュメントを参照してください。
ここでは本サイト同様にmamezou-tech.com(Route53で購入)のサブドメインを使用します。

事前準備

#

以下のいずれかの方法で事前にEKS環境を作成しておいてください。

また、external-dnsのインストールにk8sパッケージマネージャーのhelmを利用します。未セットアップの場合はこちら を参考にv3以降のバージョンをセットアップしてください。

次にIngress Controllerをインストールします。
以下のいずれかをインストールしてください。以降はAWS Load Balancer Controllerをインストールしたものとして記載していますが、NGINXでもIngressClassNameの指定以外は変わりません。

external-dnsのアクセス許可設定

#

external-dnsが、Route53に対してレコード操作ができるようにIAM PolicyとIAM Roleを作成します。
必要なアクセス許可は以下に記載されています。

https://github.com/kubernetes-sigs/external-dns/blob/master/docs/tutorials/aws.md#iam-policy

ここでは、上記をJSONファイル(external-dns-policy.json)として保存して利用します。

eksctl

#

環境構築にeksctlを利用している場合はIngress Controllerセットアップ同様にeksctlのサブコマンドを利用します。
今回もIRSAを利用しますので、EKSのOIDCは有効化しておいてください(eksctl utils associate-iam-oidc-provider)。

まずはexternal-dnsで使用するカスタムポリシーを作成します。
以下のコマンドで先程作成したIAM PolicyのJSONファイルを引数として作成しましょう(マネジメントコンソールから作成しても構いません)。

aws iam create-policy \
    --policy-name ExternalDNSRecordSetChange \
    --policy-document file://external-dns-policy.json

次に作成したポリシーに対応するIAM Role/k8s ServiceAccountを作成します。
これについてはeksctlのサブコマンドで作成します。

eksctl create iamserviceaccount \
  --cluster=mz-k8s \
  --namespace=external-dns \
  --name=external-dns \
  --attach-policy-arn=arn:aws:iam::xxxxxxxxxxxx:policy/ExternalDNSRecordSetChange \
  --approve

これを実行すると、eksctlがCloudFormationスタックを実行し、AWS上にIAM Role、k8s上に対応するServiceAccountが作成されます。
こちらについても、マネジメントコンソールで確認してみましょう。

  • CloudFormation
  • IAM Role

ServiceAccountについては、kubectlで確認します[1]

kubectl get sa external-dns -n external-dns -o yaml
# 必要部分のみ抜粋・整形
apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::xxxxxxxxxxxx:role/eksctl-mz-k8s-addon-iamserviceaccount-extern-Role1-2P413LKUVE98
  labels:
    app.kubernetes.io/managed-by: eksctl
  name: external-dns
  namespace: external-dns
secrets:
  - name: external-dns-token-4f7s4

annotationsに上記IAM RoleのARNが指定されていることが分かります。

Terraform

#

環境構築にTerraformを利用している場合は、main.tfに以下の定義を追加してください。

resource "aws_iam_policy" "external_dns" {
  name = "ExternalDNSRecordSetChange"
  policy = file("${path.module}/external-dns-policy.json")
}

resource "kubernetes_namespace" "external_dns" {
  metadata {
    name = "external-dns"
  }
}

module "external_dns" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-assumable-role-with-oidc"
  version = "~> 4.0"
  create_role                   = true
  role_name                     = "EKSExternalDNS"
  provider_url                  = replace(module.eks.cluster_oidc_issuer_url, "https://", "")
  role_policy_arns              = [aws_iam_policy.external_dns.arn]
  oidc_fully_qualified_subjects = ["system:serviceaccount:${kubernetes_namespace.external_dns.metadata[0].name}:external-dns"]
}

resource "kubernetes_service_account" "external_dns" {
  metadata {
    name = "external-dns"
    namespace = kubernetes_namespace.external_dns.metadata[0].name
    annotations = {
      "eks.amazonaws.com/role-arn" = module.external_dns.iam_role_arn
    }
  }
}

以下のことをしています。

  • JSONファイルよりIAM Policyを作成し、external-dnsがRoute53の更新をできるようにカスタムポリシーを作成
  • k8s上にexternal-dnsというNamespaceを作成
  • external-dnsが利用するIAM Roleを作成(EKSのOIDCプロバイダ経由でk8sのServiceAccountが引受可能)し、上記カスタムポリシーを指定
  • k8s上にServiceAccountを作成して上記IAM Roleと紐付け

これをAWS/k8sクラスタ環境に適用します。

# module初期化
terraform init
# 追加内容チェック
terraform plan
# AWS/EKSに変更適用
terraform apply

反映が完了したらマネジメントコンソールで確認してみましょう。
IAM Role/Policyは以下のようになります。

IAM Role/Policyが問題なく作成されています。

次にk8sのServiceAccountは以下のように確認できます。

kubectl get sa external-dns -n external-dns -o yaml
# 必要部分のみ抜粋・整形
apiVersion: v1
automountServiceAccountToken: true
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::xxxxxxxxxxxx:role/EKSExternalDNS
  name: external-dns
  namespace: external-dns
secrets:
- name: external-dns-token-nt5fl

指定したIAM RoleでServiceAccountリソースがNamespaceexternal-dnsに作成されていることが確認できます。

DNSホストゾーンの登録

#

external-dnsが管理するホストゾーンをRoute53に登録します。

Route53で購入した場合は、既にそのドメインでホストゾーンが作成されていますので、新たに作成する必要はありません[2]。この手順はスキップ可能です。

Route53で購入していない場合は、マネジメントコンソールより「Route53 -> ホストゾーンの作成」を選択して自分で用意したドメインを入力・作成してください。
タイプはデフォルトの「パブリックホストゾーン」のままにしてください。

AWS CLIの場合は以下で作成可能ですが、Route53へのアクセスができるIAMユーザーで実施してください[3]

aws route53 create-hosted-zone \
  --name "xxxxxxxx.xxx" --caller-reference "k8s-tutorial-$(date +%s)"

次に、ドメインレジストラ側で、作成したRoute53のホストゾーンに名前解決を移譲するように変更します。
ドメインレジストラで用意されている管理ページで、このホストゾーンに割り当てられたネームサーバーを利用するように設定をしてください。
指定するネームサーバーは、マネジメントコンソールのNSレコードの内容から確認できます。

例としてGoogle Domainsで購入したものは、以下のようにカスタムネームサーバーとして上記内容を設定します。

external-dnsインストール

#

それでは準備が整いましたので、今回主役のexternal-dnsをセットアップしましょう。
以下にHelm Chartが準備されていますので、今回もHelmを使ってインストールします。

いつものようにリポジトリを追加・更新します。

helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update

以下のパラメータでexternal-dnsをインストールします。以下は現時点で最新の1.9.0のHelm Chartを利用しています。

helm upgrade external-dns external-dns/external-dns \
  --install --version 1.9.0 \
  --namespace external-dns \
  --set provider=aws \
  --set aws.region=ap-northeast-1 \
  --set aws.zoneType=public \
  --set serviceAccount.create=false \
  --set serviceAccount.name=external-dns \
  --set domainFilters[0]=mamezou-tech.com \
  --wait
  • --namespaceでexternal-dnsをインストールするのは事前に作成したnamespaceを指定
  • providerはRoute53を利用するためawsを指定
  • aws.regionは東京リージョン(ap-northeast-1)を指定。使っているリージョンが異なる場合は変更してください。
  • aws.zoneTypeは外部公開のpublicを指定
  • serviceAccount/serviceAccount.nameは事前に作成したものを指定
  • domainFilters[0]で対象とするドメイン(ホストゾーン)を指定。自分で用意したドメインに変更してください。

external-dnsには、その他にも多数のパラメータが用意されています。必要に応じて追加してください。
利用可能なパラメータはこちらを参照してください。

デプロイが正常に終了しているかを確認しましょう。
今回はexternal-dnsNamespaceに配置していますので、以下のコマンドで確認できます。

kubectl get deploy,svc,pod -n external-dns
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/external-dns   1/1     1            1           44s

NAME                   TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/external-dns   ClusterIP   10.100.72.190   <none>        7979/TCP   44s

NAME                                READY   STATUS    RESTARTS   AGE
pod/external-dns-6d5f46b99d-9zhl9   1/1     Running   0          43s

1つのPod(デフォルト)でexternal-dnsが稼働中となっていることが分かります。
ログについても確認してみましょう。

kubectl logs deploy/external-dns -n external-dns

以下抜粋です。

time="2021-10-20T02:48:01Z" level=info msg="Instantiating new Kubernetes client"
time="2021-10-20T02:48:01Z" level=info msg="Using inCluster-config based on serviceaccount-token"
time="2021-10-20T02:48:01Z" level=info msg="Created Kubernetes client https://10.100.0.1:443"
time="2021-10-20T02:48:09Z" level=info msg="Applying provider record filter for domains: [mamezou-tech.com. .mamezou-tech.com.]"
time="2021-10-20T02:48:09Z" level=info msg="All records are already up to date"

external-dnsが起動して、Route53とクラスタ環境を監視している様子が分かります。

サンプルアプリのデプロイ

#

external-dnsを確認するためのサンプルアプリをデプロイしましょう。
こちらと同じですが再掲します。

# サンプルアプリスクリプト
apiVersion: v1
kind: ConfigMap
metadata:
  name: server
data:
  index.js: |
    const http = require('http');

    const server = http.createServer((req, res) => {
      res.statusCode = 200;
      res.setHeader('Content-Type', 'text/plain');
      res.end(`${process.env.POD_NAME}: hello sample app!\n`);
    });

    const hostname = '0.0.0.0';
    const port = 8080;
    server.listen(port, hostname, () => {
      console.log(`Server running at http://${hostname}:${port}/`);
    });
---
# 1つ目のアプリ
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app1
spec:
  replicas: 2
  selector:
    matchLabels:
      app: app1
  template:
    metadata:
      labels:
        app: app1
    spec:
      containers:
        - name: app1
          image: node:16
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
          command: [sh, -c, "node /opt/server/index.js"]
          volumeMounts:
            - mountPath: /opt/server
              name: server
      volumes:
        - name: server
          configMap:
            name: server
---
# 2つ目のアプリ
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app2
spec:
  replicas: 2
  selector:
    matchLabels:
      app: app2
  template:
    metadata:
      labels:
        app: app2
    spec:
      containers:
        - name: app2
          image: node:16
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
          command: [sh, -c, "node /opt/server/index.js"]
          volumeMounts:
            - mountPath: /opt/server
              name: server
      volumes:
        - name: server
          configMap:
            name: server
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: app1
  name: app1
spec:
  type: NodePort
  selector:
    app: app1
  ports:
    - targetPort: http
      port: 80
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: app2
  name: app2
spec:
  type: NodePort
  selector:
    app: app2
  ports:
    - targetPort: http
      port: 80

AWS Load Balancer ControllerはNodePortを使う点に注意してください。
こちらでデプロイします。

kubectl apply -f app.yaml

いつものようにデプロイ後はアプリの状態を確認しましょう。

kubectl get cm,deployment,pod,svc
# 必要部分のみ抜粋
NAME                         DATA   AGE
configmap/server             1      100s

NAME                   READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/app1   2/2     2            2           100s
deployment.apps/app2   2/2     2            2           100s

NAME                        READY   STATUS    RESTARTS   AGE
pod/app1-7ff67dc549-gz48k   1/1     Running   0          100s
pod/app1-7ff67dc549-jxflp   1/1     Running   0          100s
pod/app2-b6dc558b5-5zb69    1/1     Running   0          100s
pod/app2-b6dc558b5-6nksz    1/1     Running   0          100s

NAME                 TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
service/app1         NodePort    172.20.135.3    <none>        80:31391/TCP   100s
service/app2         NodePort    172.20.122.30   <none>        80:31670/TCP   100s

これでアプリの準備は完了です。

Ingressリソース作成

#

それではこれに対応するIngressリソースを作成しましょう。
基本的にはAWS Load Balancer Controllerの環境構築時と同じです。

以下のようになります。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app-aws-ingress
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    # external-dnsにRoute53への登録を指示
    external-dns.alpha.kubernetes.io/hostname: k8s-tutorial.mamezou-tech.com
spec:
  ingressClassName: aws
  rules:
    - host: k8s-tutorial.mamezou-tech.com
      http:
        paths:
          # app1へのルーティングルール
          - backend:
              service:
                name: app1
                port:
                  number: 80
            path: /app1
            pathType: Prefix
          # app2へのルーティングルール
          - backend:
              service:
                name: app2
                port:
                  number: 80
            path: /app2
            pathType: Prefix

annotations部分に注目してください。
external-dns.alpha.kubernetes.io/hostnameに、ルーティングルールのhostで指定したカスタムドメインを設定しています(複数の場合はカンマ区切り)。こちらは自分で取得したドメインに置き換えてください。
external-dnsはこれを検知してRoute53と同期します。

なお、NGINX Ingress Controllerを使用する場合はingressClassNamenginxと指定してください。

これをk8sに反映しましょう。

kubectl apply -f ingress.yaml

反映が終わったら、まずはIngressリソースを確認してみましょう。

kubectl describe ing app-aws-ingress
Name:             app-aws-ingress
Namespace:        default
Address:          k8s-default-appawsin-xxxxxxxxx-xxxxxxxxxxxx.ap-northeast-1.elb.amazonaws.com
Default backend:  default-http-backend:80 (<error: endpoints "default-http-backend" not found>)
Rules:
  Host                           Path  Backends
  ----                           ----  --------
  k8s-tutorial.mamezou-tech.com  
                                 /app1   app1:80 (10.0.1.55:8080,10.0.2.177:8080)
                                 /app2   app2:80 (10.0.1.114:8080,10.0.2.131:8080)
Annotations:                     alb.ingress.kubernetes.io/scheme: internet-facing
                                 external-dns.alpha.kubernetes.io/hostname: k8s-tutorial.mamezou-tech.com
Events:
  Type    Reason                  Age   From     Message
  ----    ------                  ----  ----     -------
  Normal  SuccessfullyReconciled  53s   ingress  Successfully reconciled

前回と同じように、Addressに外部公開URL(AWSで自動生成されたもの)が割り当てられ、バックエンドとしてapp1/app2が設定されています。
また、Annotationsにexternal-dnsのドメインが指定されていることも確認できます。

AWSのマネジメントコンソールからRoute53の状態を見てみましょう。サービスからRoute53を選択し、自分のドメインのホストゾーンに登録されているレコードを見てみましょう。

Aレコードが作成され、これがIngress(実態はALB)に対してマッピングされている様子が分かります。
Aレコードが作成されていない場合は、external-dnsのログを確認してみましょう。以下のようにexternal-logのPodより確認可能です。

kubectl logs deploy/external-dns -n external-dns

正常に終了していれば以下のような出力が確認できます。

time="2021-10-17T07:09:38Z" level=info msg="Applying provider record filter for domains: [mamezou-tech.com. .mamezou-tech.com.]"
time="2021-10-17T07:09:38Z" level=info msg="Desired change: UPSERT k8s-tutorial.mamezou-tech.com A [Id: /hostedzone/XXXXXXXXXXXXXXXXXXXXX]"
time="2021-10-17T07:09:38Z" level=info msg="Desired change: UPSERT k8s-tutorial.mamezou-tech.com TXT [Id: /hostedzone/XXXXXXXXXXXXXXXXXXXXX]"
time="2021-10-17T07:09:39Z" level=info msg="2 record(s) in zone mamezou-tech.com. [Id: /hostedzone/XXXXXXXXXXXXXXXXXXXXX] were successfully updated"

レコード追加が完了したら、実際に名前解決ができるのかをdigコマンドで確認します。Windowsの場合はnslookupコマンドで代用してください。

# ドメインは自身で取得したものに置き換えてください
dig k8s-tutorial.mamezou-tech.com
; <<>> DiG 9.10.6 <<>> k8s-tutorial.mamezou-tech.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41319
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;k8s-tutorial.mamezou-tech.com.	IN	A

;; ANSWER SECTION:
k8s-tutorial.mamezou-tech.com. 60 IN	A	xxx.xxx.xxx.xxx
k8s-tutorial.mamezou-tech.com. 60 IN	A	xxx.xxx.xxx.xxx
k8s-tutorial.mamezou-tech.com. 60 IN	A	xxx.xxx.xxx.xxx

;; Query time: 421 msec
;; SERVER: 192.168.10.1#53(192.168.10.1)
;; WHEN: Sun Oct 17 16:16:09 JST 2021
;; MSG SIZE  rcvd: 106

ANSWER SECTIONでAレコードが確認できました。DNSは全世界に伝播されるまでしばらく時間がかかります(特にRoute53以外でドメイン取得した場合は数時間かかることもあります)。

動作確認

#

最後にアプリへアクセスしてみましょう。
前回までは、curlでエンドポイントはAWSで自動生成されたものを使い、HostヘッダにIngressのホスト名を指定していましたが、今回はDNS設定をしましたので不要です。

# ドメインは、自身で取得したものに置き換えてください。
# app1
curl k8s-tutorial.mamezou-tech.com/app1
# app2
curl k8s-tutorial.mamezou-tech.com/app2
app1-7ff67dc549-gz48k: hello sample app!
app2-b6dc558b5-5zb69: hello sample app!

カスタムドメインのみでアクセスできていることが分かります。

クリーンアップ

#

各リソースは以下の手順で削除します。

# app1/app2
kubectl delete -f app.yaml
# Ingress -> ALBリソース削除
kubectl delete -f ingress.yaml
# ALBが削除されたことを確認後にAWS Load Balancer Controller/external-dnsをアンインストール
helm uninstall -n external-dns external-dns
helm uninstall -n kube-system aws-load-balancer-controller

また、external-dnsはデフォルトでRoute53のレコードを削除しません[4]
マネジメントコンソールから不要になったレコード(A/Txt)は手動で削除しておきましょう(誤って利用中のものを削除しないよう注意してください)。

最後にクラスタ環境を削除します。こちらは環境構築編のクリーンアップ手順を参照してください。


参照資料

external-dnsドキュメント: https://github.com/kubernetes-sigs/external-dns


更新情報

  • 2022-06-04: External DNSのHelmチャートレポジトリ変更(bitnami -> kubernetes-sigs)に伴ってインストール方法を最新化しました。

  1. Namespaceについては、存在しない場合はeksctlが作成してくれます。 ↩︎

  2. とはいえ複数のAWSアカウントで管理する環境ですと環境ごとにサブドメインでホストゾーンを作成し、親ドメイン側(マスターアカウント)はサブドメイン側のホストゾーンに移譲する方法がよく使われると思います。 ↩︎

  3. 環境構築時に作成したterraformIAM ユーザーは該当操作のパーミッションはありませんので別途ポリシーの追加が必要です。 ↩︎

  4. helmインストール時にpolicysyncとすれば削除可能です。 ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。