external-dnsのWebhookを調べて実装してみた

この記事は [富士通クラウドテクノロジーズ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/fjct) 18 日目の記事です。

昨日は @earth429 さんの [PrometheusのNode Exporterで取得した値とNodeのパフォーマンスを比較してみた](https://qiita.com/earth429/items/12a5ea9b9cb579ac9f79) でした。

Prometheus、 最初は PromQL がとっつきにくくてとても苦労した記憶があります。
ちなみに kube-prometheus-stack は社内 GitLab 運用でも使っていますがとても便利ですね！ただバージョンをちゃんと追従させていかないと、あとで一気にあげようとすると痛い目を見ます。。。(経験者 :innocent:)

皆さん。Kubernetes は活用されていますか？
先日、社内の Slack を眺めていたら、「external-dns をニフクラに対応させたいなぁ」みたいな投稿を見かけました。個人的に様々な Kubernetes のアドオンをニフクラに対応させてきたということもあって、 今回は external-dns をどうやったらニフクラに対応させられるのか？を調べて実装してみることにしました。

## external-dns とは

[external-dns](https://github.com/kubernetes-sigs/external-dns) は Kubernetes SIG で開発されているアドオンの一つで、 Kubernetes から **クラスター外の** DNS レコードを操作できるようにするものです。(クラスター内であれば何もしなくてもデフォルトで Service 作成時にレコードを登録してくれますね)

具体的にはアノテーションが付与された Service (`type: LoadBalancer`) や Ingress を external-dns が監視し、対象に応じて適宜外部の DNS サービス上にレコードを登録・更新・削除をしてくれるというもののようです。

Kubernetes 上の操作だけで DNS サービス上のレコードを操作できるようになると、さまざまな運用にも役立ちそうですね！

## external-dns がサポートする DNS プロバイダー

本日時点での最新版である v0.14.0 では、 

- Google Cloud DNS
- AWS Route 53
- AzureDNS

などの大手クラウドプロバイダーの DNS サービスを始め、様々なサービスが対応されているようです。 ([参照](https://github.com/kubernetes-sigs/external-dns/tree/v0.14.0?tab=readme-ov-file#the-latest-release))

## 対応プロバイダーの仲間入りをしたいけど...

さて、こうなると対応プロバイダーの一覧にニフクラも仲間入りしたいところですね！
しかし、直近のプロバイダー追加 PR (例えば[これ](https://github.com/kubernetes-sigs/external-dns/pull/3990)) を見てみると、

> もう新しいプロバイダーは追加しないよ！対応させたいなら Webhook を使ってね！

のようなコメントがされており、どうも新規で external-dns 本体にプロバイダー追加をすることは叶わなくなってしまったようです。。。

## external-dns の Webhook

ここで、コメントに書かれている Webhook とはなんのことなのでしょうか。コメントと一緒にリンクされている PR は下記です。

https://github.com/kubernetes-sigs/external-dns/pull/3063 

なるほど。どうもこれは [v0.14.0](https://github.com/kubernetes-sigs/external-dns/releases/tag/v0.14.0) でリリースされた機能で、DNS レコードを操作する処理を外部の HTTP API に移譲することができる仕組みのようです。

詳細は[ドキュメント](https://github.com/kubernetes-sigs/external-dns/blob/v0.14.0/docs/tutorials/webhook-provider.md)に記載されていますが、具体的には、下記のエンドポイントを持つ API を実装することで、 external-dns 本体にコードを追加することなく、対応するプロバイダーを増やせるということのようです。

* `GET /`
    * 対象のプロバイダーが管理しているゾーン情報を返却する
* `GET /records`
    * 登録されているレコード一覧を返却する
* `POST /adjustendpoints`
    * プロバイダ固有のレコード設定等があれば、情報を付け加えたり書き換えて返却する
    * 例えば AWS Route 53 プロバイダーでは ALIAS レコードに対する処理などがされている
        * https://github.com/kubernetes-sigs/external-dns/blob/v0.14.0/provider/aws/aws.go#L663
* `POST /records`
    * レコードの登録・更新・削除をする


## Webhook を実装するには

さて、 Webhook を実装すれば様々なプロバイダーに対応できそうなことはわかりました。では Webhook はどのように実装すればよいのでしょうか？

上述のとおり、特定のエンドポイントを持つ　　HTTP API を作成すればよいだけではあります。つまり、実装言語は何でも良いです。ですが、 external-dns にコミットされているコードを活用すると、かなり簡単に Webhook を作成できるようです。ただし、external-dns は Golang で書かれているため、この方法を使う場合は Golang 実装限定になります。

ここでは external-dns のコードを活用した方法に限定して話を進めていきます。


### Provider インターフェースを満たす実装を書く

まず、 [`provider.Provider`](https://github.com/kubernetes-sigs/external-dns/blob/v0.14.0/provider/provider.go#L29-L41) インターフェースを実装する struct を実装します。

ただし必ず実装しなければならないのは

- `Records(ctx context.Context) ([]*endpoint.Endpoint, error)`
- `ApplyChanges(ctx context.Context, changes *plan.Changes) error`

の 2 つだけで、

- `AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error)`
- `GetDomainFilter() endpoint.DomainFilter`

は動かすだけであれば特に実装せずに OK です。その場合は下記のように struct に [`provider.BaseProvider`](https://github.com/kubernetes-sigs/external-dns/blob/v0.14.0/provider/provider.go#L43-L51) を埋め込んでおくのが楽です。

```go
type nifcloudProvider struct {
    provider.BaseProvider
}
```

### HTTP サーバーを作成・起動する

さて、 `provider.Provider` インターフェースを満たす実装ができたら、その実装を [`webhook.StartHTTPApi`](https://github.com/kubernetes-sigs/external-dns/blob/v0.14.0/provider/webhook/httpapi.go#L112) に渡してあげましょう。

`StartHTTPApi` 関数は前述の Webhook が実装すべき HTTP API エンドポイントと `provider.Provider` の実装をマッピングし、 HTTP サーバーを起動してくれます。

具体的には下記のルーティングが設定されます。

- `GET /`: `GetDomainFilter`
- `GET /records`: `Records`
- `POST /records`: `ApplyChanges`
- `POST /adjustendpoints`' `AdjustEndpoints`

## ニフクラ向けの Webhook を書いてみた

というわけで、実装方針が見えたのでニフクラ DNS 向けの Webhook を書いてみました。
足りない実装が結構ありますが、さらっと試すくらいならこの実装で十分です。

https://github.com/aokumasan/external-dns-nifcloud-webhook

メインロジックである `provider.Provider` インターフェースを実装している箇所は https://github.com/aokumasan/external-dns-nifcloud-webhook/blob/main/internal/cloud/nifcloud.go あたりです。

## 試してみる

### external-dns-nifcloud-webhook のインストール

`example/webhook.yaml` を使えばインストールできるようにしてあります。 Secret の `nifcloud-secrets` は使用するニフクラアカウントのアクセスキーとシークレットキーに書き換えてください。(Helm chart 作ろうとしましたが、間に合いませんでした :pray: )

```sh
$ kubectl apply -f example/webhook.yaml
```

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

external-dns 本体を Helm でインストールします。

```sh
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm upgrade --install external-dns external-dns/external-dns \
  -n external-dns \
  --set 'image.tag=v0.14.0' \
  --set 'provider=webhook' \
  --set 'extraArgs={--webhook-provider-url=http://external-dns-nifcloud-webhook:8888}'
```

インストール時に下記の設定をしています。

- 2023/12/18　時点で最新版の external-dns の helm chart が `v0.13.6` だったため、 `image.tag` に webhook 対応版の `v0.14.0` を指定
- webhook を使うため、 `provider` に `webhook` を指定
- webhook の URL を external-dns に設定するため、 `externalArgs` に `webhook-provider-url=<webhook URL>` を指定

`kubectl -n external-dns get pods` して、 external-dns 本体と webhook の 2 つの Pod が Running になっていれば OK です。

```sh
$ kubectl -n external-dns get po
NAME                                             READY   STATUS    RESTARTS   AGE
external-dns-54986b5868-s7sjj                    1/1     Running   0          64s
external-dns-nifcloud-webhook-5bbcd9b64d-n45qt   1/1     Running   0          79s
```

### Ingress リソースを適当に作成してみる

今回はサンプルとして、 `Hello` と表示するだけの Web ページを Ingress 経由で公開してみようと思います。(※事前に ingress nginx を導入してあります。)

下記のマニフェストを apply します。

```yml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: echo
  template:
    metadata:
      labels:
        app: echo
    spec:
      containers:
        - name: app
          image: hashicorp/http-echo
          args:
            - -text=Hello
            - -listen=:8080
---
apiVersion: v1
kind: Service
metadata:
  name: echo-service
spec:
  selector:
    app: echo
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: echo-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
    - host: example.test.aokuma.net
      http:
        paths:
          - backend:
              service:
                name: echo-service
                port:
                  number: 80
            path: /
            pathType: Prefix
```

作成してしばらくすると、対応するレコードがニフクラ DNS に無事に登録されていました！！ :tada:

![スクリーンショット 2023-12-18 5.03.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/72278/44105921-3175-4aad-8068-20290c1e990b.png)

ちなみに現状は対象の Ingress オブジェクトを削除しても DNS レコードが削除されなくて、原因を調べてましたがちょっと時間切れでした。。。後日controllerのコードを読んで原因を探ろうと思います。

## さいごに

今回は external-dns にプロバイダーを追加する方法を探り、その中で Webhook 機能を調査し、実際にニフクラ DNS にレコードを登録できるところまで実装してみました。あくまでも調査目的のため簡単な実装しかしていませんが、 external-dns の動作を知るよいきっかけにはなったと思います。今後やる気が出てきたら、もっと厳密な実装をチャレンジしてみようと思います。

また、Kubernetes 関連のアドオンで Webhook に対応しているものというと、 [cert-manager](https://cert-manager.io/) なんかもあるのですが、 DNS サービスのようにプロバイダーがたくさん存在するようなものは今回のような webhook の仕組みがとても生きてくるような気がしますし、実装する側も本家にコントリビュートするよりも気楽に開発できてさらっと試す分には良いなと思いました。

明日は @sameshima_alt さんが「UbuntuでDAW環境を構築するためにN100ミニPCを買った話」という記事を書いてくれるみたいです。自分も N100 のミニ PC を使って NAS に貯めてある写真を Amazon Photos に同期する仕組みを作ってたりしますが、 N100 マシンはアイデア次第でいろいろ活用できて面白いですね！

※本記事の内容は 2023/12/18　(v0.14.0) 現在の情報になります。Webhook に関してはまだ公開されたばかりの機能であるため、今後仕様が大きく変更される可能性もあります。また、完全に動く実装までたどり着けなかったこともあり、理解が間違っている可能性もありますのでご承知おきください。