logo
< Back Post-Image

Dynamic DNS and LoadBalancing without cloud provider

We often talk about managed Kubernetes or running Kubernetes in the Cloud but we also run Kubernetes on less “cloudy” environment, like VMware or bare metal servers.

You may also hear a lot about how awesome the cloud provider integrations are: you can get passwordless credentials to access managed services, provision cloud load balancer without manual intervention, create DNS entries automatically etc.

These integrations are often not available when running on premises except if you are using a well supported Cloud provider like OpenStack. So how can you get the automation benefits of Cloud Native environment when running on bare metal or VMs ?

So what do we want we most, let’s go feature by feature.

All the manifests use throughout this article are available here

GitOps

As usual we are using GitOps with FluxCD to deploy our resources into our cluster, whether they are on a cloud provider or on premises. You can check out our articles about Flux.

To get started you can use our GitOps template and customize it to your needs. You can also deploy directly the manifests with kubectl if this is more suitable.

Let’s dive into our components.

Load Balancing

When running on a Cloud provider you often get a Load Balancer out of the box. When running on bare metal or VMs, your load balancers stay in pending state.

So first we’d like for our service type LoadBalancer to not stay in pending and to be able to provision dynamic load balancer if needed without having to configure an haproxy or other manually.

Enters metallb which can provide virtual load balancer in two modes:

The latter is simpler because it works on almost any layer 2 network without further configuration.

In ARP mode, metallb is quite simple to configure. You just have to give it a bunch of IPs it can use and you are good to go.

The manifests are available here or in the official documentation. To configure the IP address needed, this is done with a ConfigMap.

metallb-config.yaml:

apiVersion: v1
kind: ConfigMap
metadata:
  namespace: metallb-system
  name: config
data:
  config: |
    address-pools:
    - name: default
      protocol: layer2
      addresses:
      - 10.10.39.200-10.10.39.220

You also need to generate a secret to secure metallb components communication, you can use this script to generate the Kubernetes secret yaml:

kubectl create secret generic -n metallb-system memberlist --from-literal=secretkey="$(openssl rand -base64 128)" -o yaml --dry-run=client > metallb-secret.yaml

Once everything is deployed you should see your pods inside the metallb-system namespace:

NAME                          READY   STATUS    RESTARTS   AGE
controller-57f648cb96-tvr9q   1/1     Running   0          2d1h
speaker-7rd8p                 1/1     Running   0          2d1h
speaker-7t7rg                 1/1     Running   0          2d1h
speaker-8qm2t                 1/1     Running   0          2d1h
speaker-bks4s                 1/1     Running   0          2d1h
speaker-cz6bc                 1/1     Running   0          2d1h
speaker-h8b54                 1/1     Running   0          2d1h
speaker-j6bss                 1/1     Running   0          2d1h
speaker-phvv7                 1/1     Running   0          2d1h
speaker-wdwjc                 1/1     Running   0          2d1h
speaker-xj25p                 1/1     Running   0          2d1h

We are now ready to test our load balancers. To do so let’s move directly to our next topic.

Ingress controller

When running on Cloud Provider, in addition of the classic layer 4 load balancer, you sometime can get a Layer 7 load balancer, on GCP and AWS (with the application load balancer for example). But these have limited feature and are not really cost efficient and you often want/need an ingress controller to manager your traffic from you Kubernetes cluster.

This ingress controller is often published on the outside with a service type LoadBalancer. That’s why our previous metallb deployment will come in handy.

One of the first and most used ingress controller is the nginx-ingress one which can easily be deployed with Helm.

Since we are using Flux with Helm Operator, we are using an [Helm Release available here] from which you can derived the values.yaml if needed to manually deployed via Helm:

apiVersion: helm.fluxcd.io/v1
kind: HelmRelease
metadata:
  name: nginx-ingress
  namespace: nginx-ingress
spec:
  releaseName: nginx-ingress
  chart:
    repository: https://kubernetes-charts.storage.googleapis.com
    version: 1.36.3
    name: nginx-ingress
  values:
    controller:
      publishService:
        enabled: true
      kind: "DaemonSet"
      service:
        enabled: true
        externalTrafficPolicy: Local
      daemonset:
        hostPorts:
          http: 80
          https: 443
    defaultBackend:
      replicaCount: 2
    podSecurityPolicy:
      enabled: true

Nothing out of the ordinary, we are using a DaemonSet and the default is to use a service type LoadBalancer.

If we check our newly deployed release:

$ kubectl -n nginx-ingress get helmreleases.helm.fluxcd.io
NAME            RELEASE         PHASE       STATUS     MESSAGE                                                                       AGE
nginx-ingress   nginx-ingress   Succeeded   deployed   Release was successful for Helm release 'nginx-ingress' in 'nginx-ingress'.   2d1h

or

$ helm -n nginx-ingress ls
NAME            NAMESPACE       REVISION        UPDATED                                 STATUS          CHART                   APP VERSION
nginx-ingress   nginx-ingress   2               2020-05-12 15:06:25.832403094 +0000 UTC deployed        nginx-ingress-1.36.3    0.30.0

$ kubectl -n nginx-ingress get svc
NAME                            TYPE           CLUSTER-IP       EXTERNAL-IP    PORT(S)                      AGE
nginx-ingress-controller        LoadBalancer   10.108.113.212   10.10.39.200   80:31465/TCP,443:30976/TCP   2d1h
nginx-ingress-default-backend   ClusterIP      10.102.217.148   <none>         80/TCP                       2d1h

We can see that our service is of type LoadBalancer and that the external IP is one that we defined inside our previous ConfigMap for metallb.

Let’s create a demo namespace and check the behavior when we create an ingress object:

$ kubectl create namespace demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: nginx
  name: nginx
  namespace: demo
spec:
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - image: nginx
        name: nginx
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: nginx
  name: nginx
  namespace: demo
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 80
  selector:
    app: nginx
---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
  name: nginx
  namespace: demo
spec:
  rules:
  - host: nginx.test.org
    http:
      paths:
      - backend:
          serviceName: nginx
          servicePort: 80

nginx-ingress is able to publish the service by default which mean it can report the load balancer IP address into the ingress object:

$ kubectl -n demo get ingress

NAME    CLASS    HOSTS            ADDRESS        PORTS     AGE
nginx   <none>   nginx.test.org   10.10.39.200   80, 443   47h

We can see that the LoadBalancer IP address is embedded into the ingress object. This is one the requirement to be able to use external DNS which is our next topic.

External DNS

Now that we have both our layer 4 loadbalancer (metallb) which can carry traffic to our layer 7 load balancer (nginx-ingress) inside our cluster, how can we manage DNS dynamically ? A commonly use tools to do so is external-dns which can keep in sync Kubernetes Services and Ingress with a DNS provider.

And this is pretty simple to use, that is if you are running of using one of the widely use DNS provider (AWS Route53 or Google Cloud DNS). External DNS also supports other providers but if you are not using one of the directly supported provider, you are in no luck.

Let’s say for example your on premises DNS are managed by Active Directory, you are kind of stuck because there is no way external DNS is going to writing directly into your Active Directory DNS.

So how can we get this dynamic DNS feature ? Sure you can use a wildcard DNS record and direct it to our nginx-ingress loadbalancer IP, that’s one way to do it. This works if you are only using one LoadBalancer as an entry point for your cluster but if you want to use other protocol than HTTP or other service of type loadbalancer, you would still need to update manually some DNS records.

The other solution is to delegate a DNS zone for your Cluster.

External DNS supports CoreDNS as a backend, so we can delegate a DNS zone from our active directory to our CoreDNS server running inside Kubernetes.

Caveats

It sounds quite simple but when diving into the external-dns / CoreDNS part we noticed that the only supported backend for CoreDNS that works with External DNS is Etcd. So yes, we need an Etcd cluster. You may also notice that the readme relies on etcd-operator which is now archived and deprecated, and that it also does not encrypt communication with Etcd.

We made an up to date guide to make up for those caveats, first we are going to use Cilium’s fork of etcd-operator that will take care of provisioning a 3 nodes etcd cluster and generate TLS assets.

The manifests are available here.

Etcd operator

First we apply the etcd Custom Resource Definition:

---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: etcdclusters.etcd.database.coreos.com
spec:
  additionalPrinterColumns:
  - JSONPath: .metadata.creationTimestamp
    description: 'CreationTimestamp is a timestamp representing the server time when
      this object was created. It is not guaranteed to be set in happens-before order
      across separate operations. Clients may not set this value. It is represented
      in RFC3339 form and is in UTC. Populated by the system. Read-only. Null for
      lists. More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata'
    name: Age
    type: date
  group: etcd.database.coreos.com
  names:
    kind: EtcdCluster
    listKind: EtcdClusterList
    plural: etcdclusters
    shortNames:
    - etcd
    singular: etcdcluster
  scope: Namespaced
  version: v1beta2
  versions:
  - name: v1beta2
    served: true
    storage: true

Then we can deploy the Etcd operator.

Soon after we should end up with etcd pods and secrets:

$ kubectl -n external-dns get pods

NAME                                    READY   STATUS    RESTARTS   AGE
cilium-etcd-mnphzk2tjl                  1/1     Running   0          2d1h
cilium-etcd-operator-55d89bbff7-cw8rc   1/1     Running   0          2d1h
cilium-etcd-tsxm5rsckj                  1/1     Running   0          2d1h
cilium-etcd-wtnqt22ssg                  1/1     Running   0          2d1h
etcd-operator-6c57fff6f5-g92pc          1/1     Running   0          2d1h

$ kubectl -n external-dns get secrets
NAME                                 TYPE                                  DATA   AGE
cilium-etcd-client-tls               Opaque                                3      2d1h
cilium-etcd-operator-token-zmjcl     kubernetes.io/service-account-token   3      2d1h
cilium-etcd-peer-tls                 Opaque                                3      2d1h
cilium-etcd-sa-token-5dhtn           kubernetes.io/service-account-token   3      2d1h
cilium-etcd-secrets                  Opaque                                3      2d1h
cilium-etcd-server-tls               Opaque                                3      2d1h

CoreDNS

We can then deploy CoreDNS with the official Helm chart.

Just like before, our resource is an HelmRelease from which you can derive the values.yaml if needed:

apiVersion: helm.fluxcd.io/v1
kind: HelmRelease
metadata:
  name: coredns
  namespace: external-dns
spec:
  releaseName: coredns
  chart:
    repository: https://kubernetes-charts.storage.googleapis.com
    version: 1.10.1
    name: coredns
  values:
    serviceType: "NodePort"
    replicaCount: 2
    serviceAccount:
      create: true
    rbac:
      pspEnable: true
    isClusterService: false
    extraSecrets:
    - name: cilium-etcd-client-tls
      mountPath: /etc/coredns/tls/etcd
    servers:
      - zones:
        - zone: .
        port: 53
        plugins:
        - name: errors
        - name: health
          configBlock: |-
            lameduck 5s
        - name: ready
        - name: prometheus
          parameters: 0.0.0.0:9153
        - name: forward
          parameters: . /etc/resolv.conf
        - name: cache
          parameters: 30
        - name: loop
        - name: reload
        - name: loadbalance
        - name: etcd
          parameters: test.org
          configBlock: |-
            stubzones
            path /skydns
            endpoint https://cilium-etcd-client.external-dns.svc:2379
            tls /etc/coredns/tls/etcd/etcd-client.crt /etc/coredns/tls/etcd/etcd-client.key /etc/coredns/tls/etcd/etcd-client-ca.crt

The important lines are the following:

extraSecrets:
- name: cilium-etcd-client-tls
  mountPath: /etc/coredns/tls/etcd

and

- name: etcd
  parameters: test.org
  configBlock: |-
    stubzones
    path /skydns
    endpoint https://cilium-etcd-client.external-dns.svc:2379
    tls /etc/coredns/tls/etcd/etcd-client.crt /etc/coredns/tls/etcd/etcd-client.key /etc/coredns/tls/etcd/etcd-client-ca.crt

Where we are mounting and using etcd secret to use TLS communication with etcd.

External DNS

Finally we can wrap things up and install external DNS. As usual we are going to use the official Helm chart and an HelmRelease:

apiVersion: helm.fluxcd.io/v1
kind: HelmRelease
metadata:
  name: external-dns
  namespace: external-dns
spec:
  releaseName: external-dns
  chart:
    repository: https://charts.bitnami.com/bitnami
    version: 2.22.4
    name: external-dns
  values:
    provider: coredns
    policy: sync
    coredns:
      etcdEndpoints: "https://cilium-etcd-client.external-dns.svc:2379"
      etcdTLS:
        enabled: true
        secretName: "cilium-etcd-client-tls"
        caFilename: "etcd-client-ca.crt"
        certFilename: "etcd-client.crt"
        keyFilename: "etcd-client.key"

Here, same as before, we are supply the secret name and the path to etcd TLS assets to secure communication and we are enabling the coredns provider.

So here is our final external-dns namespace:

$ kubectl -n external-dns get svc
NAME                 TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                     AGE
cilium-etcd          ClusterIP   None           <none>        2379/TCP,2380/TCP           2d2h
cilium-etcd-client   ClusterIP   10.105.37.25   <none>        2379/TCP                    2d2h
coredns-coredns      NodePort    10.99.62.135   <none>        53:31071/UDP,53:30396/TCP   2d1h
external-dns         ClusterIP   10.103.88.97   <none>        7979/TCP                    2d1h

$ kubectl -n external-dns get pods
NAME                                    READY   STATUS    RESTARTS   AGE
cilium-etcd-mnphzk2tjl                  1/1     Running   0          2d2h
cilium-etcd-operator-55d89bbff7-cw8rc   1/1     Running   0          2d2h
cilium-etcd-tsxm5rsckj                  1/1     Running   0          2d2h
cilium-etcd-wtnqt22ssg                  1/1     Running   0          2d2h
coredns-coredns-5c86dd5979-866s2        1/1     Running   0          2d
coredns-coredns-5c86dd5979-vq86w        1/1     Running   0          2d
etcd-operator-6c57fff6f5-g92pc          1/1     Running   0          2d2h
external-dns-96d9fbc64-j22pf            1/1     Running   0          2d1h

If you look back at our ingress from before:

---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
  name: nginx
  namespace: demo
spec:
  rules:
  - host: nginx.test.org
    http:
      paths:
      - backend:
          serviceName: nginx
          servicePort: 80
$ kubectl -n demo get ingress

NAME    CLASS    HOSTS            ADDRESS        PORTS     AGE
nginx   <none>   nginx.test.org   10.10.39.200   80, 443   2d

Let’s check that this ingress has been picked up and inserted into etcd by external dns:

$ kubectl -n external-dns logs -f external-dns-96d9fbc64-j22pf
time="2020-05-12T15:23:52Z" level=info msg="Add/set key /skydns/org/test/nginx/4781436c to Host=10.10.39.200, Text=\"heritage=external-dns,external-dns/owner=default,external-dns/resource=ingress/demo/nginx\", TTL=0"

External DNS appears to be doing its job. Now let’s see if we can resolve a query from CoreDNS directly because it is supposed to be reading from the same etcd server.

CoreDNS is listening with a NodePort service which mean we can query any nodes on the service NodePort:

$ kubectl -n external-dns get svc coredns-coredns
NAME                 TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                     AGE
coredns-coredns      NodePort    10.99.62.135   <none>        53:31071/UDP,53:30396/TCP   2d1h

The 53/UDP port is mapped to port 31071/UDP. Let’s pick a random node:

NAME STATUS   ROLES    AGE   VERSION   INTERNAL-IP    EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION      CONTAINER-RUNTIME
m1   Ready    master   15d   v1.18.2   10.10.40.10    <none>        Ubuntu 18.04.3 LTS   4.15.0-99-generic   containerd://1.3.4
n1   Ready    <none>   15d   v1.18.2   10.10.40.110   <none>        Ubuntu 18.04.3 LTS   4.15.0-99-generic   containerd://1.3.4
n2   Ready    <none>   15d   v1.18.2   10.10.40.120   <none>        Ubuntu 18.04.3 LTS   4.15.0-74-generic   containerd://1.3.4

And try to make a DNS query with dig:

root@inf-k8s-epi-m5:~# dig -p 31071 nginx.test.org @10.10.40.120

; <<>> DiG 9.11.3-1ubuntu1.11-Ubuntu <<>> -p 31071 nginx.test.org @10.10.40.120
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 61245
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
; COOKIE: ef8ff2732b2dc6fd (echoed)
;; QUESTION SECTION:
;nginx.test.org.                        IN      A

;; ANSWER SECTION:
nginx.test.org.         30      IN      A       10.10.39.200

;; Query time: 2 msec
;; SERVER: 10.10.40.120#31071(10.10.40.120)
;; WHEN: Thu May 14 16:26:07 UTC 2020
;; MSG SIZE  rcvd: 85

We can see that CoreDNS is replying with our MetalLB load balancer IP.

Quickly get up and running

Throughout this guide, we set up CoreDNS, External DNS, Nginx Ingress and MetalLB, to provide a dynamics experience like the one provided with Cloud architecture. If you want to get started quickly, check out our Flux repository with all the manifest used for this demo and more.

Kevin Lefevre