Kubernetesのexternal-dnsでRoute53 RecordSetを自動作成する

# external-dnsとは

> [kubernetes-incubator/external-dns]
(https://github.com/kubernetes-incubator/external-dns)
>
> Configure external DNS servers (AWS Route53, Google CloudDNS and others) for Kubernetes Ingresses and Services

[](https://github.com/kubernetes-incubator/external-dns/blob/master/docs/tutorials/aws.md)

externa-dnsはKubernetes Incubatorプロジェクトの一つで、KubernetesユーザがクラウドプロバイダのWebコンソールやCLIを使わずとも簡単にDNSレコードを作成・更新してサービスを公開するために利用します。

AWSの場合、AWSコンソールやawscliを使わずともkubectlだけで簡単にRoute53でサービスを公開することができます。

# external-dnsの仕組み

Kubernetesユーザが何かサービスをデプロイするとき、Deployment、Service、Ingressなどを作成します。そのとき、IngressやServiceに特定のAnnotationをつけると、その内容に応じたRoute53 RecordSetを作成してくれます。

# external-dnsの使い方

[この記事の後半に書きました](https://qiita.com/mumoshu/items/bd82bd69525bc8feb4da#external-dns%E3%81%AE%E5%88%A9%E7%94%A8%E6%89%8B%E9%A0%86)。既にexternal-dnsがセットアップされていて使い方を知りたいだけの場合はそちらを参照してください！

# external-dnsのセットアップ手順

## IAMポリシーの割当

external-dnsが稼働するKubernetesノード、またはPodに必要なIAMポリシーを割り当てます。

具体的には、

- kube2iam/kiamを使わない場合は、Kubernetesノード用EC2インスタンスに割り当てられるIAMロール
- kube2iamやkiamを使う場合は、annotationで指定するIAMロール

に以下のようなIAMポリシーを設定します。

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

kube-awsの場合、cluster.yamlに以下のように記述します。

```
controller:
  iam:
    policy:
      statements:
      -	effect:	Allow
      	actions:
       	- "route53:ChangeResourceRecordSets"
      	resources:
      	- "arn:aws:route53:::hostedzone/*"
      -	effect:	Allow
      	actions:
      	- "route53:ListHostedZones"
       	- "route53:ListResourceRecordSets"
      	resources:
      	- "*"
```

Ref: [external-dnsのドキュメント: IAM Permissions](https://github.com/kubernetes-incubator/external-dns/blob/master/docs/tutorials/aws.md#iam-permissions)

## external-dnsのデプロイ

```
apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: external-dns
spec:
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: external-dns
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: "node-role.kubernetes.io/master"
                operator: "Exists"
      tolerations:
      - key: "node.alpha.kubernetes.io/role"
        operator: "Equal"
        value: "master"
        effect: "NoSchedule"
      containers:
      - name: external-dns
        image: registry.opensource.zalan.do/teapot/external-dns:v0.4.8
        args:
        - --source=service
        - --source=ingress
        - --domain-filter=example.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
        - --registry=txt
        - --txt-owner-id=my-identifier
```

ポイント:

- `--domain-filter=example.com`はHostedZoneに対応するドメイン名に置き換えます。
- Service LoadBalancerにだけRecordSetを作成するようにしたい場合は、`--source=service`、Ingress LoadBalancerにだけRecordSetを作成するようにしたい場合は、`--souce=ingress`、または両方を指定します。
- tolerationとaffinityは今回IAMポリシーを割り当てたmasterノードにexternal-dnsのpodをスケジュールするために指定します。
- workerノードのIAMポリシーも変更した場合は、tolerationとaffinityどちらも不要です。

## external-dnsのログ確認

まだserviceにもingressにもannotationをつけていないとして、以下のように"All records are already up to date"と表示されればひとまずListHostedZones, ListResourceRecordSetsは呼び出せた、ということになります。IAMの設定はだいたいあっていそう、ということですね。

```
$ stern external-dns
+ external-dns-f76db487d-m6btx › external-dns
external-dns-f76db487d-m6btx external-dns time="2017-12-05T14:52:26Z" level=info msg="config: &{Master: KubeConfig: Sources:[service ingress] Namespace: AnnotationFilter: FQDNTemplate: Compatibility: PublishInternal:false Provider:aws GoogleProject: DomainFilter:[example.com] AWSZoneType: AzureConfigFile:/etc/kubernetes/azure.json AzureResourceGroup: CloudflareProxied:false InfobloxGridHost: InfobloxWapiPort:443 InfobloxWapiUsername:admin InfobloxWapiPassword: InfobloxWapiVersion:2.3.1 InfobloxSSLVerify:true InMemoryZones:[] Policy:upsert-only Registry:txt TXTOwnerID:my-identifier TXTPrefix: Interval:1m0s Once:false DryRun:false LogFormat:text MetricsAddress::7979 LogLevel:info}"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T14:52:26Z" level=info msg="Connected to cluster at https://10.3.0.1:443"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T14:52:27Z" level=info msg="All records are already up to date"
```

# external-dnsの利用手順

## Service LoadBalancerのRecordSetをつくってみる

```
apiVersion: v1
kind: Service
metadata:
  name: nginx
  annotations:
    external-dns.alpha.kubernetes.io/hostname: mynginx.example.com
spec:
  type: LoadBalancer
  ports:
  - port: 80
    targetPort: 80
  selector:
    app: nginx

---

apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: nginx
spec:
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - image: nginx
        name: nginx
        ports:
        - containerPort: 80
```

```
$ pbpaste | k create -f -
service "nginx" created
deployment "nginx" created
```

Service LoadBalancer(ELB)が作成されて、その後にexternal-dnsがELBへのA(lias)レコードを作成します。

external-dnsのログを眺めてみると、今回は、最後の更新チェックである"All records are already up to date"から約1分ほどでELBの作成からRecordSetの作成までが完了したことがわかります。

```
external-dns-f76db487d-m6btx external-dns time="2017-12-05T14:56:31Z" level=info msg="All records are already up to date"

external-dns-f76db487d-m6btx external-dns time="2017-12-05T14:57:32Z" level=info msg="Desired change: CREATE mynginx.example.com A"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T14:57:32Z" level=info msg="Desired change: CREATE mynginx.example.com TXT"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T14:57:33Z" level=info msg="Record in zone example.com. were successfully updated"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T14:58:34Z" level=info msg="All records are already up to date"
```

awscliで該当ドメインのHostedZoneの内容を見てみると、たしかにRecordSetが作成されていることがわかります。

```
$ aws route53 list-resource-record-sets --hosted-zone-id "/hostedzone/MYHOSTEDZONEID"
```

```json
{
    "ResourceRecordSets": [
        {
            "AliasTarget": {
                "HostedZoneId": "MYHOSTEDZONEID",
                "EvaluateTargetHealth": true,
                "DNSName": "__HASH__.ap-northeast-1.elb.amazonaws.com."
            },
            "Type": "A",
            "Name": "mynginx.example.com.net."
        },
        {
            "ResourceRecords": [
                {
                    "Value": "\"heritage=external-dns,external-dns/owner=my-identifier\""
                }
            ],
            "Type": "TXT",
            "Name": "mynginx.example.com.",
            "TTL": 300
        }
    ]
}
```

TXTレコードはホスト名に関連付けるテキストデータを保持するレコードですが、external-dnsはそれをレコードの所有権を表すことに使っています。`external-dns/owner=my-identifier`とある部分が、external-dnsのコマンドライン・オプションで指定した`--txt-owner-id=my-identifier`に対応します。

これのおかげで、external-dnsは既存の、external-dnsの管理外のレコードセットを誤って削除せずにすみます。

また、同じHostedZoneを複数のクラスタのexternal-dnsで共有する場合でも、txt-owner-idをクラスタ間で異なる値にしておけば、他のクラスタで作られたRecordSetを誤って削除するということもありません。

## Service LoadBalancerのRecordSetを削除してみる

試しに`external-dns.alpha.kubernetes.io/hostname: mynginx.example.com`というannotationを消してみると・・・

```
$ k edit svc nginx
```

いつまでたっても消えませんね。

```
external-dns-f76db487d-m6btx external-dns time="2017-12-05T14:58:34Z" level=info msg="All records are already up to date"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T14:59:35Z" level=info msg="All records are already up to date"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:00:36Z" level=info msg="All records are already up to date"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:01:37Z" level=info msg="All records are already up to date"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:02:38Z" level=info msg="All records are already up to date"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:03:39Z" level=info msg="All records are already up to date"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:04:40Z" level=info msg="All records are already up to date"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:05:41Z" level=info msg="All records are already up to date"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:06:42Z" level=info msg="All records are already up to date"
```

これは、external-dnsのコマンドライン・オプションで`--policy=upsert-only`を指定しているためです。upsert-onlyの場合、annotationの内容に応じてRecordSetを新規作成か更新するだけで、削除はしません。うっかりRecordSetを削除してしまって、サービスをダウンさせてしまわないようにするための機能ですね。

試しに、external-dnsのpolicyを他のものに変更してみます。
[external-dnsのソースをみるとupsert-onlyかsyncしかない](https://github.com/kubernetes-incubator/external-dns/blob/305f691a30b12a77a6c40c2f1147d6db50c454dd/plan/policy.go#L26-L27)ので、syncにしてみます。


```
$ k edit deploy external-dns
```

```
--policy=sync
```

```
deployment "external-dns" edited
```

ログを見てみると、10秒ほどでexternal-dnsが再起動して、どこからも使われていないRecordSetを削除します。なお、今回利用したHostedZoneには他にもRecordSetがあったのですが、ちゃんと先程作成してからannotationを削除したものだけが削除されています。TXTレコードのおかげですね。

```
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:12:48Z" level=info msg="All records are already up to date"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:13:35Z" level=info msg="Received SIGTERM. Terminating..."
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:13:35Z" level=info msg="Terminating main controller loop"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:13:35Z" level=info msg="Pod waiting to be deleted"
external-dns-f76db487d-m6btx external-dns time="2017-12-05T15:14:05Z" level=info msg="Pod waiting to be deleted"
- external-dns-f76db487d-m6btx
+ external-dns-7fd4749755-gsjbl › external-dns
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:14:08Z" level=info msg="config: &{Master: KubeConfig: Sources:[service ingress] Namespace: AnnotationFilter: FQDNTemplate: Compatibility: PublishInternal:false Provider:aws GoogleProject: DomainFilter:[example.com] AWSZoneType: AzureConfigFile:/etc/kubernetes/azure.json AzureResourceGroup: CloudflareProxied:false InfobloxGridHost: InfobloxWapiPort:443 InfobloxWapiUsername:admin InfobloxWapiPassword: InfobloxWapiVersion:2.3.1 InfobloxSSLVerify:true InMemoryZones:[] Policy:sync Registry:txt TXTOwnerID:my-identifier TXTPrefix: Interval:1m0s Once:false DryRun:false LogFormat:text MetricsAddress::7979 LogLevel:info}"
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:14:08Z" level=info msg="Connected to cluster at https://10.3.0.1:443"
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:14:10Z" level=info msg="Desired change: DELETE mynginx.example.com A"
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:14:10Z" level=info msg="Desired change: DELETE mynginx.example.com TXT"
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:14:10Z" level=info msg="Record in zone example.com. were successfully updated"
```

## IAMパーミッションを絞る

今回はIAMポリシーで全HostedZoneの操作をexternal-dnsに許可しました。セキュリティを考慮すると、Kubernetesから変更できてよいHostedZoneを絞り込むべきです。

幸い、Route53のChangeResourceRecordSets APIはResource Level Permissionに対応しており、以下のように特定のHostedZoneだけを許可することができます。

```
"Resource": "arn:aws:route53:::hostedzone/MYHOSTEDZONEID"
```

kube-awsの場合は、cluster.yamlを以下のように変更します。

```yaml
controller:
  iam:
    policy:
      statements:
      - effect: Allow
        actions:
        - "route53:ChangeResourceRecordSets"
        resources:
        # BEFORE:
        # - "arn:aws:route53:::hostedzone/*"
        # AFTER:
        - "arn:aws:route53:::hostedzone/MYHOSTEDZONEID"
      - effect: Allow
        actions:
        - "route53:ListHostedZones"
        - "route53:ListResourceRecordSets"
        resources:
        - "*"
```

参考: [Amazon Route 53 API Permissions: Actions, Resources, and Conditions Reference - Amazon Route 53](http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/r53-api-permissions-ref.html#required-permissions-resource-record-sets)

この状態でもう一度annotationをつけてみます。

```
$ k edit svc nginx
```

```yaml
annotations:
  external-dns.alpha.kubernetes.io/hostname: mynginx.example.com
```

```
stern external-dns
```

ポリシーを間違えていると以下のようにエラーになりますが、

```
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:43:39Z" level=info msg="Desired change: CREATE mynginx.example.com A"
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:43:39Z" level=info msg="Desired change: CREATE mynginx.example.com TXT"
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:43:40Z" level=error msg="AccessDenied: User: arn:aws:sts::myaccountid:assumed-role/k8s3-Controlplane-7VONPPP3RI90-IAMRoleController-1VP8UCUTQBGBC/bc931bf0-k8s3-Controlplane-7VONPPP3RI90-IAMRoleController-1VP8UC is not authorized to perform: route53:ChangeResourceRecordSets on resource: arn:aws:route53:::hostedzone/MYHOSTEDZONEID
external-dns-7fd4749755-gsjbl external-dns 	status code: 403, request id: 10b69f55-d9d3-11e7-947b-0dd69a6ee2ee"
```

あっている場合は以下のようにRecordSetがつくられます。

```
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:44:41Z" level=info msg="Desired change: CREATE mynginx.example.com A"
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:44:41Z" level=info msg="Desired change: CREATE mynginx.example.com TXT"
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:44:41Z" level=info msg="Record in zone example.com. were successfully updated"
external-dns-7fd4749755-gsjbl external-dns time="2017-12-05T15:45:42Z" level=info msg="All records are already up to date"
```

## TTLを設定する

ttlというannotationでRecordSetのTTLを設定できます。例えば以下のようにするとTTLが60秒のレコードセットが作成されます。

```
external-dns.alpha.kubernetes.io/ttl: 60
```

## Private Hosted Zoneにレコードをつくる

Private Hosted ZoneのHostedZoneIdに対するChangeResourceRecordSetsをIAMで許可して、external-dnsのコマンドラインオプションでPrivate Hosted Zoneに対応するドメイン名を指定すればOKです。

kube-awsのcluster.yaml

```yaml
controller:
  iam:
    policy:
      statements:
      - effect: Allow
        actions:
        - "route53:ChangeResourceRecordSets"
        resources:
        - "arn:aws:route53:::hostedzone/MYPUBLICHOSTEDZONE"
        - "arn:aws:route53:::hostedzone/MYPRIVATEHOSTEDZONE"
      - effect: Allow
        actions:
        - "route53:ListHostedZones"
        - "route53:ListResourceRecordSets"
        resources:
        - "*"
```

external-dnsのコマンドラインオプション

```
--domain-filter=private.example.com
```

external-dnsのログ(通常のHostedZoneの場合と全く一緒です)

```
external-dns-85576898b4-c2qdj external-dns time="2017-12-05T15:56:32Z" level=info msg="Desired change: CREATE mynginx.private.example.com A"
external-dns-85576898b4-c2qdj external-dns time="2017-12-05T15:56:32Z" level=info msg="Desired change: CREATE mynginx.private.example.com TXT"
external-dns-85576898b4-c2qdj external-dns time="2017-12-05T15:56:32Z" level=info msg="Record in zone private.example.com. were successfully updated"
```

## レコードの削除

Serviceを削除すると、１０秒ほどで自動的に削除されます。

```
kubectl delete svc nginx
```

```
external-dns-85576898b4-c2qdj external-dns time="2017-12-05T16:15:51Z" level=info msg="Desired change: DELETE mynginx.private.example.com A"
external-dns-85576898b4-c2qdj external-dns time="2017-12-05T16:15:51Z" level=info msg="Desired change: DELETE mynginx.private.example.com TXT"
external-dns-85576898b4-c2qdj external-dns time="2017-12-05T16:15:51Z" level=info msg="Record in zone private.example.com. were successfully updated"
```

# まとめ

external-dnsを使うと、annotationをつけるだけで簡単にService/Ingress Load BalancerをDNSで公開することができます。
IAMで権限を絞れば、自由に使っていいドメインについてはAWSがあまりわからないエンジニアでも自分でDNSレコードをつくれるようになり、権限委譲が捗りそうですね！