Simple CI/CD deployment from Github to Kubernetes on Digital Ocean
I spent a bit of time rebuilding a small Go application recently and I wanted to implement an easy test, build, deploy pipeline to a Kubernetes instance I have on Digital Ocean. This took longer than I was expecting, so I will detail what I did in this post both for whoever needs it as well as my future self.
Note: this is a very simple approach, it just tests, builds and deploys and effectively hopes for the best. There is no blue/green deployment or anything else that fancy.
Throughout this post it is assumed that the application name is "appname" and the namespace is also "appname".
End State
I wanted the following to happen when I pushed code to the master branch:
- Tests run against the application
- The application was built
- The Docker image was built
- Docker image pushed to private docker repository and tagged with a version number set in code
- Kubernetes manifests updated using the latest version
- New containers created using the new version
- Monitored kubernetes for success/failure
This involves a few pieces behind the scenes. At the application level, we need two main files: Dockerfile and Makefile.
Dockerfile
This is the default Dockerfile I use for Go applications. It's super light and secure, and the images are tiny.
FROM golang:alpine as builder
ENV GO111MODULE on
RUN mkdir -p /build
WORKDIR /build
COPY go.mod go.sum ./
RUN apk add git && go mod download
ADD . .
RUN mkdir -p /app
# If you have static assets like templates
COPY tpl /app/tpl
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags '-extldflags "-static"' -o /app/appname .
# FROM alpine
FROM gcr.io/distroless/base
COPY --from=builder /app /app
WORKDIR /app
EXPOSE 80 8080
CMD ["./appname"]
Makefile
The Makefile contains quite a few different pieces but I'll drop in only the relevant ones.
The version is kept in the codebase in the following line:
var version = "0.1"
Which allows the grep
and sed
commands to work correctly. The Makefile looks like this:
version=$(shell grep "version" main.go | sed 's/.*"\(.*\)".*/\1/')
version:
echo ${version}
publish:
docker build --rm -t username/appname:${version} . && docker push username/appname:${version}
update:
VERSION=${version} envsubst < k8s/deployment.yml | kubectl apply -n appname -f -
kubectl apply -f k8s/service.yml -f k8s/ingress.yml -n appname
Now we can use make version
to see the version, make publish
to build and publish a docker image with the latest version and make update
to deploy the latest version to the Kubernetes cluster.
Requirements
A lot is required in the background to make this work. There are secrets to be stored in Github, and then secrets that I keep in Kubernetes. The Github secrets are used for Docker tasks, and the Kubernetes secrets are used for environment variables.
The Github secrets are as follows and pretty self-explanatory:
The Digital Ocean access token can be found in the API section in the dashboard. The Docker username and password is what you use to log in to Dockerhub (where I keep my images).
The following is used to create the Kubernetes secrets:
kubectl create secret docker-registry docker-registry -n appname \
--docker-username=DOCKER_USERNAME \
--docker-password=DOCKER_PASSWORD \
--docker-email=DOCKER_EMAIL
kubectl create secret generic newsbias-db-details -n appname \
--from-literal="username=digital-ocean-db-user" \
--from-literal="password=digital-ocean-db-password" \
--from-literal="host=digital-ocean-db-host" \
--from-literal="port=digital-ocean-db-port"
kubectl create secret generic prod-route53-credentials-secret -n cert-manager \
--from-literal="secret-access-key=SECRET_KEY"
The first secret is for Kubernetes to be able to pull down the images from the private Docker registry.
The second secret is environment variable related - these are passed in to the application as environment variables. This is really cool, because you have can have multiple environments (dev, staging, prod) and just use different namespaces to store the relevant variables. No changes to pipelines.
The third secret is used by the Let's Encrypt cert manager to automatically configure the DNS, where my DNS is on AWS. I've had too much hassle using web-based validation to just skip it. It's mainly due to the way I use the load balancers at Digital Ocean, as they pass the traffic through and this causes trouble. The DNS validation is much more preferred now.
Manifests
Provisioning
There are a few manifests that I use to spin up a new Kubernetes cluster which I thought would be handy for anyone doing the same.
They are listed in order of application. The pre-requisite for this step is to have a Kubernetes cluster created.
namespace.yml
apiVersion: v1
kind: Namespace
metadata:
name: appname
ingress.yml
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: newsbias-ingress
namespace: appname
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/server-snippet: |-
location /metrics { return 404; }
location /debug { return 404; }
spec:
tls:
- hosts:
- appname.com
secretName: appname-tls
rules:
- host: appname.com
http:
paths:
- backend:
serviceName: appname
servicePort: 80
ingress-controller.yml
controller:
image:
repository: k8s.gcr.io/ingress-nginx/controller
tag: "v0.43.0"
containerPort:
http: 80
https: 443
# Will add custom configuration options to Nginx https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/
config:
compute-full-forwarded-for: "true"
use-forwarded-headers: "true"
use-proxy-protocol: "true"
## Election ID to use for status update
electionID: ingress-controller-leader
ingressClass: nginx
kind: DaemonSet
# Define requests resources to avoid probe issues due to CPU utilization in busy nodes
# ref: https://github.com/kubernetes/ingress-nginx/issues/4735#issuecomment-551204903
# Ideally, there should be no limits.
# https://engineering.indeedblog.com/blog/2019/12/cpu-throttling-regression-fix/
resources:
limits:
cpu: 100m
memory: 90Mi
requests:
cpu: 100m
memory: 90Mi
service:
enabled: true
annotations:
# https://developers.digitalocean.com/documentation/v2/#load-balancers
# https://www.digitalocean.com/docs/kubernetes/how-to/configure-load-balancers/
service.beta.kubernetes.io/do-loadbalancer-name: "KubernetesLoadBalancer"
service.beta.kubernetes.io/do-loadbalancer-hostname: lb.thenewsbias.com
service.beta.kubernetes.io/do-loadbalancer-enable-proxy-protocol: "true"
service.beta.kubernetes.io/do-loadbalancer-algorithm: "round_robin"
service.beta.kubernetes.io/do-loadbalancer-healthcheck-port: "80"
service.beta.kubernetes.io/do-loadbalancer-healthcheck-protocol: "http"
service.beta.kubernetes.io/do-loadbalancer-healthcheck-path: "/healthz"
service.beta.kubernetes.io/do-loadbalancer-protocol: "tcp"
# service.beta.kubernetes.io/do-loadbalancer-http-ports: "80"
# service.beta.kubernetes.io/do-loadbalancer-tls-ports: "443"
service.beta.kubernetes.io/do-loadbalancer-tls-passthrough: "true"
# lb-small, lb-medium, lb-large
service.beta.kubernetes.io/do-loadbalancer-size-slug: "lb-small"
# loadBalancerIP: ""
loadBalancerSourceRanges: []
externalTrafficPolicy: "Local"
enableHttp: true
enableHttps: true
# specifies the health check node port (numeric port number) for the service. If healthCheckNodePort isn’t specified,
# the service controller allocates a port from your cluster’s NodePort range.
# Ref: https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/#preserving-the-client-source-ip
# healthCheckNodePort: 0
ports:
http: 80
https: 443
targetPorts:
http: http
https: https
type: LoadBalancer
nodePorts:
http: "30021"
https: "30248"
tcp: {}
udp: {}
admissionWebhooks:
enabled: true
# failurePolicy: Fail
failurePolicy: Ignore
timeoutSeconds: 10
Cert manager
Install the cert manager using the following command:
kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.6.1/cert-manager.yaml
cluster-issuers.yml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
namespace: kube-system
spec:
acme:
# The ACME server URL
server: https://acme-v02.api.letsencrypt.org/directory
# Email address used for ACME registration
email: email@domain.com
# Name of a secret used to store the ACME account private key
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- dns01:
route53:
accessKeyID: ACCESS_KEY_ID
region: eu-east-1
role: ""
secretAccessKeySecretRef:
key: secret-access-key
name: prod-route53-credentials-secret
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
namespace: kube-system
spec:
acme:
# The ACME server URL
server: https://acme-staging-v02.api.letsencrypt.org/directory
# Email address used for ACME registration
email: email@domain.com
# Name of a secret used to store the ACME account private key
privateKeySecretRef:
name: letsencrypt-staging
solvers:
- dns01:
route53:
accessKeyID: ACCESS_KEY_ID
region: eu-east-1
role: ""
secretAccessKeySecretRef:
key: secret-access-key
name: prod-route53-credentials-secret
cert-manager.yml
installCRDs: true
resources:
requests:
cpu: 50m
memory: 200Mi
limits:
cpu: 50m
memory: 400Mi
webhook:
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 10m
memory: 32Mi
cainjector:
resources:
requests:
cpu: 50m
memory: 200Mi
limits:
cpu: 50m
memory: 400Mi
There we go - new cluster spun up and ready to get the services and deployments done.
Application
There are only two scripts for the application - service and deployment.
service.yml
apiVersion: v1
kind: Service
metadata:
name: appname
namespace: appname
spec:
selector:
app: appname
type: ClusterIP
ports:
- port: 80
targetPort: 8080
protocol: TCP
name: http
deployment.yml
apiVersion: apps/v1
kind: Deployment
metadata:
name: appname
namespace: appname
spec:
replicas: 1
selector:
matchLabels:
app: appname
template:
metadata:
labels:
app: appname
spec:
imagePullSecrets:
- name: docker-registry
containers:
- name: appname
image: username/appname:$VERSION
env:
- name: DB_NAME
value: "appname-db-name"
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: appname-db-details
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: appname-db-details
key: password
- name: DB_HOST
valueFrom:
secretKeyRef:
name: appname-db-details
key: host
- name: DB_PORT
valueFrom:
secretKeyRef:
name: appname-db-details
key: port
- name: STATIC_ENVIRONMENT_VARIABLE
value: "example"
imagePullPolicy: Always
ports:
- containerPort: 8080
resources:
limits:
cpu: 500m
memory: 1Gi
requests:
cpu: 100m
memory: 500Mi
If you take a look at the image line, you see $VERSION
being used. This is where the magic happens.
Let's bring the Makefile line back:
VERSION=${version} envsubst < k8s/deployment.yml | kubectl apply -n newsbias -f -
The version is grepped from the file, and then using envsubst
it is applied to the file, replacing $VERSION
with whatever is in the environment variable.
Github Actions
The final task was to put this all together in a Github action.
# Change this to whenever you want this to run
on: [push, pull_request]
name: Workflow
jobs:
test:
strategy:
matrix:
go-version: [1.17.x]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- name: Checkout code
uses: actions/checkout@v2
- name: Test
run: go test
- name: Coverage
run: go test -cover
- name: Build
run: go build
deploy:
strategy:
matrix:
go-version: [ 1.17.x ]
os: [ ubuntu-latest ]
runs-on: ${{ matrix.os }}
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- name: Checkout code
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker
run: make publish
- name: Checkout code
uses: actions/checkout@v2
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
- name: Save DigitalOcean kubeconfig with short-lived credentials
run: doctl kubernetes cluster kubeconfig save --expiry-seconds 600 digital-ocean-kubernetes-cluster-name
- name: Deploy to DigitalOcean Kubernetes
run: make update
- name: Verify deployment
run: kubectl rollout status deployment/appname -n appname
The first part is used just for testing, soon I'll add a line to upload the code coverage to CodeCov which I've found to be a great service.
The second part, deploy
, is where the magic happens. The steps are above, but basically:
- Log in to Docker
- Build and publish the image using the version
- Checkout the code (so we can run the kubernetes manifests)
- Install doctl to get Kubernetes access temporarily for a specific cluster (this can also be in a secret to be fully generic)
- Deploy using
make update
- Verify the deployment
The end result is code is pushed, built and deployed automtically.
Conclusion
These are the scripts and flows I use to get a really simple CI/CD pipeline set up where everything is secure and secrets are secret. I plan to create a public Github repo with these scripts as well as ones for Bitbucket and other providers.
If you found this post interesting, subscribe to get posts delivered to your inbox.