← All Articles

Continuous Build and Deployment of Go Applications with Google Cloud Build

Khash SajadiKhash Sajadi
Feb 15th 22Updated Jul 4th 22

Continuous Build and Deployment of Go Applications with Google Cloud Build

We've gone through many iterations of ways to build, deploy and distribute applications written in Go at Cloud 66. Unlike Rails, Go applications can be web applications, daemons or CLIs and therefore have different requirements. I'll share some of what we've learned with you in this post.

Setup

We use Google Cloud Build to build our Go apps. In our experience, GCP Cloud Build has the best developer experience and is the most convenient one to use, especially if you're running other things on GCP. Other products we tried were Buildkite, which is as powerful as a fully configurable workflow-based bash script running on a cluster of machines, but is almost always overkill for something like this, and Github Actions, which has without exception been a disappointment with various levels of frustration.

For web applications and daemons written in Go, we always use Docker images as a distribution method since, ultimately, we will be running them in a Docker container. For CLIs written in Go, we build them using Google Cloud Build but distribute them in their executable form for different platforms.

Building Go web apps and daemons (services)  

Google Cloud Build uses a manifest file called cloudbuild.yaml or cloudbuild.json for its builds. To build a Go app, you'd need a cloudbuild file and a Dockerfile:

Dockerfile

Here is an example of a Dockerfile for a service called agent:

FROM golang:1.17 AS build

WORKDIR /app/agent
COPY . .
RUN CGO_ENABLED=0 go build -o /app/agent/agent

FROM alpine
LABEL maintainer="ACME Engineering <acme@example.com>"

RUN mkdir /app
# Copy any other files required in the final image here
COPY --from=build /app/agent/agent /app/agent
ENTRYPOINT ["/app/agent"]

This build uses a 2 step build: a first step using Go binaries and a second step using a small image (Alpine in this case) to run the compiled executable.

Note the CGO_ENABLED=0 before build: this makes the compiled executable work in Alpine. If you plan to use an OS other than Alpine for your final image or need CGO in your Go code, you can drop it.

As a convention for our Go apps, we always include the git SHA of the revision used to build the code in the executable. You can do this by including some code like this in your Go app:

package utils

var (
	Commit string = "dev"
)

Now you can set Commit during the build step:

FROM golang:1.17 AS build
ARG SHORT_SHA

WORKDIR /app/agent
COPY . .
RUN CGO_ENABLED=0 go build -o /app/agent/agent -ldflags="-X 'github.com/acme/example/agent/utils.Commit=${SHORT_SHA}'"

FROM alpine
LABEL maintainer="ACME Engineering <acme@example.com>"

RUN mkdir /app
# Copy any other files required in the final image here
COPY --from=build /app/agent/agent /app/agent
ENTRYPOINT ["/app/agent"]

Note the SHORT_SHA argument (we will pass this into the build process later) and the ldflags params in build, including the package name (utils in this case).

cloudbuild.json

Now let's turn our attention to the cloudbuild.json file. I always prefer JSON to YAML because I don't like overthinking about indentations in YAML. However, if you have large Cloud Build files, you might benefit from YAML since it supports comments.

{
  "steps": [
    {
      "name": "gcr.io/kaniko-project/executor:latest",
      "id": "agent",
      "args": [
        "--context=./agent",
        "--destination=gcr.io/$PROJECT_ID/agent:$SHORT_SHA",
        "--build-arg=SHORT_SHA=$SHORT_SHA",
        "--cache=true",
        "--cache-ttl=5h"
      ]
    },
    {
      "name": "gcr.io/cloud-builders/gcloud",
      "id": "agent-retag",
      "waitFor": [
        "agent"
      ],
      "args": [
        "--quiet",
        "container",
        "images",
        "add-tag",
        "gcr.io/$PROJECT_ID/ct-agent:$SHORT_SHA",
        "gcr.io/$PROJECT_ID/ct-agent:latest"
      ]
    }
  ]
}

In this file, we're defining two steps:

The first step uses Kaniko to build the Docker image using our Dockerfile. We use Kaniko on GCP Cloud Build because it takes care of the build without the need for a Docker daemon and automatically pushes the built image to the GCP Container Registry. In this step, we are using the short SHA git ref of the commit used for the build as the image tag. At this point we pass in SHORT_SHA which is automatically populated by GCP into the build process so it can be picked up by the ARG command in the Dockerfile.

The second step is an optional one: it retags the image we just built with the latest tag.

Cloud Build Setup (triggers)

All that's left now is to set up a trigger in GCP Cloud Build to build our image after every commit. For this we usually create a trigger that starts the build on every commit to the main or master branch. Head to GCP Cloud Build, create a new trigger, connect your GCP to your Github account, and select your code repo.

continuous-build-and-deployment-of-go-applications-with-google-cloud-build

Bonus Step (deployment to Kubernetes)

If you use GKE clusters to run your applications you can deploy them to your cluster directly from here. Just add a deployment step to your cloudbuild.json:

{
        "name": "gcr.io/cloud-builders/gke-deploy",
        "args": [
          "run",
          "--filename=deployment.yml",
          "--location=us-east4-a",
          "--cluster=example"
        ]
      }

For this, you'd need to have a deployment.yml file that contains your Kubernetes deployment manifest. You will also need to ensure your GCP build service account has the correct permissions to access your GKE cluster. This document includes some information on adding automatic deployments to your Cloud Build: https://cloud.google.com/build/docs/deploying-builds/deploy-gke

Building Go CLI apps

Building a CLI app written in Go is very similar to building other Go applications. The difference is usually in its distribution: CLI tools are distributed as binaries and need to be built for all platforms they support, not just Linux.

Cross-Platform Builds

For CLI tools, we don't use Dockerfiles in the repository and use GCP Cloud Build builder images. Let's take a look at a cloudbuild.yml that we use for CLI tools (this one written in YAML):

steps:
- id: go-build-mac
  name: "gcr.io/cloud-builders/go"
  args: ["build", "-o", "mycli_darwin_$TAG_NAME", "-ldflags=-X 'github.com/example/mycli/cmd.Version=$TAG_NAME' -X 'github.com/example/mycli/cmd.Commit=$SHORT_SHA'", "."]
  env: ['PROJECT_ROOT=ctx', "GOOS=darwin", "GOARCH=amd64"]
- id: go-build-linux
  name: "gcr.io/cloud-builders/go"
  args: ["build", "-o", "mycli_linux_$TAG_NAME", "-ldflags=-X 'github.com/example/mycli/cmd.Version=$TAG_NAME' -X 'github.com/example/mycli/cmd.Commit=$SHORT_SHA'", "."]
  env: ['PROJECT_ROOT=ctx', "GOOS=linux", "GOARCH=amd64"]
- id: replace-version
  name: "gcr.io/cloud-builders/gcloud"    
  entrypoint: "bash"
  args:
    - "replace.sh"
    - "$TAG_NAME"
artifacts:
  objects:
    location: 'gs://example_bucket/ctx/'
    paths: ['mycli_darwin_$TAG_NAME', 'mycli_linux_$TAG_NAME', 'version.json']

This Cloud build file has three steps:

Steps 1 and 2, build the tool for Mac and Linux (you can add more steps for other platforms like Windows or ARM by changing GOARCH and GOOS environment variables for cross-compiling.

Optional Step: Generate version files

The last step is optional, and we use it to let CLI tools automatically update themselves if a newer version is available. For this, we use a simple setup:

  1. A version.json file that contains the latest version number of the tool.
  2. Some simple Go code to download version.json and compare the latest version with the executable's version.

Since we are building the code on GCP, we can use GCP Cloud Storage buckets to store both the binaries and the version.json files. You can have this bucket publicly available or restrict public access if your CLI tool is internal or private.

As you've already noticed, the last step of the build runs a bash file called replace.sh

#!/bin/bash

version=$1
sed_pattern='s/\$VERSION/'$version'/'
sed $sed_pattern version.template.json > version.json

This simple bash file takes a single argument for the version and generates a version.json for it. Here is the version.template.json file:

{
    "version": "$VERSION"
}

Now that we have the latest version in the JSON file, we can upload the binaries and the version.json to the GCP Storage using the artefacts element of cloudbuild.yml seen at the end of the file above.

The Go code that downloads version.json and the binaries can use httputils if your bucket is accessible publicly, or the cloud.google.com/go/storage library if it is private.


Try Cloud 66 for Free, No credit card required