Hướng dẫn deploy application to kubernetes từ gitlabci

In this blog post we’ll go through the steps of creating an automated deployment pipeline for Kubernetes using GitLab. In the end we’ll have a simple Go application running that very excitingly returns “Hello, World!”.

Prerequisites

Before we can begin our quest for automation, we’ll need to set up some tools. Many alternatives of course exist to the tools that I pick. Feel free to use any other option, but make sure to make any necessary changes if you are following along with this post.

Let’s begin with setting up a Kubernetes cluster. There are many ways to get one, and it does not really matter how you set one up. I’m personally happy with the eksctl utility which makes it really easy to set up an AWS EKS Cluster.

Please note that there is some pricing involved with spinning up this cluster. You pay $0.20 per hour for the Amazon EKS control plane. You’ll also pay $0.0228 per hour for the t3.small worker node that we spin up.

Check out the documentation on eksctl.io to install and configure the tool. Then you can spin up the Kubernetes cluster with the following command:

eksctl create cluster --name=go-hello-world --nodes=1 --node-type t3.small

This will take around 10 to 15 minutes. Once the cluster is created, you can set up your kubeconfig file using the AWS CLI’s update-kubeconfig command as follows:

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Healthy!")
    })
    http.ListenAndServe(":8080", nil)
}

0

Check to see if your worker node has properly registered with the following command:

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Healthy!")
    })
    http.ListenAndServe(":8080", nil)
}

1

Finally we’ll create a

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Healthy!")
    })
    http.ListenAndServe(":8080", nil)
}

2 service account that we’ll use to deploy to Kubernetes from GitLab. Create a file called

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Healthy!")
    })
    http.ListenAndServe(":8080", nil)
}

3 with the following contents:


apiVersion: v1 kind: ServiceAccount metadata: name: gitlab-service-account
apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding metadata: name: gitlab-service-account-role-binding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: cluster-admin subjects:
  • kind: ServiceAccount name: gitlab-service-account namespace: default

Apply these settings with the following command:

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Healthy!")
    })
    http.ListenAndServe(":8080", nil)
}

4

This will create a new service account and attach admin permissions to it. Keep in mind that in production environments you’ll definitely want to use a role with only the minimum permissions required.

Docker registry

Next up we’re going to set up a Docker registry to which we can push the Go “Hello, World!” application that we’ll dockerize. Feel free to use any registry that you’re familiar with. If you don’t have one yet, you can easily create one for free at the Docker Hub. You can also create a private repository if you don’t like sharing your Dockerfile with the whole world.

To get a sneak peek of what we’ll build, you can find my reposity in the Docker hub: sanderknape/go-hello-world.

Once you have an account, create a repository called

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Healthy!")
    })
    http.ListenAndServe(":8080", nil)
}

5.

GitLab

GitLab is free to use. If you don’t have an account yet, you can get one at the . After you have created an account, create a new repository and call it

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Healthy!")
    })
    http.ListenAndServe(":8080", nil)
}

6. It’s up to you if you set it up publicly or privately. My project is public so feel free to take a look.

To be able to push code from your laptop to the repository, you need to set up an SSH key. Check out the GitLab documentation to learn how to do this.

Specifying configuration

As we’re going to connect to both the Docker Hub and to Kubernetes, we need to specify some authentication configuration. When you’re in your repository, use the left menu to open up the

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Healthy!")
    })
    http.ListenAndServe(":8080", nil)
}

7. We’re going to add the following configuration:

  • package main import (

    "fmt"  
    "net/http"  
    
    ) func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {  
        fmt.Fprintf(w, "Hello, World!")  
    })  
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {  
        fmt.Fprintf(w, "Healthy!")  
    })  
    http.ListenAndServe(":8080", nil)  
    
    }

    8. This is the Docker user you use to login to the Docker Hub.
  • package main import (

    "fmt"  
    "net/http"  
    
    ) func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {  
        fmt.Fprintf(w, "Hello, World!")  
    })  
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {  
        fmt.Fprintf(w, "Healthy!")  
    })  
    http.ListenAndServe(":8080", nil)  
    
    }

    9. This is the Docker passwrod you use to login to the Docker Hub.
  • FROM golang:1.11-alpine as builder WORKDIR /usr/build ADD main.go . RUN go build -o app . FROM alpine:latest WORKDIR /usr/src COPY --from=builder /usr/build/app . EXPOSE 8080 CMD ["/usr/src/app"]

    0. This is the CA configuration for the Kubernetes cluster. For EKS, login to the AWS EKS console and open up your cluster configuration. You can find the

    FROM golang:1.11-alpine as builder WORKDIR /usr/build ADD main.go . RUN go build -o app . FROM alpine:latest WORKDIR /usr/src COPY --from=builder /usr/build/app . EXPOSE 8080 CMD ["/usr/src/app"]

    1 on the right.
  • FROM golang:1.11-alpine as builder WORKDIR /usr/build ADD main.go . RUN go build -o app . FROM alpine:latest WORKDIR /usr/src COPY --from=builder /usr/build/app . EXPOSE 8080 CMD ["/usr/src/app"]

    2. This is the endpoint to the Kubernetes API for our cluster. You can find this on the page where you already are.
  • FROM golang:1.11-alpine as builder WORKDIR /usr/build ADD main.go . RUN go build -o app . FROM alpine:latest WORKDIR /usr/src COPY --from=builder /usr/build/app . EXPOSE 8080 CMD ["/usr/src/app"]

    3. This is the token for the user that we’ll use to connect to the Kubernetes cluster. We need to find the token for the user that we created earlier. First, list all secrets with

    FROM golang:1.11-alpine as builder WORKDIR /usr/build ADD main.go . RUN go build -o app . FROM alpine:latest WORKDIR /usr/src COPY --from=builder /usr/build/app . EXPOSE 8080 CMD ["/usr/src/app"]

    4. There will be a secret starting with

    FROM golang:1.11-alpine as builder WORKDIR /usr/build ADD main.go . RUN go build -o app . FROM alpine:latest WORKDIR /usr/src COPY --from=builder /usr/build/app . EXPOSE 8080 CMD ["/usr/src/app"]

    5, which is the token for the GitLab user we created earlier. Copy the NAME for this secret, and run the following command to see the token:

    FROM golang:1.11-alpine as builder WORKDIR /usr/build ADD main.go . RUN go build -o app . FROM alpine:latest WORKDIR /usr/src COPY --from=builder /usr/build/app . EXPOSE 8080 CMD ["/usr/src/app"]

    6. Copy the token that is part of the output, and enter it in GitLab.

Be sure to enable the

FROM golang:1.11-alpine as builder
WORKDIR /usr/build
ADD main.go .
RUN go build -o app .
FROM alpine:latest
WORKDIR /usr/src
COPY --from=builder /usr/build/app .
EXPOSE 8080
CMD ["/usr/src/app"]

7 flag for at least the

FROM golang:1.11-alpine as builder
WORKDIR /usr/build
ADD main.go .
RUN go build -o app .
FROM alpine:latest
WORKDIR /usr/src
COPY --from=builder /usr/build/app .
EXPOSE 8080
CMD ["/usr/src/app"]

0, the

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Healthy!")
    })
    http.ListenAndServe(":8080", nil)
}

9 and the

FROM golang:1.11-alpine as builder
WORKDIR /usr/build
ADD main.go .
RUN go build -o app .
FROM alpine:latest
WORKDIR /usr/src
COPY --from=builder /usr/build/app .
EXPOSE 8080
CMD ["/usr/src/app"]

3. Click

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

1.

Building our Docker image

It’s finally time to get to the good stuff. All code that we’re going to write can be found in my GitLab repository. We’ll set up this repository step by step.

First, create a new directory and run

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

2. Follow the instructions in your GitLab repository to “sync” your local repository with it. Let’s create a dockerized Go app first that we’ll push to that repo.

The Go application is a super simple webserver that just returns “Hello, World!”. Create a file called

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

3 and add the following content:

package main
import (
    "fmt"
    "net/http"
)
func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Healthy!")
    })
    http.ListenAndServe(":8080", nil)
}

If you have Go installed you can run the webserver as follows (if you don’t have it installed, you can also wait with running it until we have dockerized the app in a minute):

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

4

You can open up your browser, navigate to

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

5 and you should see the fine words “Hello, World!”.

Next up, let’s create the Dockerfile. In the same directory create a new file called

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

6 and add the following content:

FROM golang:1.11-alpine as builder
WORKDIR /usr/build
ADD main.go .
RUN go build -o app .
FROM alpine:latest
WORKDIR /usr/src
COPY --from=builder /usr/build/app .
EXPOSE 8080
CMD ["/usr/src/app"]

If you are not familiar with multi-stage builds this may look a little confusing. We first build the application in the official Golang Docker image. As this image contains all the tools required to build Go images (and more), this image is a little over 100MB. However, to actually run the application, all we really need is just a bare-bones OS.

Therefore, starting at line 6, we build the final Docker image based on the alpine OS. This is only about 6MB in size. We grab the application artifact created earlier from that build, and put it in

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

7. We then tell Docker to start the container by running our app in the last line.

Build this Docker image as follows:

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

8

Next, be sure that the Go application we tested earlier isn’t still running. It would fail the next command as port 8080 is then already in use. We can now run our application through Docker as follows:

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

9

Open up your browser again and visit

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

5. You should again see the famous words.

Building Docker in GitLab

The next step is to build this Docker image in GitLab and push it to our Docker registry. Create a new file

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

1 and add the following content:

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

As we’re going to build a Docker image inside of another Docker image, we enable the Docker in Docker service. Next we’ll use the predefined variable

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

2 to tag the image. We do this as we want to know exactly which code from our Git repository this Dockerfile contains. In addition, if we would simply use

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

3, rollbacks wouldn’t work in Kubernetes as rolling back from

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

3 to

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

3 doesn’t make a lot of sense.

In the

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

6 steps we use the previously set environment variables to connect with the Docker hub. We then build and push the Docker image to our repository.

Push the three files that we created to your GitLab repository. This will automatically trigger the build job. Through the left navigation, go to

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

7 and open up your job. You should see a succesful push to Docker Hub. Navigate to Docker hub and you should find the first Docker image!

Hướng dẫn deploy application to kubernetes từ gitlabci

Kubernetes deployments

With our Docker image now available to be consumed, it’s time to push it to our Kubernetes cluster.

We’re going to create a Kubernetes Deployment. This is a Kubernetes resource that wraps Docker containers and controls their lifecycle. It makes sure to restart the containers if they are stopped and ensures that the right amount of containers is running. It can also perform rolling updates and use health checks to see if the containers are still working.

Create a new file called

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

8 and add the following content:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

The configuration also contains rolling updates configuration and health checks (the liveness and readiness probes). Though we won’t really touch these in this blog post, you can change these settings to get a better feeling for how deployments work.

Find the

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

9 and replace it with the tag that you pushed earlier to the Docker Hub. This is only temporary: we’ll replace this later once we create the GitLab pipeline.

Assuming you’ve correctly configured your

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

0 earlier, you are now able to deploy this image to your Kubernetes cluster with the following command:

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

1

Run a

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

2 and you should see output similar to the following:

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

It may be that the status is still in

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

3, if the image is still being downloaded (luckily only just 5MB!).

To ensure our pods our working, let’s set up a proxy to one of the containers. First, ensure that your previous tests with Go and the Docker image are not still running. Opening up

image: docker:latest
services:
  • docker:dind stages:
  • build variables: CONTAINER_IMAGE: sanderknape/go-hello-world:${CI_COMMIT_SHORT_SHA} build: stage: build script:
    • docker login -u ${DOCKER_USER} -p ${DOCKER_PASSWORD}
    • docker build -t ${CONTAINER_IMAGE} .
    • docker tag ${CONTAINER_IMAGE} ${CONTAINER_IMAGE}
    • docker tag ${CONTAINER_IMAGE} sanderknape/go-hello-world:latest
    • docker push ${CONTAINER_IMAGE}

5 should give a connection-refused error. Copy/paste one of the names of the pods and run the following command:

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

5

Open up your browser again and you should once again see “Hello, World!”. This time coming from your Kubernetes cluster.

Deployment through GitLab

Next up we’re going to run this deployment through GitLab. First remove the deployment we just created with the following command:

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

6

Ensure that no pods are running with

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

2. Now, replace the SHA you added earlier with the string

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

9 again. This will be replaced with the latest SHA in our GitLab pipeline.

Add the following new stage at the end of your

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

1 file:

deploy:
  stage: deploy
  image: dtzar/helm-kubectl
  script:
  • kubectl config set-cluster k8s --server="${SERVER}"
  • kubectl config set clusters.k8s.certificate-authority-data ${CERTIFICATE_AUTHORITY_DATA}
  • kubectl config set-credentials gitlab --token="${USER_TOKEN}"
  • kubectl config set-context default --cluster=k8s --user=gitlab
  • kubectl config use-context default
  • sed -i "s//${CI_COMMIT_SHORT_SHA}/g" deployment.yaml
  • kubectl apply -f deployment.yaml

We use an existing Docker image that already has

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

0 installed. We then configure the cluster, user and context with the environment variables we set earlier so that we can connect to the cluster. When setting the cluster we can unfortunately not directly set the

deploy:
  stage: deploy
  image: dtzar/helm-kubectl
  script:
  • kubectl config set-cluster k8s --server="${SERVER}"
  • kubectl config set clusters.k8s.certificate-authority-data ${CERTIFICATE_AUTHORITY_DATA}
  • kubectl config set-credentials gitlab --token="${USER_TOKEN}"
  • kubectl config set-context default --cluster=k8s --user=gitlab
  • kubectl config use-context default
  • sed -i "s//${CI_COMMIT_SHORT_SHA}/g" deployment.yaml
  • kubectl apply -f deployment.yaml

1 as no flag exists for it. There is a GitHub issue open for this. We therefore set the CA data using an additional command.

We also perform a

deploy:
  stage: deploy
  image: dtzar/helm-kubectl
  script:
  • kubectl config set-cluster k8s --server="${SERVER}"
  • kubectl config set clusters.k8s.certificate-authority-data ${CERTIFICATE_AUTHORITY_DATA}
  • kubectl config set-credentials gitlab --token="${USER_TOKEN}"
  • kubectl config set-context default --cluster=k8s --user=gitlab
  • kubectl config use-context default
  • sed -i "s//${CI_COMMIT_SHORT_SHA}/g" deployment.yaml
  • kubectl apply -f deployment.yaml

2 to replace the with the short SHA of the Docker image that we just pushed to the Docker registry. This may feel a little dirty/hacky; I’ll share a different way to do this in a future blog post using Helm. (UPDATE: check out my new blog post on improving Kubernetes deployments with Helm).

Finally, higher up in the file find the

deploy:
  stage: deploy
  image: dtzar/helm-kubectl
  script:
  • kubectl config set-cluster k8s --server="${SERVER}"
  • kubectl config set clusters.k8s.certificate-authority-data ${CERTIFICATE_AUTHORITY_DATA}
  • kubectl config set-credentials gitlab --token="${USER_TOKEN}"
  • kubectl config set-context default --cluster=k8s --user=gitlab
  • kubectl config use-context default
  • sed -i "s//${CI_COMMIT_SHORT_SHA}/g" deployment.yaml
  • kubectl apply -f deployment.yaml

3 array. Add

deploy:
  stage: deploy
  image: dtzar/helm-kubectl
  script:
  • kubectl config set-cluster k8s --server="${SERVER}"
  • kubectl config set clusters.k8s.certificate-authority-data ${CERTIFICATE_AUTHORITY_DATA}
  • kubectl config set-credentials gitlab --token="${USER_TOKEN}"
  • kubectl config set-context default --cluster=k8s --user=gitlab
  • kubectl config use-context default
  • sed -i "s//${CI_COMMIT_SHORT_SHA}/g" deployment.yaml
  • kubectl apply -f deployment.yaml

4 after

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world-go
  labels:
    app: go
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: go
    spec:
      containers:
  • name: go image: sanderknape/go-hello-world: ports:
    • containerPort: 8080 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 2 periodSeconds: 2

6.

Push these changes to your GitLab repository. The new

deploy:
  stage: deploy
  image: dtzar/helm-kubectl
  script:
  • kubectl config set-cluster k8s --server="${SERVER}"
  • kubectl config set clusters.k8s.certificate-authority-data ${CERTIFICATE_AUTHORITY_DATA}
  • kubectl config set-credentials gitlab --token="${USER_TOKEN}"
  • kubectl config set-context default --cluster=k8s --user=gitlab
  • kubectl config use-context default
  • sed -i "s//${CI_COMMIT_SHORT_SHA}/g" deployment.yaml
  • kubectl apply -f deployment.yaml

4 step will have applied the deployment to Kubernetes. Run

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

2 to see your pods running again. In addition, if you run a

deploy:
  stage: deploy
  image: dtzar/helm-kubectl
  script:
  • kubectl config set-cluster k8s --server="${SERVER}"
  • kubectl config set clusters.k8s.certificate-authority-data ${CERTIFICATE_AUTHORITY_DATA}
  • kubectl config set-credentials gitlab --token="${USER_TOKEN}"
  • kubectl config set-context default --cluster=k8s --user=gitlab
  • kubectl config use-context default
  • sed -i "s//${CI_COMMIT_SHORT_SHA}/g" deployment.yaml
  • kubectl apply -f deployment.yaml

8, you can see the image that is pulled from Docker Hub. You can verify that this is indeed the latest tag that was pushed to the hub.

Like before, run

NAME                              READY   STATUS    RESTARTS   AGE
hello-world-go-864cbc655d-5tz9v   1/1     Running   0          40s
hello-world-go-864cbc655d-88t6f   1/1     Running   0          35s
hello-world-go-864cbc655d-psfbt   1/1     Running   0          31s

5 on one of the pods to verify that it can succesfully accept connections. And that was it - you now have a fully automated pipeline that deploys from your laptop to a Kubernetes cluster!

Teardown

Remove the Kubernetes cluster with the following command:

`t3.small`0

Keeping the Docker Hub and GitLab up and running won’t cost you anything, though you can of course delete the resources we created.

Conclusion

In this blog post we created a fully automated deployment pipeline to Kubernetes using GitLab. While the pipeline doesn’t contain any automated (unit) testing or promotions of the application through different environments, it should give enough of an idea on how to build a pipeline with such features for Kubernetes. Happy deploying!