1クール続けるブログ

とりあえず1クール続けるエンジニアの備忘録

external-dnsの仕組みを少し覗いてみる

記事一覧はこちら

背景・モチベーション

EKS(or GKE)を運用するときにお世話になる external-dns をふんわりとだけ理解て利用してきたのですが、どのようにDNSレコードを操作しているのかをちゃんと理解したいと思ったのがきっかけです。

使い始めた当初はなかった、Route53のルーティングポリシーのサポートなどが加わったりと順調に進化しているように見える、このプロダクトを少し紐解いてみようと思います。

external-dnsとは

github.com

KubernetesのリソースをPublicなDNSサーバを利用して公開してくれます。 kubednsのように、公開されたServiceやIngressKubernetes APIを利用して列挙し、desiredなDNSレコードのリストを作成し、DNSプロバイダと同期します。

DNS プロバイダによって安定レベルが異なります。レベルはStable, Beta, Alphaに分けられますが、Stableになっているのは、 Google Cloud DNSAWS Route 53 のみです(参考)。

EKSでの導入方法

Route53の手順はこちらに記載されている。

下記のようにIAM Policyの作成して、EKSでOIDCプロバイダの機能を利用してk8sのサービスアカウント単位で権限を付与します。 IAM Policy はサンプルのままだと権限が強すぎるので、下記のようにホストゾーンを指定して権限を付与してあげるのが良さそう。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "route53:ChangeResourceRecordSets"
      ],
      "Resource": [
        "arn:aws:route53:::hostedzone/ABCD1234EFG567",
        "arn:aws:route53:::hostedzone/1234ABCD567EFG",
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "route53:ListHostedZones",
        "route53:ListResourceRecordSets"
      ],
      "Resource": [
        "*"
      ]
    }
  ]
}

サービスアカウント external-dns に権限が付与できたら、マニフェストをデプロイする。チュートリアルで明示されている引数は下記です。 コマンド引数の説明を見るときに一番簡単なのは、 docker run k8s.gcr.io/external-dns/external-dns:v0.7.6 --help です。 コマンドオプションはproviderごとにもあるので、数がかなり多いのですが、AWS適用例で指定している引数に関しては記載してみました。

Required

  • --source : エンドポイントを提供するリソース名(service, ingress, node, istio-gateway等から1つ以上選択)
  • --provider : DNSレコードが作成されるDNSプロバイダ(aws, google, azure等から1つ選択)

Optional

  • --policy : sourceとprovider間を同期させる方針、デフォルトは syncsync はレコードの削除も行うので万全を期すなら、 upsert-onlycreate-only を選択する。
  • --domain-filter : ドメインのsuffixで制限をかける
  • --aws-zone-type : ゾーンのタイプで制限をかける(public, privateから選択)
  • --registry : DNSレコードの所有権を追跡する利用する実装、デフォルトではtxtレコードに情報を書き込む(txt, noop, aws-sdから選択)
  • --txt-owner-id : txt regisryを利用しているときにこのexternal-dnsからの更新であることを認識させるためのID、デフォルトでは default という文字列になる
 # マニフェストから一部を抜粋
    containers:
      - name: external-dns
        image: k8s.gcr.io/external-dns/external-dns:v0.7.6
        args:
        - --source=service
        - --source=ingress
        - --domain-filter=external-dns-test.my-org.com # 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 # only look at public hosted zones (valid values are public, private or no value for both)
        - --registry=txt
        - --txt-owner-id=my-hostedzone-identifier

あとは、ServiceリソースやIngressリソースをexternal-dnsから認識させてあげればOKです。Ingressリソースは、.spec.rule.hosts で宣言しておけば認識されます。また、 external-dns.alpha.kubernetes.io/hostnameアノテーションを利用することで、ServiceとIngressどちらでも紐づけることが可能です。

アノテーションにはいくつか種類があり、例えばRoute53のRouting Policyが使えたり、TTLを指定出来たりします。

apiVersion: v1
kind: Ingress
metadata:
  name: nginx
  annotations:
    external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com
    external-dns.alpha.kubernetes.io/ttl: 60
    # set-identifierはルーティングポリシーを利用する上で必須の設定項目
    external-dns.alpha.kubernetes.io/set-identifier: default
    external-dns.alpha.kubernetes.io/aws-weight: "30"
spec: {} # 省略

Route53を更新するまでの処理を追う

エントリポイント

external-dnsは起動後に設定を読み込んだ後は、設定したInterval(デフォルト1分)に従い、処理を定期実行しています(該当コード)。 Tickerを使って、処理時間を除いて1秒毎に時間のチェックをしているのは、自前で定期処理を作成として参考になるかも(処理を含めて定期処理であればcronのライブラリで良いと思うけど)。

// Run runs RunOnce in a loop with a delay until context is canceled
func (c *Controller) Run(ctx context.Context) {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()
    for {
        if c.ShouldRunOnce(time.Now()) {
            if err := c.RunOnce(ctx); err != nil {
                log.Error(err)
            }
        }
        select {
        case <-ticker.C: // ループが最短でも1秒ごとの処理になるように
        case <-ctx.Done():
            log.Info("Terminating main controller loop")
            return
        }
    }
}

定期的に行っている更新処理の流れ

この中で行っている、 RunOnce(ctx context.Context) がメインの処理となります(該当コード)。 流れを箇条書きにすると下記のようになります。

  • Route53に存在するDNSレコードを列挙します
    • まずHostedZoneを列挙します(該当コード
      • すべてのHostedZoneを取得します
        • クエリ結果はキャッシュされます
        • キャッシュの生存期間を過ぎていなければキャッシュから返します
      • 起動引数に設定した、ZoneやDomainのフィルタを使って絞り込みます
    • ZoneごとにDNSレコードの情報を取得します(該当コード
      • Endpoint というドメインオブジェクトに詰め替えます
      • ルーティングポリシーがあれば、 Endpoint.ProviderSpecific というフィールドに突っ込みます
      • (Go SDKのRoute53の実装って引数に関数渡すケースが多いんだ…知らなかった。アウトプットは変数をキャプチャしてそこに詰める感じになってる)
  • k8sのsource(例えば、serviceやingressリソース)からエンドポイントを列挙します
  • 1つ前の処理で作成した望んだ状態に、実際の状態を同期させるために、必要なアクションを計算します
    • keyが文字列の2次元HashMapを作成します、1つ目のKeyがDNS名で2つ目のkeyが SetIdentifier となっています。Valueは現在の状態と望んだ状態を持つ planTableRow構造体です。
    • Mapにそれぞれ currentState と desiredStateを書き込んでいきます。
    • もし、desiredStateとして挙げられたDNS名が既に存在している場合には、そのコンフリクトを解決する
    • 起動引数に設定したPolicy( upsert 等)に従い、アクションをフィルタする
  • 計算したアクションを実行する

まとめ

k8sの変更監視とGoのinterfaceの使い方がかなり勉強になりました。 k8sの周辺ツールは各クラウドプロバイダによる処理の違いを、interfaceとその実装で持たせることが多くて、コード読んでて面白いです。

DNSを扱うというデリケートな処理をどのように実装しているかを俯瞰することができてよかった!以上!