Zero to Kubernetes on Azure

Table of Contents

Introduction

Kubernetes is a highly popular container management platform. If you have just heard about it but didn’t have a chance to play with it then this post might help you to get started.

In this guide, we will create a single-node kubernetes cluster and will deploy a sample application into our cluster from our private container registry, and finally, we are going to configure our cluster to be able to serve our content with TLS certificate from a custom domain!

If this sounds interesting, then buckle up because this post is going to be really looooong post!

Prerequisites

Before we start, make sure that you have an active Visual Studio Subscription.

While we are creating and configuring a cluster we will make use of a couple of tools.

  • Docker We will need a local docker installation to be able to build Docker images locally.
  • Azure CLI is a suite of command-line tools that we are going to use heavily to manage our Azure resources. Once you have installed it, make sure you are logged in (az login) and your Visual Studio subscription is the active one.
  • kubectl is a command-line tool that we will use to manage our kubernetes cluster.
  • helm is a command-line tool that we will use to manage deployments to our kubernetes cluster.

Create the Kubernetes Cluster

OK, let’s get started.

First, we need to create a resource group which will contain all the resources that we are going to create later on.

Z:\>az group create -n kube-demo-group --location=westus2
{
  "id": "/subscriptions/<redacted>/resourceGroups/kube-demo-group",
  "location": "westus2",
  "managedBy": null,
  "name": "kube-demo-group",
  "properties": {
    "provisioningState": "Succeeded"
  },
  "tags": null,
  "type": null
}

Next, we are going to create a single-node kubernetes cluster.

NOTE: I have chosen to create a single-node cluster purely because the cost of a multi-node cluster would exceed monthly Visual Studio Subscription credit. If you are not planning to run the cluster for a month then feel free to increase the node count in the previous command.

az aks create -n kube-demo --resource-group kube-demo-group --node-count 1 --node-vm-size Standard_B2ms

Creating a kubernetes cluster might easily take a while. You should see a JSON formatted cluster information printed to the console when the operation is completed.

{
  "aadProfile": null,
  "addonProfiles": null,
  "agentPoolProfiles": [
    {
      "count": 1,
      "maxPods": 110,
      "name": "nodepool1",
      "osDiskSizeGb": 100,
      "osType": "Linux",
      "storageProfile": "ManagedDisks",
      "vmSize": "Standard_B2ms",
      "vnetSubnetId": null
    }
  ],
  "dnsPrefix": "kube-demo-kube-demo-group-b86a0f",
  "enableRbac": true,
  "fqdn": "kube-demo-kube-demo-group-b86a0f-5107b82b.hcp.westus2.azmk8s.io",
  "id": "/subscriptions/[redacted]/resourcegroups/kube-demo-group/providers/Microsoft.ContainerService/managedClusters/kube-demo",
  "kubernetesVersion": "1.11.9",
  "linuxProfile": {
    "adminUsername": "azureuser",
    "ssh": {
      "publicKeys": [redacted]
    }
  },
  "location": "westus2",
  "name": "kube-demo",
  "networkProfile": {
    "dnsServiceIp": "10.0.0.10",
    "dockerBridgeCidr": "172.17.0.1/16",
    "networkPlugin": "kubenet",
    "networkPolicy": null,
    "podCidr": "10.244.0.0/16",
    "serviceCidr": "10.0.0.0/16"
  },
  "nodeResourceGroup": "MC_kube-demo-group_kube-demo_westus2",
  "provisioningState": "Succeeded",
  "resourceGroup": "kube-demo-group",
  "servicePrincipalProfile": { clientId: <redacted> },
  "tags": null,
  "type": "Microsoft.ContainerService/ManagedClusters"
}

NOTE: If you see an error message saying An RSA key file or key value must be supplied to SSH Key Value. You can use –generate-ssh-keys to let CLI generate one for you, then try appending --generate-ssh-keys option to the end of the previous command and run it again.

From now on, we are going to use kubectl CLI to interact with our cluster. kubectl can be used to work with multiple kubernetes cluster. Authentication information for each cluster is called context.

We need to provide context information of our cluster so that the commands we run points to our cluster.

We are going to use Azure CLI to handle pulling the context information of cluster and merge it with kubectl configuration.

Z:\>az aks get-credentials -g kube-demo-group -n kube-demo
Merged "kube-demo" as current context in C:\Users\Ibrahim.Dursun\.kube\config

Context information for our cluster is called kube-demo. Let’s make sure that it is the default context.

Z:\>kubectl config use-context kube-demo
Switched to context "kube-demo".

Awesome! Now, every command we are going to run using kubectl is going to be executed in our kubernetes cluster.

Let’s request the list of active nodes in our cluster.

Z:\>kubectl get nodes
NAME                       STATUS   ROLES   AGE   VERSION
aks-nodepool1-27011079-0   Ready    agent   12m   v1.11.9

Create an Azure Container Registry (ACR)

We have created a kubernetes cluster, but it is pretty useless as it’s own because we haven’t deployed anything to it.

Ideally, we would like to deploy containers from our own application images into our cluster.

One way of doing it is to push our application’s docker image to public hub.docker.com under our user name but this will make it publicly accessible. If this is not something you would like the alternative is to create a private container registry.

A private container registry on Azure is called Azure Container Registry (ACR).

The following command creates an ACR resource with name kubeDockerRegistry on Azure. The full address of the container registry will be kubedockerregistry.azurecr.io.

NOTE: The name of the ACR needs to be unique. If the name is taken, an error message will be printed. Don’t forget to replace ACR name in the subsequent commands with the name you have chosen.

Z:\>az acr create --resource-group kube-demo-group --name kubeDockerRegistry --sku Basic
{
  "adminUserEnabled": false,
  "id": "/subscriptions/<redacted>/resourceGroups/kube-demo-group/providers/Microsoft.ContainerRegistry/registries/kubeDockerRegistry",
  "location": "westus2",
  "loginServer": "kubedockerregistry.azurecr.io",
  "name": "kubeDockerRegistry",
  "provisioningState": "Succeeded",
  "resourceGroup": "kube-demo-group",
  "sku": {
    "name": "Basic",
    "tier": "Basic"
  },
  "status": null,
  "storageAccount": null,
  "tags": {},
  "type": "Microsoft.ContainerRegistry/registries"
}

At this point, if we knew username and password to our ACR then we would run docker login to login to the registry. As the first line of the response suggests, the admin user is disabled by default.

We are going to use Azure CLI to handle the details of logging in to our container registry through docker.

Z:\>az acr login --name kubeDockerRegistry
Login Succeeded

We are going to use aks-helloworld image to test deployments to our kubernetes cluster.

Z:\>docker pull neilpeterson/aks-helloworld:v1
...snip...
Status: Downloaded newer image for neilpeterson/aks-helloworld:v1

Docker image naming format is registry[:port]/user/repo[:tag]. When registry part not specified then it is assumed to be hub.docker.com. If we want to push an image to our private container registry then we need to tag the image accordingly. In our case the name should be kubedockerregistry.azurecr.io/aks-helloworld:latest.

Z:\>docker tag neilpeterson/aks-helloworld:v1 kubedockerregistry.azurecr.io/aks-helloworld:latest

Now, when we push the image, it will be sent to our private container registry.

Z:\>docker push kubedockerregistry.azurecr.io/aks-helloworld:latest
The push refers to repository [kubedockerregistry.azurecr.io/aks-helloworld]
752a9476c0fe: Pushed
1f6a42f2e735: Pushed
fc5f084dd381: Pushed
...snip..
851f3e348c69: Pushed
e27a10675c56: Pushed
latest: digest: sha256:fb47732ef36b285b1f3fbda69ab8411a430b1dc43823ae33d5992f0295c945f4 size: 6169

Associate Azure Container Registry and Kubernetes Cluster

We have set up our kubernetes cluster and a private container registry and be able to communicate with both of them.

Next step is to make them be able to talk with each other.

We need to grant acrpull permission to our kubernetes cluster service principal to be able to pull docker images from our private container registry.

In order to do this, we need two pieces of information.

First, we need to get the server principal id of the cluster which we will be referring to as SERVER_PRINCIPAL_ID.

Z:\>az aks show --resource-group kube-demo-group --name kube-demo --query "servicePrincipalProfile.clientId" --output=tsv
9646e977-98de-4217-beb3-28ec3d043290

Secondly, we need resource id of the private container registry which we will be referring to as ACR_RESOURCE_ID.

Z:\>az acr show --name kubeDockerRegistry --resource-group kube-demo-group --query "id" --output=tsv
/subscriptions/<redacted>/resourceGroups/kube-demo-group/providers/Microsoft.ContainerRegistry/registries/kubeDockerRegistry

Finally, we are going to grant the acrpull permission to SERVER_PRINCIPLE_ID on ACR_RESOURCE_ID.

Z:\>az role assignment create --role acrpull --assignee <SERVER_PRINCIPAL_ID> --scope <ACR_RESOURCE_ID>
{
  "canDelegate": null,
  "id": "/subscriptions/<redacted>/resourceGroups/kube-demo-group/providers/Microsoft.ContainerRegistry/registries/kubeDockerRegistry/providers/Microsoft.Authorization/roleAssignments/<redacted>",
  "name": "<redacted>",
  "principalId": "<redacted>",
  "resourceGroup": "kube-demo-group",
  "roleDefinitionId": "/subscriptions/<redacted>/providers/Microsoft.Authorization/roleDefinitions/<redacted>",
  "scope": "/subscriptions/<redacted>/resourceGroups/kube-demo-group/providers/Microsoft.ContainerRegistry/registries/kubeDockerRegistry",
  "type": "Microsoft.Authorization/roleAssignments"
}

Install Helm

Helm is the package manager for Kubernetes.

We are going to use it to deploy our applications into our cluster.

Helm has a server-side component called tiller which needs to be initialised in the cluster to be able to create resources needed for deployment of our application.

Let’s do that first.

Z:\>helm init --history-max 200
$HELM_HOME has been configured at Z:\.helm.

Tiller (the Helm server-side component) has been installed into your Kubernetes Cluster.

Please note: by default, Tiller is deployed with an insecure 'allow unauthenticated users' policy.
To prevent this, run `helm init` with the --tiller-tls-verify flag.
For more information on securing your installation see: https://docs.helm.sh/using_helm/#securing-your-helm-installation
Happy Helming!

If we run helm list now, we will get an error message because helm is running with the default service account. That means it doesn’t have the required permissions to make any changes to our cluster.

Z:\>helm list
Error: configmaps is forbidden: User "system:serviceaccount:kube-system:default" cannot list configmaps in the namespace "kube-system"

We need to grant the required permissions to be able to install packages into our cluster.

NOTE: This is giving cluster-admin access to the tiller service, which is not something you should be doing in production.

{{< highlight sh >}} Z:>kubectl create clusterrolebinding add-on-cluster-admin –clusterrole=cluster-admin –serviceaccount=kube-system:default clusterrolebinding.rbac.authorization.k8s.io/add-on-cluster-admin created {{< /highlight >}}

If we run helm list again, the error message should not show and we should be able to see the tiller pod running.

Z:\>kubectl get pods -n kube-system
NAME                                    READY   STATUS    RESTARTS   AGE
heapster-5d6f9b846c-t4n4h               2/2     Running   0          1h
kube-dns-autoscaler-746998ccf6-kc5hp    1/1     Running   0          1h
kube-dns-v20-7c7d7d4c66-n8tnd           4/4     Running   0          1h
kube-dns-v20-7c7d7d4c66-tk6pt           4/4     Running   0          1h
kube-proxy-vblvr                        1/1     Running   0          1h
kube-svc-redirect-dz7tp                 2/2     Running   0          1h
kubernetes-dashboard-67bdc65878-vwb67   1/1     Running   0          1h
metrics-server-5cbc77f79f-hhxxp         1/1     Running   0          1h
tiller-deploy-f8dd488b7-ls5j4           1/1     Running   0          53m
tunnelfront-66cd6b6875-29vvv            1/1     Running   0          1h

Deploy with helm

In Helm’s terminology a recipe for a deployment is called a chart. A chart made of a collection of templates and a file called Values.yaml to provide the template values.

We are going to create a chart for our aks-helloworld application.

Z:\>helm create aks-helloworld-chart
Creating aks-helloworld-chart

Helm is going to create a folder with name aks-helloworld-chart. The file that we are interested is values.yaml.

The contents of the file look like this:

# Default values for simple-server.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.

replicaCount: 1

image:
  repository: nginx
  tag: stable
  pullPolicy: IfNotPresent

nameOverride: ""
fullnameOverride: ""

service:
  type: ClusterIP
  port: 80

ingress:
  enabled: false
  annotations:
    {}
    # kubernetes.io/ingress.class: nginx
    # kubernetes.io/tls-acme: "true"
  hosts:
    - host: chart-example.local
      paths: []

  tls: []
  #  - secretName: chart-example-tls
  #    hosts:
  #      - chart-example.local

resources:
  {}
  # We usually recommend not to specify default resources and to leave this as a conscious
  # choice for the user. This also increases chances charts run on environments with little
  # resources, such as Minikube. If you do want to specify resources, uncomment the following
  # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
  # limits:
  #   cpu: 100m
  #   memory: 128Mi
  # requests:
  #   cpu: 100m
  #   memory: 128Mi

nodeSelector: {}

tolerations: []

affinity: {}

We are going to change the values under the image block. These values define which and what version of the docker image to pull.

For our application;

  • image.repository is kubedockerregistry.azurecr.io/aks-helloworld
  • image.tag is latest.

We are not interested in ingress and tls blocks for now, but we are going to use them later.

Once we have updated these values, we can try deploying our application.

Z:\>helm install -n aks-helloworld aks-helloworld-chart
NAME:   aks-helloworld
LAST DEPLOYED: Mon Apr 15 14:31:22 2019
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Deployment
NAME                                 READY  UP-TO-DATE  AVAILABLE  AGE
aks-helloworld-aks-helloworld-chart  0/1    1           0          1s

==> v1/Pod(related)
NAME                                                  READY  STATUS             RESTARTS  AGE
aks-helloworld-aks-helloworld-chart-599d9658f6-4gvjt  0/1    ContainerCreating  0         1s

==> v1/Service
NAME                                 TYPE       CLUSTER-IP   EXTERNAL-IP  PORT(S)  AGE
aks-helloworld-aks-helloworld-chart  ClusterIP  10.0.242.36  <none>       80/TCP   1s


NOTES:
1. Get the application URL by running these commands:
  export POD_NAME=$(kubectl get pods --namespace default -l "app.kubernetes.io/name=aks-helloworld-chart,app.kubernetes.io/instance=aks-helloworld" -o jsonpath="{.items[0].metadata.name}")
  echo "Visit http://127.0.0.1:8080 to use your application"
  kubectl port-forward $POD_NAME 8080:80

The installation has kicked off. You can watch the progress of the deployment by running the following command.

Z:\>kubectl get deployments --watch
NAME                                           READY   UP-TO-DATE   AVAILABLE   AGE
aks-helloworld-aks-helloworld-chart            0/1     1            0           96s
aks-helloworld-aks-helloworld-chart            1/1     1            1          100s

As a result of the deployment of our application, the following resources created in the kubernetes cluster:

  • Pods are a group of one or more containers running inside the cluster.
  • Services define a well known name and a port for a set of pods inside our cluster. You might be running your application in multiple pods, but then in order to connecto to these pods you need to know IPs and ports assigned to them. Instead of connecting pods individually, you can use services.
  • Deployments are used to declare what resources you want to run inside your cluster and kubernetes handles the creating and updating the necessary resources. If a pod dies, it spins up another one. If deployment is deleted then all the associated resources are deleted.

Install nginx ingress

Right now, we have pods and services running within the cluster. But, they are not accesible outside of the cluster.

The resource that allows external requests to map into the services within the cluster is called ingress.

We need to create an ingress resource to tell kubernetes how the requests should be mapped.

Luckily, there is a premade chart that we can just install and enable ingress.

Z:\>helm install stable/nginx-ingress
NAME:   kneeling-coral
LAST DEPLOYED: Fri Apr 12 13:29:54 2019
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/ConfigMap
NAME                                     DATA  AGE
kneeling-coral-nginx-ingress-controller  1     2s

==> v1/Pod(related)
NAME                                                           READY  STATUS             RESTARTS  AGE
kneeling-coral-nginx-ingress-controller-f66ddfd74-z6cgx        0/1    ContainerCreating  0         1s
kneeling-coral-nginx-ingress-default-backend-845d46bc44-jqzl4  0/1    ContainerCreating  0         1s

==> v1/Service
NAME                                          TYPE          CLUSTER-IP    EXTERNAL-IP  PORT(S)                     AGE
kneeling-coral-nginx-ingress-controller       LoadBalancer  10.0.218.217  <pending>    80:30833/TCP,443:31062/TCP  2s
kneeling-coral-nginx-ingress-default-backend  ClusterIP     10.0.187.231  <none>       80/TCP                      2s

==> v1/ServiceAccount
NAME                          SECRETS  AGE
kneeling-coral-nginx-ingress  1        2s

==> v1beta1/ClusterRole
NAME                          AGE
kneeling-coral-nginx-ingress  2s

==> v1beta1/ClusterRoleBinding
NAME                          AGE
kneeling-coral-nginx-ingress  2s

==> v1beta1/Deployment
NAME                                          READY  UP-TO-DATE  AVAILABLE  AGE
kneeling-coral-nginx-ingress-controller       0/1    1           0          2s
kneeling-coral-nginx-ingress-default-backend  0/1    1           0          2s

==> v1beta1/Role
NAME                          AGE
kneeling-coral-nginx-ingress  2s

==> v1beta1/RoleBinding
NAME                          AGE
kneeling-coral-nginx-ingress  2s


NOTES:
The nginx-ingress controller has been installed.
It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status by running 'kubectl --namespace default get services -o wide -w kneeling-coral-nginx-ingress-controller'

An example Ingress that makes use of the controller:

  apiVersion: extensions/v1beta1
  kind: Ingress
  metadata:
    annotations:
      kubernetes.io/ingress.class: nginx
    name: example
    namespace: foo
  spec:
    rules:
      - host: www.example.com
        http:
          paths:
            - backend:
                serviceName: exampleService
                servicePort: 80
              path: /
    # This section is only required if TLS is to be enabled for the Ingress
    tls:
        - hosts:
            - www.example.com
          secretName: example-tls

If TLS is enabled for the Ingress, a Secret containing the certificate and key must also be provided:

  apiVersion: v1
  kind: Secret
  metadata:
    name: example-tls
    namespace: foo
  data:
    tls.crt: <base64 encoded cert>
    tls.key: <base64 encoded key>
  type: kubernetes.io/tls

Helm deployed all the ingress related resources. If we query the running services, we should see an ingress-controller with an external IP assigned.

Z:\>kubectl get svc
NAME                                           TYPE           CLUSTER-IP     EXTERNAL-IP     PORT(S)                      AGE
aks-helloworld-aks-helloworld-chart            ClusterIP      10.0.242.36    <none>          80/TCP                       1d
kneeling-coral-nginx-ingress-controller        LoadBalancer   10.0.218.217   13.77.160.221   80:30833/TCP,443:31062/TCP   1d
kneeling-coral-nginx-ingress-default-backend   ClusterIP      10.0.187.231   <none>          80/TCP                       1d
kubernetes                                     ClusterIP      10.0.0.1       <none>          443/TCP                      1d

13.77.160.221 is the IP that we can use to connect to our cluster now.

Let’s see what we get back when we make a request!

Z:\>curl 13.77.160.221
default backend - 404

NOTE: You can install curl if you don’t have it locally install by running scoop install curl

The response is default backend - 404 which is absolutely normal.

It means ingress is up and running but it doesn’t know how to map external requests to any of the internal services, therefore, falling back to the default backend which only returns 404.

We are going to modify ingress block on our chart as follows:

ingress:
  enabled: true
  annotations:
    kubernetes.io/ingress.class: nginx
    # kubernetes.io/tls-acme: "true"
  hosts:
    - paths:
        - /

We have enabled the ingress, which will tell helm to create an ingress resource which maps the root of our host to the internal aks-helloworld service.

It’s worth bumping up the version of the chart in Chart.yaml so that we can rollback if anything goes wrong.

Let’s deploy the new version.

Z:\>helm upgrade aks-helloworld aks-helloworld-chart
Release "aks-helloworld" has been upgraded. Happy Helming!
LAST DEPLOYED: Mon Apr 15 14:51:19 2019
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Deployment
NAME                                 READY  UP-TO-DATE  AVAILABLE  AGE
aks-helloworld-aks-helloworld-chart  1/1    1           1          19m

==> v1/Pod(related)
NAME                                                  READY  STATUS   RESTARTS  AGE
aks-helloworld-aks-helloworld-chart-599d9658f6-4gvjt  1/1    Running  0         19m

==> v1/Service
NAME                                 TYPE       CLUSTER-IP   EXTERNAL-IP  PORT(S)  AGE
aks-helloworld-aks-helloworld-chart  ClusterIP  10.0.242.36  <none>       80/TCP   19m

==> v1beta1/Ingress
NAME                                 HOSTS  ADDRESS  PORTS  AGE
aks-helloworld-aks-helloworld-chart  *      80       1s


NOTES:
1. Get the application URL by running these commands:
  http:///

Let’s test if the server is returning anything!

Z:\>curl -k https://13.77.160.221/
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <link rel="stylesheet" type="text/css" href="/static/default.css">
    <title>Welcome to Azure Container Service (AKS)</title>

    <script language="JavaScript">
        function send(form){
        }
    </script>

</head>
<body>
    <div id="container">
        <form id="form" name="form" action="/"" method="post"><center>
        <div id="logo">Welcome to Azure Container Service (AKS)</div>
        <div id="space"></div>
        <img src="/static/acs.png" als="acs logo">
        <div id="form">
        </div>
    </div>
</body>
</html>

Create a DNS Zone

Accessing the cluster only by the IP is not ideal.

I want to get to the cluster by using a domain name. I am going to configure one of my custom domains to access the cluster.

First, we need to create a DNS Zone resource for our domain.

Z:\>az network dns zone create --resource-group=kube-demo-group -n idursun.dev
{
  "etag": "00000002-0000-0000-7f3b-15e695f3d401",
  "id": "/subscriptions/<redacted>/resourceGroups/kube-demo-group/providers/Microsoft.Network/dnszones/idursun.dev",
  "location": "global",
  "maxNumberOfRecordSets": 5000,
  "name": "idursun.dev",
  "nameServers": [
    "ns1-07.azure-dns.com.",
    "ns2-07.azure-dns.net.",
    "ns3-07.azure-dns.org.",
    "ns4-07.azure-dns.info."
  ],
  "numberOfRecordSets": 2,
  "registrationVirtualNetworks": null,
  "resolutionVirtualNetworks": null,
  "resourceGroup": "kube-demo-group",
  "tags": {},
  "type": "Microsoft.Network/dnszones",
  "zoneType": "Public"
}

We are interested in the name server values that are printed as a part of the JSON response. I am going to enter these values to my domain registrar’s portal so that the domain resolves into our DNS Zone.

In my registrar’s portal, it looks like this:

NOTE: Don’t forget to include trailing dots.

DNS propagation may take many hours to complete.

You can run use nslookup to check if the operation is completed. The name of the primary name server should change to ns1-07.azure-dns.com.

Z:\>nslookup -type=SOA idursun.dev
...snip...
idursun.dev
        primary name server = ns1-07.azure-dns.com
...snip...

When a user types the domain into their browser, they will be taken to the Azure DNS Zonec, but Azure doesn’t know what IP to redirect to. We need to add an A-type record-set in our DNS Zone to point our domain to the cluster’s external IP.

Z:\>az network dns record-set a add-record --resource-group=kube-demo-group -z idursun.dev -n @ -a 13.77.160.221
{
  "arecords": [
    {
      "ipv4Address": "13.77.160.221"
    }
  ],
  "etag": "5b0a3609-ab6b-4ca2-bb62-c86e978757f7",
  "fqdn": "idursun.dev.",
  "id": "/subscriptions/<redacted>/resourceGroups/kube-demo-group/providers/Microsoft.Network/dnszones/idursun.dev/A/@",
  "metadata": null,
  "name": "@",
  "provisioningState": "Succeeded",
  "resourceGroup": "kube-demo-group",
  "targetResource": {
    "id": null
  },
  "ttl": 3600,
  "type": "Microsoft.Network/dnszones/A"
}

Let’s update our aks-helloworld-chart by adding our host value.

ingress:
  enabled: true
  annotations:
    kubernetes.io/ingress.class: nginx
    # kubernetes.io/tls-acme: "true"
  hosts:
    - host: idursun.dev
      paths:
        - /

I have added a host value to point to our custom domain.

Let’s bump the chart version and deploy again.

Z:\>helm upgrade aks-helloworld aks-helloworld-chart
Release "aks-helloworld" has been upgraded. Happy Helming!
LAST DEPLOYED: Mon Apr 15 16:26:22 2019
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Deployment
NAME                                 READY  UP-TO-DATE  AVAILABLE  AGE
aks-helloworld-aks-helloworld-chart  1/1    1           1          115m

==> v1/Pod(related)
NAME                                                  READY  STATUS   RESTARTS  AGE
aks-helloworld-aks-helloworld-chart-599d9658f6-4gvjt  1/1    Running  0         115m

==> v1/Service
NAME                                 TYPE       CLUSTER-IP   EXTERNAL-IP  PORT(S)  AGE
aks-helloworld-aks-helloworld-chart  ClusterIP  10.0.242.36  <none>       80/TCP   115m

==> v1beta1/Ingress
NAME                                 HOSTS        ADDRESS  PORTS  AGE
aks-helloworld-aks-helloworld-chart  idursun.com  80       95m


NOTES:
1. Get the application URL by running these commands:
  http://idursun.dev/

We should be able to navigate to our cluster by using the domain.

curl -L http://idursun.dev/

If you try to reach the website from a browser, you will be redirected to https because of the default HSTS policy. Majority of the browsers will refuse to load the website because it doesn’t have a browser-trusted certificate.

Let’s fix this!

TLS Certificate

I am going to use letsencrypt.org to obtain a TLS certificate.

Let’s Encrypt is a well-known, non-profit certificate authority. Certificates issued by ‘let’s encrypt’ are valid for 3 months and needs to be renewed afterwards. Renewal of the certificate can be done manually or can be automated by running an ACME client.

Luckily, there is an add-on called cert-manager for kubernetes which can automate the whole process for us. We are going to install it next but before we do that there is one more thing we need to do.

We need to add CAA record-set to our DNS zone to make it clear that our certificate issuer is letsencrypt.org. This record is checked as a part of baseline requirements by many CAs as they are required to do to be trusted by major browsers.

Z:\>az network dns record-set caa add-record -g kube-demo-group -z idursun.dev -n @ --flags 0 --tag "issue" --value "letsencrypt.org"
{
  "caaRecords": [
    {
      "flags": 0,
      "tag": "issue",
      "value": "letsencrypt.org"
    }
  ],
  "etag": "26a7e752-aac4-4816-87dd-ee7a8dc3e718",
  "fqdn": "idursun.dev.",
  "id": "/subscriptions/<redacted>/resourceGroups/kube-demo-group/providers/Microsoft.Network/dnszones/idursun.dev/CAA/@",
  "metadata": null,
  "name": "@",
  "provisioningState": "Succeeded",
  "resourceGroup": "kube-demo-group",
  "targetResource": {
    "id": null
  },
  "ttl": 3600,
  "type": "Microsoft.Network/dnszones/CAA"
}

You can use https://caatest.co.uk/ to check if your CAA record is set up correctly.

Install cert-manager

cert-manager is an open source kubernetes add-on by jetstack that automates issuance and renewal of TLS certificates.

I have installed cert-manager:0.7 by following their installation guide.

Z:\>kubectl apply -f https://raw.githubusercontent.com/jetstack/cert-manager/release-0.7/deploy/manifests/00-crds.yaml
customresourcedefinition.apiextensions.k8s.io/certificates.certmanager.k8s.io created
customresourcedefinition.apiextensions.k8s.io/challenges.certmanager.k8s.io created
customresourcedefinition.apiextensions.k8s.io/clusterissuers.certmanager.k8s.io created
customresourcedefinition.apiextensions.k8s.io/issuers.certmanager.k8s.io created
customresourcedefinition.apiextensions.k8s.io/orders.certmanager.k8s.io created

Z:\>kubectl create namespace cert-manager
namespace/cert-manager created

Z:\>kubectl label namespace cert-manager certmanager.k8s.io/disable-validation=true
namespace/cert-manager labeled

Z:\>helm repo add jetstack https://charts.jetstack.io
"jetstack" has been added to your repositories

Z:\>helm repo update
Hang tight while we grab the latest from your chart repositories...
...Skip local chart repository
...Successfully got an update from the "jetstack" chart repository
...Successfully got an update from the "stable" chart repository
Update Complete. ⎈ Happy Helming!⎈

Z:\>helm install --name cert-manager --namespace cert-manager --version v0.7.0 jetstack/cert-manager
NAME:   cert-manager
LAST DEPLOYED: Mon Apr 15 20:52:37 2019
NAMESPACE: cert-manager
STATUS: DEPLOYED
...snip...

Create an issuer

We have cert-manager up and running.

Next thing to do is to create an issuer resource to kick off requesting a TLS certificate from letsencrypt.

Let’s create a file with the following content and name it issuer-prod.yaml.

apiVersion: certmanager.k8s.io/v1alpha1
kind: Issuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    # You must replace this email address with your own.
    # Let's Encrypt will use this to contact you about expiring
    # certificates, and issues related to your account.
    email: <your email here>
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      # Secret resource used to store the account's private key.
      name: letsencrypt-prod
    # Enable the HTTP01 challenge mechanism for this Issuer
    http01: {}

NOTE: Don’t forget to change the email.

Z:\>kubectl apply -f issuer-prod.yaml
issuer.certmanager.k8s.io/letsencrypt-prod created

Now we can enable TLS in our aks-helloworld-chart chart and configure it to use the issuer that we have just created.

ingress:
  enabled: true
  annotations:
    kubernetes.io/ingress.class: nginx
    certmanager.k8s.io/issuer: "letsencrypt-prod"
    certmanager.k8s.io/acme-challenge-type: http01
  hosts:
    - host: idursun.dev
      paths:
        - /

  tls:
    - secretName: cert-prod
      hosts:
        - idursun.dev

We have added certmanager.k8s.io/issuer annotation to specify which issuer to use and also set certmanager.k8s.io/acme-challenge-type value to http01 to match the challenge type of the issuer.

cert-manager should pick the changes and should handle the communication with letsencrypt and finally create a certificate resource with the name cert-prod.

Let’s bump the chart version and upgrade our chart once more.

Z:\>helm upgrade aks-helloworld aks-helloworld-chart

It might take a while to complete the request but eventually, we should see our certificate created.

Z:\>kubectl get certificate
NAME
cert-prod
Z:\>kubectl describe certificate/cert-prod

..snip...

Events:
  Type    Reason              Age   From          Message
  ----    ------              ----  ----          -------
  Normal  Generated           51s   cert-manager  Generated new private key
  Normal  GenerateSelfSigned  51s   cert-manager  Generated temporary self signed certificate
  Normal  OrderCreated        50s   cert-manager  Created Order resource "cert-prod-1408931963"
  Normal  OrderComplete       26s   cert-manager  Order "cert-prod-1408931963" completed successfully
  Normal  CertIssued          26s   cert-manager  Certificate issued successfully

Let’s navigate to our domain and check if the HTTPS connection is secure.

Conclusion

Phew!

That was quite a long post even though we have cut corners whenever we can.

I hope this would give an overall understanding of how various pieces of technology come together to create a kubernetes cluster that is capable of routing HTTP requests to the services inside the cluster as well as issuing a TLS certificate and keeping it up to date.

As a next step, you might create an Azure DevOps CI/CD pipeline that deploys your application straight from the git repository to the cluster.

Cheers!