A Complete Guide to Deploying a Containerized Application Using Managed Instance Groups (MIGs) in Google Cloud (GCP) with Continuous Integration (CICD) — Part 2

Image for post
Image for post
Saturn V Engine Nozzles by Good Free Photos

In the second part of this guide, we will walk through setting up a CICD solution for client-side Javascript applications such as ReactJS that are deployed serverless in a GCP cloud storage bucket.

Scenario

We want to deploy a web application consisting of one or more frontends using a client side framework such as ReactJS which connects to one (or more) backends that have been containerized with Docker. Our backend will connect to a CloudSQL instance for storage and all environment variables will be encrypted with GCP’s Key Management Service (KMS). GIT commits to a specific branch on GitHub will trigger a build and deploy of the application. A load balancer will direct traffic and serve as a proxy for HTTPS using a managed certificate.

Pre-requisites

Setup the environment proposed in part 1 of this guide or equivalent.

Step 1: Dockerize the App

Below, we have a Dockerfile (placed in the root of the repository) that creates a docker image containing our application’s source code and installs all required NPM packages. The five steps below correspond to the numbered sections of the Dockerfile.

FROM node:10.16.0 as installer # (1.)# Create src app dir and set as workdir # (2.)
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
# Environment variables # (3.)
ENV NODE_PATH src
# Copy package.json and install dependencies # (4.)
COPY package.json /usr/src/app/package.json
COPY yarn.lock /usr/src/app/yarn.lock
RUN yarn install
# Copy app # (5.)
COPY . /usr/src/app

Note: Although we could have copied the entirety of our source code at once, we copy only the files that define the modules to install first so as to enable caching. That way, changing the source code does not require reinstalling all packages.

Step 2: Create CloudBuild file

We will build our application using GCP’s CloudBuild. This service makes use of a YAML file that specifies the build behavior. The file can be named anything (since we will be using custom triggers as opposed to the integration via the Github App) but we will call it cloudbuild.yaml for consistency and place it inside a /deploy directory.

Step 3: Define the First Build Steps

Below, you can observe the newly created deploy/cloudbuild.yaml file containing some comments at the top (lines starting with a #) and the first two steps. A build consists of 1 or more steps which define operations that should be executed by Google’s build VM.

The gcr.io/cloud-builders/docker specifies the container image of a cloud builder which serves as the base upon which the given step’s commands will be executed. More information on Cloud Builders can be found here.

The id is an arbitrarily named unique identifier for this step. We will use it for flow control in the following steps through the waitFor command.

The args specify the commands that will be run. A command string is constructed by concatenating each of the “-” lines in the provided order. In this case, we are running “docker build […] .” applying two tags to the image and setting the previous cache to speed up build.

The second build step pushes the newly built image to a Container Registry for later access.

# CLOUD BUILD CONFIG
# Deployment to GCP Bucket
steps:
# Build builder container
- name: 'gcr.io/cloud-builders/docker'
id: buildinstaller
args:
- build
- --cache-from
- gcr.io/$PROJECT_ID/my-frontend/installer:$SHORT_SHA
- -t
- gcr.io/$PROJECT_ID/my-frontend/installer:$SHORT_SHA
- -t
- gcr.io/$PROJECT_ID/my-frontend/installer:$BRANCH_NAME
- --target=installer
- '.'

# Push container with installed packages
- name: 'gcr.io/cloud-builders/docker'
id: pushinstaller
args:
- push
- gcr.io/$PROJECT_ID/my-frontend/installer
waitFor:
- buildinstaller

Step 4: Run Tests

We run the tests on our previously built Docker image. Note: We will go over how to run integration tests between multiple services in a future part of the guide.

[...]  # Run unit tests
- name: 'gcr.io/cloud-builders/docker'
id: test
args:
- run
- gcr.io/$PROJECT_ID/my-frontend/installer:$SHORT_SHA
- yarn
- test
waitFor:
- pushinstaller

Step 5: Decrypt Secrets and Build

The two build steps below decrypt the secrets (optional) and then builds the ReactJS application.

To execute the first step you will have to have a keyring and key created in the project as detailed in GCP’s instructions. The kms decrypt command takes in a ciphertext-file, which is a client side encrypted file containing all secrets, and generates a secrets.dec file in the workspace that can be used by later steps.

The build step runs the previously built Docker image containing our application’s code and executes a yarn build. It receives all decrypted values from the previous step (optional).

Important: Since we are not deploying the Docker image, we mount a volume called “final-build-files” to which we copy our build files to make them available to later build steps.

[...]  # Decrypt secrets 
- name: gcr.io/cloud-builders/gcloud
id: decrypt
args:
- kms
- decrypt
- --ciphertext-file=deploy/environment/secrets.enc
- --plaintext-file=secrets.dec
- --location=global
- --keyring=my-keyring
- --key=my-key
waitFor:
- pushinstaller
# Build app
- name: 'gcr.io/cloud-builders/docker'
id: build
args:
- run
- --env-file
- secrets.dec
- -v
- final-build-files:/build-files
- gcr.io/$PROJECT_ID/my-frontend/installer:$SHORT_SHA
- /bin/bash
- -c
- 'yarn build && cp -r /usr/src/app/build /build-files'
volumes:
- name: 'final-build-files'
path: '/build-files'
waitFor:
- decrypt
- test

Below is a sample command for client side encrypting your secrets.

gcloud kms encrypt \
--plaintext-file=deploy/environment/secrets.dec \
--ciphertext-file=deploy/environment/secrets.enc \
--location=global \
--keyring=my-keyring \
--key=my-key

A sample secrets.dec file is shown below. Note that the secrets file needs to be in a format compatible with Docker’s env-file command.

REACT_APP_API_URL=https://my-backend.example.com.br
REACT_APP_GOOGLE_TRACKING_ID=UA-123456789-0

Step 6: Deploy to Bucket

In these final 3 build steps we will copy over the static files, prepare a backup of our previous files and invalidate the CDN cache to start serving our new assets.

The first step copies over the files in the volume used by the previous step (note that the volume name in the two steps need to match for data to be preserved and available) to our Cloud Storage Bucket.

The aforementioned step alone is sufficient to deploy our application. However, there are some concerns that are partly addressed by the two following points (modifications may be required to fit into the specifics of your application or organizational requirements):

[...]

# Copy files to the bucket
- name: gcr.io/cloud-builders/gsutil
id: deploy
args:
- -m
- cp
- -r
- /build-files/build/*
- gs://$_URL
volumes:
- name: 'final-build-files'
path: '/build-files'
waitFor:
- build
# To enable easy rollback, create a hashed duplicate of index.html
- name: gcr.io/cloud-builders/gsutil
id: backupindex
args:
- cp
- gs://$_URL/index.html
- gs://$_URL/previous-versions/index.$SHORT_SHA.html
waitFor:
- deploy
# Invalidate the / cache
- name: gcr.io/cloud-builders/gcloud
id: invalidatecache
args:
- compute
- url-maps
- invalidate-cdn-cache
- my-lb-map
- --host
- $_URL
- --path
- /
- --async # Invalidate as a background task
waitFor:
- deploy

Step 7: Create Trigger

By now, we have a complete cloudbuild.yaml file that species our build steps. However, we have not specified when our file should run. One way to accomplish this is via triggers. Triggers associate an action in Github (such as a push to a repository) with the execution of a Cloud Build script.

Image for post
Image for post

2. Select GitHub (mirrored) and click continue.

Image for post
Image for post

3. Select the repository where your code is hosted and click connect repository.

Image for post
Image for post

4. Select Add trigger on the following page.

Image for post
Image for post

5. Give the trigger an appropriate name. Determine pushes to which branch(es) should start a build (via the regex expression where ^ means start of and $ end of) and provide the cloudbuild.yaml file directory. The _URL substitution variable is used in our cloudbuild file. Click Create trigger to complete the process.

Image for post
Image for post

Conclusion

At this point, we have all the infrastructure setup (part 1) and are (now) able to deploy code automatically to our Cloud Storage bucket(s). In the next part of our series, we will go over deploying to our backend services running on our managed instance group.

Part 3 of the guide can be found here.

Written by

A curious minded engineer.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store