Deploying Ruby apps to Google Cloud Kubernetes Engine continuously with CircleCI

I hadn’t had a chance to try Kubernetes for a long time, but finally a few weeks ago I got one: I’ve been working on a simple Ruby application (a Slack bot), and decided to deploy it to Google Cloud Kubernetes Engine.

There was an additional requirement to set up a continuous deployment process. For that, I chose CircleCI service.

NOTE: This post is just a playbook containing all the actions I made to get the application up and running.

Software versions:

  • Kubernetes 1.9.4-gke.1
  • Google Cloud SDK 195.0.0

Preparing GCloud

Let’s assume that you already have a Google Cloud account.

Go to the Console and create a new project (my-project) with Kubernetes API enabled (more thorough description of this step could be found here).

Next step—install gcloud CLI:

# for macOS/Homebrew users it's pretty simple
brew install caskroom/cask/google-cloud-sdk

Then you must authenticate yourself:

gcloud auth login

(Optionally) Configure the default project (to avoid specifying it with every command):

gcloud config set project my-project

Create K8S cluster:

# "g1-small" is a minimal instance type (as of the time of writing) that allows having only one node
gcloud container clusters create my-cluster --machine-type g1-small --num-nodes 1

Obtain the cluster’s credentials:

gcloud container clusters get-credentials my-cluster

Get the list of clusters to verify that everything is okay:

gcloud container clusters list

The output looks like this:

NAME         LOCATION    MASTER_VERSION  MASTER_IP      MACHINE_TYPE  NODE_VERSION  NUM_NODES  STATUS
my-app-web  us-east1-b  1.9.4-gke.1     35.227.92.102  g1-small      1.9.4-gke.1   1          RUNNING

Preparing application

As I’ve already told, the application I want to deploy is a simple Ruby-only app (i.e., no databases, caches, persistent stores). See the links at the end of the post on how to deploy Rails applications.

We need to build a Docker image and push to Google Container Registry.

Here is my Dockerfile:

FROM ruby:2.5.0

RUN apt-get update && apt-get install -y build-essential git

RUN mkdir -p /app
WORKDIR /app

ENV LANG C.UTF-8

COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install --jobs 20 --retry 5

COPY . .

# We want to include the current git revision information
# to a build to track releases
ARG BUILD_SHA=unknown

ENV BUILD_SHA ${BUILD_SHA}

EXPOSE 3000

ENTRYPOINT ["bundle", "exec"]

CMD ["puma", "-p", "3000"]

Let’s build it:

docker build -t my-app -f Dockerfile.prod .

NOTE: I use Dockerfile.prod file for production and Dockerfile for development.

Then tag it using a specific GCloud format (containing region and project name):

docker tag my-app us.gcr.io/my-project/my-app:v1

And push:

gcloud docker -- push us.gcr.io/my-project/my-app:v1

OK. We’re almost there. Now we have to tell somehow to our K8S cluster to get this image and run.

For that we’re going to use K8S deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    name: web
spec:
  replicas: 2
  selector:
    matchLabels:
      name: web
  template:
    metadata:
      labels:
        name: web
    spec:
      containers:
        - name: web
          image: gcr.io/my-project/my-app:latest
          ports:
            - containerPort: 3000
          livenessProbe:
            httpGet:
              path: /_health
              port: 3000
            initialDelaySeconds: 10
            timeoutSeconds: 1
          readinessProbe:
            httpGet:
              path: /_health
              port: 3000
            initialDelaySeconds: 10
            timeoutSeconds: 1

K8S documentation is a good place to read about deployments; no need to recall it here.

I’d only like to pay attention to the livenessProbe and readinessProbe sections: they tell K8S how to monitor the application state to provide rolling update functionality and make sure that the desired number of replicas are up and running. Note that your application should provide endpoints for these checks (/_health in my case).

Now we need to install one more tool—kubectl—to operate our cluster:

gcloud components install kubectl

It’s time to finally deploy our application! Let’s create our deployment:

kubectl create -f kube/web-deployment.yml

To get the information about our deployment, you can see the list of pods:

kubectl get pods

More information about a pod:

kubectl describe pod web-<pod-id>

Updating application manually

Your application is up and running. How to push a new release?

If you want to update your deployment configuration, just run the command below:

kubectl apply -f kube/web-deployment.yml

For updating the codebase, you should tell your cluster to use a new image:

docker tag my-app us.gcr.io/my-project/my-app:v1.1
gcloud docker -- push us.gcr.io/my-project/my-app:v1.1
kubectl set image deployment web web=gcr.io/my-project/my-app:v1.1

K8S will take care of creating new pods and replacing the old ones.

Running three commands and manually providing new versions is not the most convenient way to deploy, isn’t it?

Let’s make someone else do all the dirty work.

CircleCI integration

CircleCI 2.0 is a very flexible automation tool.

We want to make our deployment as easy as just making git push (or merging PR into master branch).

Below you can find an example .circle/config.yml with the comments explaining every step:

version: 2
workflows:
  version: 2
  build_and_deploy:
    jobs:
      - build
      - deploy:
          # Run the "deploy" job only if the "build" job was successful
          requires:
            - build
          # Run deploy job only on master branch
          filters:
            branches:
              only:
                - master
jobs:
  # This job is responsible for running tests and linters
  build:
    docker:
      - image: circleci/ruby:2.5.0
        environment:
          RACK_ENV: test
    steps:
      - checkout
      - run:
          name: Install gems
          command: bundle install
      - run:
          name: Run Rubocop and RSpec
          # Our default rake task contains :spec and :rubocop tasks
          command: bundle exec rake
      # Cache all the project files to re-use in the deploy job
      # (instead of pulling the repo again)
      - persist_to_workspace:
          root: .
          paths: .
  deploy:
    docker:
      # official image which includes `gcloud` and `kubectl` tools
      - image: google/cloud-sdk
        # project information
        environment:
          GOOGLE_PROJECT_ID: my-project
          GOOGLE_COMPUTE_ZONE: us-east1-b
          GOOGLE_CLUSTER_NAME: my-app
    steps:
      # Attach previously cached workspace
      - attach_workspace:
          at: .
      # SERVICE_KEY provides access to your GCloud project.
      # Read more here https://circleci.com/docs/2.0/google-auth/
      - run: echo $GCLOUD_SERVICE_KEY > ${HOME}/gcloud-service-key.json
      # Authenticate gcloud
      - run: gcloud auth activate-service-account --key-file=${HOME}/gcloud-service-key.json
      # Configure gcloud (the same steps as you do on your local machine)
      - run: gcloud --quiet config set project ${GOOGLE_PROJECT_ID}
      - run: gcloud --quiet config set compute/zone ${GOOGLE_COMPUTE_ZONE}
      - run: gcloud --quiet container clusters get-credentials ${GOOGLE_CLUSTER_NAME}
      # Enable remote Docker (https://circleci.com/docs/2.0/building-docker-images/)
      - setup_remote_docker
      # Build Docker image with the current git revision SHA
      - run: docker build -t brooder -f Dockerfile.prod --build-arg BUILD_SHA=${CIRCLE_SHA1} .
      # Use the same SHA as our image version
      - run: docker tag brooder gcr.io/my-project/my-app:${CIRCLE_SHA1}
      # Using remote Docker is a little bit tricky but this "spell" works
      - run: gcloud docker --docker-host=$DOCKER_HOST -- --tlsverify --tlscacert $DOCKER_CERT_PATH/ca.pem --tlscert $DOCKER_CERT_PATH/cert.pem --tlskey $DOCKER_CERT_PATH/key.pem push gcr.io/my-project/my-app:${CIRCLE_SHA1}
      # and finally, "deploy" the new image
      - run: kubectl set image deployment web web=gcr.io/my-project/my-app:${CIRCLE_SHA1} --record

Keeping secrets

Let’s cover one more topic here—managing app secrets.

We use Kubernetes Secrets to store sensitive information (such as third-party services API tokens).

First, you have to create secrets definition file:

# kube/app-secrets.yml
apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
data:
  # The value should be base64 encoded
  github_token: b2t0b2NhdA==
  slack_token: Y3V0bWVzb21lc2xhY2s=

Don’t forget to encode the value into base64. For example:

echo -n "myvalue" | base64

Push secrets to the cluster:

kubectl create -f kube/app-secrets.yml
#=> secret "mysecret" created

NOTE: Remove app-secrets.yml right after pushing to K8S (or, at least, add to .gitignore).

Now you can pass the secrets to your app through env variables:

# web-deployment.yml
apiVersion: apps/v1
kind: Deployment
# ...
spec:
  # ...
  templaee:
    # ...
    spec:
      containers:
        - name: web
          image: gcr.io/my-project/my-app:latest
          # ....
          env:
            - name: MY_GITHUB_TOKEN
              valueFrom:
                secretKeyRef:
                  name: mysecret
                  key: github_token
            - name: MY_SLACK_TOKEN
              valueFrom:
                secretKeyRef:
                  name: mysecret
                  key: slack_token

Join our email newsletter

Get all the new posts delivered directly to your inbox. Unsubscribe anytime.