How-To: Automatic SSL Certificate Management for your Kubernetes Application Deployment

Jaroslav Pantsjoha
Contino Engineering
12 min readOct 24, 2019

--

A bit of the Same, But Different. We deployed the app, but Let’s ensure our SSL Certificate is managed automatically for our Application Deployment.

Welcome back, or welcome for the very first time. This Security themed post will take the example of the previous blog post, “How-to: Kubernetes Application Deployment with DNS management” — which exemplifies the relative simplicity in application deployment with automatic DNS management, and takes it to the next “production-closer” level.

Let’s ensure such an application deployment process is matched with the SSL certificate. This post will lead it’s way into the GitOps deployment methodology again. The purpose of which is to codify your infrastructure, store it in Git and let the likes of Weaveworks Flux (https://github.com/fluxcd/flux) deploy it automatically, and in full.

Your System Requirements

This may be obvious, but you will need git, google-cloud-sdk and kubectl installed on whichever system you are running this.

As I do intend the hands-on number of your readers to be able to duplicate the success, so do ensure you read the First Blog Post, which outlines the Google DNS zone requirement and the follow-up application configuration, from here. The full code example with yet-another DNS zone, but the same service configuration, with yet full working code example, is found on my own bitbucket.

There is a number of dependencies to get this off the ground, to reproduce this, you will require to ensure the following checklist is in order;

  • Your Google Cloud DNS zone is setup
  • Kubernetes Cluster is created
  • Kubernetes Demo Application is running from the previous post.
  • Get ready to start pasting more…

Where were we

Before getting into the meat of this, let’s fast forward this dependency prep formality.

DNS ZONE is the same as before.

I’ve deployed the same Kubernetes cluster, via Google Cloud gcloud cli utility.

FROM

PROJECT=jaroslav-pantsjoha-contino
CLUSTER_NAME=jpworks-cluster
gcloud beta container --project "$PROJECT" clusters create "$CLUSTER_NAME" \
--zone "us-central1-a" --no-enable-basic-auth \
--cluster-version "1.14.6-gke.2" --machine-type "n1-standard-2" \
--image-type "COS" --disk-type "pd-ssd" --disk-size "50" \
--metadata disable-legacy-endpoints=true \
--scopes "https://www.googleapis.com/auth/cloud-platform" \
--preemptible --num-nodes "2" \
--enable-cloud-logging \
--enable-stackdriver-kubernetes --enable-ip-alias \
--network "projects/$PROJECT/global/networks/default" \
--subnetwork "projects/$PROJECT/regions/us-central1/subnetworks/default" \
--default-max-pods-per-node "110" --addons HorizontalPodAutoscaling,HttpLoadBalancing \
--enable-autoupgrade \
--enable-autorepair

TO

kubeconfig entry generated for jpworks-cluster.NAME             LOCATION       MASTER_VERSION  MASTER_IP       MACHINE_TYPE   NODE_VERSION  NUM_NODES  STATUSjpworks-cluster  us-central1-a  1.14.6-gke.2    35.226.209.236  n1-standard-2  1.14.6-gke.2  2          RUNNING

Deploy the very same Demo Nginx App, Ingress, and the DNS Manager;

k apply -f external-dns
deployment.extensions/external-dns created
serviceaccount/external-dns created
clusterrole.rbac.authorization.k8s.io/external-dns created
clusterrolebinding.rbac.authorization.k8s.io/external-dns-viewer created
k apply -f nginx-app/
configmap/nginx-configuration created
deployment.extensions/nginx-app created
service/nginx-service created
k apply -f ingress.yaml
ingress.extensions/nginx-ingress-default created

Verify. Same old, same old.

k get po,ing,svc
NAME READY STATUS RESTARTS AGE
pod/external-dns-8947bd5b9-zx98s 1/1 Running 0 6m20s
pod/nginx-app-6979bdd88f-bqtmb 1/1 Running 0 6m12s
NAME HOSTS ADDRESS PORTS AGE
ingress.extensions/nginx-ingress-default nginx.jpworks.squadzero.io 35.241.22.96 80 5m47s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/kubernetes ClusterIP 10.0.0.1 <none> 443/TCP 12m
service/nginx-service LoadBalancer 10.0.1.23 10.128.0.62 80:31338/TCP 6m13s

This does get some (little) amount of time to get DNS records propagated throughout, — taken me closer to 10 minutes to finally see it work here.

$ curl -I nginx.jpworks.squadzero.io
HTTP/1.1 200 OK
Server: nginx
Date: Thu, 24 Oct 2019 11:52:18 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 55
Last-Modified: Thu, 24 Oct 2019 11:41:44 GMT
ETag: "5db18df8-37"
Accept-Ranges: bytes
Via: 1.1 google
You have new mail in /var/mail/jp
Jaroslavs-MacBook-Pro:external-dns-demo jp$ curl nginx.jpworks.squadzero.io
<h1>hello world</h1><p>flux-kubernetes-demos/nginx</p>

SSL Manage This

Now that the environment setup and formalities are out of the way with the prereqs, let’s get to the bit what you came here for — Let’s get this under SSL Certificate and auto-manage this toil! This is a SSL Certificate for GKE Ingress — a GCP Cloud Provider-based Demo.

There are key points worthy to go-over for this particular cert-manager demonstration;

  • I am using the Let’s Encrypt Certificate Authority for this demo. There are a few to choose from.
  • I am using the DNS01 validation, there are other methods like http01 possible
  • My demo is based on the GCP platform and the relevant service-account access control setup is required.

The documentation on the cert-manager website is quite handy, but it is the usual case of piecing-up all the components together to get it to work, particularly https://docs.cert-manager.io/en/latest/tutorials/acme/dns-validation.html

Go Time. Let’s get our public-facing Nginx App Certifi’ca’ted with SSL certs.

Apply the cert-manager YAML to your cluster. You need is to deploy the latest-greatest cert-manager latest, straight into Kubernetes environment. (https://docs.cert-manager.io/en/latest/tutorials/venafi/securing-ingress.html#installing-cert-manager)

FROM

kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v0.10.1/cert-manager.yaml

TO

customresourcedefinition.apiextensions.k8s.io/certificaterequests.certmanager.k8s.io created
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
namespace/cert-manager created
serviceaccount/cert-manager-cainjector created
serviceaccount/cert-manager created
serviceaccount/cert-manager-webhook created
clusterrole.rbac.authorization.k8s.io/cert-manager-cainjector created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-cainjector created
clusterrole.rbac.authorization.k8s.io/cert-manager-leaderelection created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-issuers created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-clusterissuers created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-certificates created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-orders created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-challenges created
clusterrole.rbac.authorization.k8s.io/cert-manager-controller-ingress-shim created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-leaderelection created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-issuers created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-clusterissuers created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-certificates created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-orders created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-challenges created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-controller-ingress-shim created
clusterrole.rbac.authorization.k8s.io/cert-manager-view created
clusterrole.rbac.authorization.k8s.io/cert-manager-edit created
clusterrolebinding.rbac.authorization.k8s.io/cert-manager-webhook:auth-delegator created
rolebinding.rbac.authorization.k8s.io/cert-manager-webhook:webhook-authentication-reader created
clusterrole.rbac.authorization.k8s.io/cert-manager-webhook:webhook-requester created
service/cert-manager created
service/cert-manager-webhook created
deployment.apps/cert-manager-cainjector created
deployment.apps/cert-manager created
deployment.apps/cert-manager-webhook created
apiservice.apiregistration.k8s.io/v1beta1.webhook.certmanager.k8s.io created
mutatingwebhookconfiguration.admissionregistration.k8s.io/cert-manager-webhook created
validatingwebhookconfiguration.admissionregistration.k8s.io/cert-manager-webhook created

This will create the namespace with all the dependencies ready to go. Now, as you’d expect we have to get a few things in order.

HouseKeeping Vars

# Global VARS to work with
PROJECT_ID=jaroslav-pantsjoha-contino
CLOUD_DNS_SA=dns01-solver

Setup the GCP Service Account & Secret KEY
This will address the Kubernetes-context Cert-Manager managing that DNS zone, as that would work via the dedicated service account.

gcloud iam service-accounts create dns01-solver# Create SA to access GCP dns services with 
gcloud iam service-accounts keys create key.json \
— iam-account $CLOUD_DNS_SA@$PROJECT_ID.iam.gserviceaccount.com
# Bind the role dns.admin to this service account, so it can be used to support the ACME DNS01 challenge.gcloud projects add-iam-policy-binding $PROJECT_ID \
— member serviceAccount:$CLOUD_DNS_SA \
— role roles/dns.admin

Create that K8 context Service Account secret to be used with the Cert-Manager.

kubectl create secret generic clouddns-$CLOUD_DNS_SA-svc-acct — from-file=key.json

This will create something along the lines of

ApiVersion: v1
kind: Secret
metadata:
namespace: cert-manager
name: clouddns-dns01-solver-svc-acct
type: Opaque
data:
key.json: blah-the-long-and-boring-line-of-encoded-key.json-contents-reference=

Create Cluster Issuer

These represent a certificate authority from which signed x509 certificates can be obtained, such as Let’s Encrypt, or your own signing key pair stored in a Kubernetes Secret resource. They are referenced by Certificate resources in order to request certificates from them. (https://docs.cert-manager.io/en/latest/tasks/issuers/index.html)

I have gone for the ClusterIssuer type so my certificates are managed for the global scope of the Kubernetes Cluster, not a particular namespace if it were a mere Issueralone. (https://docs.cert-manager.io/en/latest/reference/clusterissuers.html)

ClusterIssuers are a resource type similar to Issuers. They are specified in exactly the same way, but they do not belong to a single namespace and can be referenced by Certificate resources from multiple different namespaces.

FROM

apiVersion: certmanager.k8s.io/v1alpha1
kind: ClusterIssuer
metadata:
name: contino-cluster-cert-issuer
namespace: default
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: jaroslav.pantsjoha@contino.io
privateKeySecretRef:
name: jpworks-contino-cert-account-key
dns01:
providers:
- name: cloud-dns-provider
clouddns:
project: jaroslav-pantsjoha-contino
serviceAccountSecretRef:
name: clouddns-dns01-solver-svc-acct
key: key.json

TO

clusterissuer.certmanager.k8s.io/contino-cluster-cert-issuer createdk describe clusterissuer.certmanager.k8s.io/contino-cluster-cert-issuer
Name: contino-cluster-cert-issuer
Namespace:
Labels: <none>
Annotations: kubectl.kubernetes.io/last-applied-configuration:
.....
Spec:
Acme:
dns01:
Providers:
Clouddns:
Project: jaroslav-pantsjoha-contino
Service Account Secret Ref:
Key: key.json
Name: clouddns-dns01-solver-svc-acct
Name: cloud-dns-provider
Email: jaroslav.pantsjoha@contino.io
Private Key Secret Ref:
Name: jpworks-contino-cert-account-key
Server: https://acme-v02.api.letsencrypt.org/directory
Status:
Acme:
Last Registered Email: jaroslav.pantsjoha@contino.io
Uri: https://acme-v02.api.letsencrypt.org/acme/acct/<omitted>
Conditions:
Last Transition Time: 2019-10-24T12:28:05Z
Message: The ACME account was registered with the ACME server
Reason: ACMEAccountRegistered
Status: True # << --- This bit
Type: Ready # << ---- We're good to go!
Events: <none>

Create your Initial Certificate

As the name suggests, let’s request that certificate. Here we essentially have a CSR ‘form’, with the validation type. DNS01 is the validation method of choice. Coupled with the External-DNS service we have running on this Kubernetes cluster, this method should provide the automated Certificate setup and on-going renewal management.

FROM

apiVersion: certmanager.k8s.io/v1alpha1
kind: Certificate
metadata:
name: jpworks-squadzero-io-certificate
spec:
secretName: jpworks-squadzero-io-certificate
# The certificate common name, use one from your domains.
# we can do wildcard here to catch all zone domans
commonName: "*.jpworks.squadzero.io"
issuerRef:
kind: ClusterIssuer
name: contino-cluster-cert-issuer
acme:
config:
- dns01:
provider: cloud-dns-provider
domains:
# certificate wildcards or FQDN
- "*.jpworks.squadzero.io"
dnsNames:
# Provide same list as `domains` section.
- "*.jpworks.squadzero.io"

TO

$ k describe certificate.certmanager.k8s.io/jpworks-squadzero-io-certificate
Name: jpworks-squadzero-io-certificate
Namespace: default
Labels: <none>
Annotations: kubectl.kubernetes.io/last-applied-configuration:
......
Spec:
Acme:
Config:
dns01:
Provider: cloud-dns-provider
Domains:
*.jpworks.squadzero.io
Common Name: *.jpworks.squadzero.io
Dns Names:
*.jpworks.squadzero.io
Issuer Ref:
Kind: ClusterIssuer
Name: contino-cluster-cert-issuer
Secret Name: jpworks-squadzero-io-certificate
Status:
Conditions:
Last Transition Time: 2019-10-24T12:30:37Z
Message: Certificate issuance in progress. Temporary certificate issued.
Reason: TemporaryCertificate
Status: False
Type: Ready
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Generated 2m15s cert-manager Generated new private key
Normal GenerateSelfSigned 2m15s cert-manager Generated temporary self signed certificate
Normal OrderCreated 2m15s cert-manager Created Order resource "jpworks-squadzero-io-certificate-858028746"

Create Ingress to use that certificate, or update existing ones.

This is down to choice, we can update the existing ingress and have the annotation `kubernetes.io/ingress.allow-http: true` to ensure it’s backward compatible.

FROM

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
annotations:
certmanager.k8s.io/cluster-issuer: contino-cluster-cert-issuer
kubernetes.io/ingress.allow-http: "true"
name: secure-ingress
namespace: default
spec:
tls:
- hosts:
- secure.jpworks.squadzero.io
secretName: jpworks-squadzero-io-certificate

rules:
- host: secure.jpworks.squadzero.io
http:
paths:
- backend:
serviceName: nginx-service
servicePort: 80
path: /

TO

$ k get ingress
NAME HOSTS ADDRESS PORTS AGE
nginx-ingress-default nginx.jpworks.squadzero.io 35.241.22.96 80 54m
secure-ingress secure.jpworks.squadzero.io 34.98.102.173 80, 443 2m14s
$ k describe ing secure-ingress
Name: secure-ingress
Namespace: default
Address: 34.98.102.173
Default backend: default-http-backend:80 (10.40.1.6:8080)
TLS:
jpworks-squadzero-io-certificate terminates secure.jpworks.squadzero.io
Rules:
Host Path Backends
---- ---- --------
secure.jpworks.squadzero.io
/ nginx-service:80 (10.40.0.7:80)
Annotations:
ingress.kubernetes.io/ssl-cert: k8s-ssl-6d9affb27c1fd9d1-262a5bd4122769ef--d8e364bffd9eccef
ingress.kubernetes.io/static-ip: k8s-fw-default-secure-ingress--d8e364bffd9eccef
.....certmanager.k8s.io/cluster-issuer: contino-cluster-cert-issuer
ingress.kubernetes.io/backends: {"k8s-be-30875--d8e364bffd9eccef":"HEALTHY","k8s-be-31338--d8e364bffd9eccef":"HEALTHY"}
....
kubernetes.io/ingress.allow-http: true
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal ADD 4m17s loadbalancer-controller default/secure-ingress
Normal CREATE 3m38s loadbalancer-controller ip: 34.98.102.173

Our Services and Ingress Console view, after all these configurations, should look like this;

Waiting Game commences. Despite the seemingly complete configuration, the immediate result is that the cert-manager generated a self-signed certificate. This is a temporary certificate until it gets signed by the CA (Let’s Encrypt)

openssl s_client -showcerts -connect secure.jpworks.squadzero.io:443
CONNECTED(00000005)
depth=0 O = cert-manager, CN = *.jpworks.squadzero.io
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 O = cert-manager, CN = *.jpworks.squadzero.io
verify error:num=21:unable to verify the first certificate
verify return:1
---
Certificate chain
0 s:/O=cert-manager/CN=*.jpworks.squadzero.io
i:/O=cert-manager/CN=cert-manager.local ### <---
.....

We can tail the log from the cert-manager, as well as view the zone record.

k -n cert-manager logs -f cert-manager-84fc69dbdf-jbl9w....
E1024 12:51:46.465810 1 sync.go:183] cert-manager/controller/challenges "msg"="propagation check failed" "error"="DNS record for \"jpworks.squadzero.io\" not yet propagated" "dnsName"="jpworks.squadzero.io" "resource_kind"="Challenge" "resource_name"="jpworks-squadzero-io-certificate-858028746-0" "resource_namespace"="default" "type"="dns-01"
Waiting for the auto-generated DNS01 challenge to be approved — DNS propagation wait

Now, Wait ….a few more minutes

What is happening there?
-
Cert-Manager will discover the certificate requirement across the cluster (from ingress definitions)
- Cert-Manager will the automatically create the DNS challenge (you can view your DNS zone)
- Cert-Manager will then match/self-solve that DNS challenge, and issue a temp certificate
- Cert-Manager will get the designated CA (Let’s Encrypt) to sign it, to make it all official and ‘permanent’
- Cert-Manager will repeat this on every renewal date (every 3 months with Let’s Encrypt)

Short Time Later

k describe cert jpworks-squadzero-io-certificate
.....
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Generated 30m cert-manager Generated new private key
Normal GenerateSelfSigned 30m cert-manager Generated temporary self signed certificate
Normal OrderCreated 30m cert-manager Created Order resource "jpworks-squadzero-io-certificate-858028746"
Normal OrderComplete 7m12s cert-manager Order "jpworks-squadzero-io-certificate-858028746" completed successfully
Normal CertIssued 7m12s cert-manager Certificate issued successfully
k -n cert-manager logs -f cert-manager-84fc69dbdf-jbl9w"related_resource_kind"="Certificate" "related_resource_name"="jpworks-squadzero-io-certificate" "related_resource_namespace"="default" "resource_kind"="Ingress" "resource_name"="secure-ingress" "resource_namespace"="default"
I1024 12:56:27.509017 1 sync.go:208] cert-manager/controller/ingress-shim "level"=0 "msg"="certificate resource has no owner. refusing to update non-owned certificate resource for ingress" "related_resource_kind"="Certificate" "related_resource_name"="jpworks-squadzero-io-certificate" "related_resource_namespace"="default" "resource_kind"="Ingress" "resource_name"="secure-ingress" "resource_namespace"="default"
I1024 12:56:27.509144 1 controller.go:135] cert-manager/controller/ingress-shim "level"=0 "msg"="finished processing work item" "key"="default/secure-ingress"

Trust but Verify

Curl and OpenSSL validate the results, with;

$ curl -vI https://secure.jpworks.squadzero.io/
* Trying 34.98.102.173...
* TCP_NODELAY set
* Connected to secure.jpworks.squadzero.io (34.98.102.173) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* successfully set certificate verify locations:
* CAfile: /etc/ssl/cert.pem
CApath: none
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Client hello (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-CHACHA20-POLY1305
* ALPN, server accepted to use h2
* Server certificate:
* subject: CN=*.jpworks.squadzero.io
* start date: Oct 24 11:53:51 2019 GMT
* expire date: Jan 22 11:53:51 2020 GMT
* subjectAltName: host "secure.jpworks.squadzero.io" matched cert's "*.jpworks.squadzero.io"
* issuer: C=US; O=Let's Encrypt; CN=Let's Encrypt Authority X3
* SSL certificate verify ok.
* Using HTTP2, server supports multi-use
# OPENSSL check that$ openssl s_client -showcerts -connect secure.jpworks.squadzero.io:443
CONNECTED(00000005)
depth=2 O = Digital Signature Trust Co., CN = DST Root CA X3
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
verify return:1
depth=0 CN = *.jpworks.squadzero.io
verify return:1
---
Certificate chain
0 s:/CN=*.jpworks.squadzero.io
i:/C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X3
-----BEGIN CERTIFICATE-----
MIIFZTCCBE2gAwIBAgISA2Nob1YBGrHPmTnzq3a62WAoMA0GCSqGSIb3DQEBCwUA
MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD
ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0xOTEwMjQxMTUzNTFaFw0y

Curl-tested, OpenSSL-reviewed, let’s get that screenshot as well, shall we;

Result.

There.
Saved you a lot of manual effort and your application communication is secure. Time for Beers?

If you enjoyed this post and want to see more end-to-end Kubernetes demos, please clap and share the post

See you in the next post,
JP

PS There is a host of exciting Kubernetes projects taking place at Contino. If you are looking to work on the latest-greatest infrastructure stack or looking for a challenge, — Get in touch! We’re hiring!

We’re looking for bright minds at every level. At Contino, we pride ourselves on delivering the best practices cloud transformation projects, for medium-sized businesses to large enterprises.

JP

By the way, 👏🏻 *clap* 👏🏻 your hands (up to 50x) if you enjoyed this post. It encourages me to keep writing and help other people finding it :)

--

--

Jaroslav Pantsjoha
Contino Engineering

Google Cloud CoP Lead @ Cognizant. All Content and Views are my own.