Icon

Charles Crete

Cloud Run + Cloud Build for Monorepos

June 24, 2019

Cloud Run is a "serverless container compute platform" running on Google Cloud. It is a surprisingly good product, even in it's beta stage.

Today, we'll be setting up Cloud Run and Cloud Build to run inside a monorepo with a Node.js back-end (as an API), with a Next.js front-end. We will deploy each package (server, web) as an individual Cloud Run service, with continuous delivery from GitHub and a domain attached.

I'm going to assume you have a package structure similar to:

packages/
  web/ # Next.js
    .next/ # Output
    package.json
    pages/
    src/
  server/ # Node.js
    config/
      developement.json
      staging.json
      production.json
    dist/ # Output
    package.json
    src/

APIs / IAM

Let's get started by enabling the required API:

Next, go to IAM and add the following roles to the Cloud Build Service Account:

  • Service Account User
  • Cloud Run Admin
  • Cloud KMS CryptoKey Decrypter
  • Cloud Build Service Account (should already be present)

KMS (Encryption)

We want to ship our packages/server/production.json file in our build configuration, without committing it to Git. To do this, we will use Cloud Key Management Service to only commit an encrypted version.

First, let's create a keyring and key:

# Create keyring (replace with custom keyring name or project ID)
gcloud kms keyrings create my-project-keyring \
  --location=global

gcloud kms keys create cloudbuild \
  --location=global \
  --keyring=my-keyring-name \
  --purpose=encryption

Next, let's encrypt our production configuration:

gcloud kms encrypt \
  --plaintext-file=packages/server/config/production.json \
  --ciphertext-file=packages/server/config/production.json.enc \
  --location=global \
  --keyring=my-project-keyring \
  --key=cloudbuild

We will now see a production.json.enc file. Make sure to commit this.

To speed things up, it's recommended to setup some scripts:

scripts/kms

#!/usr/bin/env bash
set -e

FILES=( "packages/server/config/staging.json" "packages/server/config/production.json" )

for FILE in "${FILES[@]}"
do
  gcloud kms $1 \
    --plaintext-file=$FILE \
    --ciphertext-file=$FILE.enc \
    --location=global \
    --keyring=my-project-keyring \
    --key=cloudbuild
done

Usage:

./scripts/kms encrypt
./scripts/kms decrypt

Docker

Server

We'll be using node-config for server configuration, with TypeScript (requires a build step).

Each package will be build inside a Docker container, ran on Cloud Build. We can easily test the Docker builds locally as we go.

We will be using the slim Node images. This alone halves the image size in most cases.

packages/server/Dockerfile

# Specify Node version
FROM node:lts-slim

# Install build dependencies
RUN apt update && apt install -y python build-essential

WORKDIR /tmp/build
ENV NODE_ENV=production

# node-config environement variable
ARG NODE_CONFIG_ENV
ENV NODE_CONFIG_ENV ${NODE_CONFIG_ENV:-production}

# Copy only required files for fetching dependencies
COPY package.json yarn.lock /tmp/build/
RUN yarn --prod

RUN mv /tmp/build/node_modules /tmp/node_modules

# Copy the whole app in
COPY . /tmp/build

# Replace node_modules
RUN rm -rf /tmp/build/node_modules && mv /tmp/node_modules /tmp/build/node_modules

# Build the app
RUN yarn build

WORKDIR /usr/src/app

# Copy only app runtime (required files for app to run)
RUN cp -a /tmp/build/node_modules /tmp/build/dist /tmp/build/package.json /tmp/build/config /usr/src/app/ \
    && rm -rf /tmp/build

# Set environment variables
ENV PORT=80

# Start app
CMD yarn start

EXPOSE 80

packages/server/.dockerignore

Dockerfile
node_modules
dist

This Dockerfile is optimized for caching based on npm dependencies, and contains support for NODE_CONFIG_ENV.

We can build and run this container locally:

cd packages/server
docker build -t server .
docker run -p 8081:80 server

You can access http://localhost:8081 to test access to your container.

Web

Next, for the Next.js Docker setup.

packages/web/Dockerfile

# Specify Node version
FROM node:lts-slim

RUN apt update && apt install -y python build-essential

WORKDIR /tmp/build
ENV NODE_ENV=production

# Copy only required files for fetching dependencies
COPY package.json yarn.lock /tmp/build/
RUN yarn --prod

RUN mv /tmp/build/node_modules /tmp/node_modules

# Copy the whole app in
COPY . /tmp/build

# Replace node_modules
RUN rm -rf /tmp/build/node_modules /tmp/build/.next && mv /tmp/node_modules /tmp/build/node_modules

# Build the app
RUN yarn build

WORKDIR /usr/src/app

# Copy only app runtime (required files for app to run)
RUN cp -a /tmp/build/node_modules /tmp/build/.next /tmp/build/package.json /usr/src/app/ \
    && rm -rf /tmp/build

# Set environment variables
ENV PORT=80

# Start app
CMD yarn start

EXPOSE 80

packages/web/.dockerignore

Dockerfile
node_modules
.next

Same deal here:

cd packages/web
docker build -t web .
docker run -p 8080:80 web

You can customize the Dockerfile to also copy a server.js or other configuration files, such as a .env

Cloud Build

Server

Next, let's create our Cloud Build configuration for the server.

packages/server/cloudbuild.yaml

steps:
  # Decrypt our config for the current environment (production, staging, etc)
  - name: "gcr.io/cloud-builders/gcloud"
    args:
      [
        "kms",
        "decrypt",
        "--ciphertext-file=${_PATH}/config/${_ENV}.json.enc",
        "--plaintext-file=${_PATH}/config/${_ENV}.json",
        "--location=global",
        "--keyring=${_KEYRING}",
        "--key=${_KEY}",
      ]
  # Pull last build for caching
  - name: "gcr.io/cloud-builders/docker"
    entrypoint: "bash"
    args:
      ["-c", "docker pull gcr.io/$PROJECT_ID/${_ENV}-server:latest || exit 0"]
  # Build new image
  - name: "gcr.io/cloud-builders/docker"
    args:
      [
        "build",
        "-t",
        "gcr.io/$PROJECT_ID/${_ENV}-server",
        "--cache-from",
        "gcr.io/$PROJECT_ID/${_ENV}-server",
        "--build-arg",
        "NODE_CONFIG_ENV=${_ENV}",
        "${_PATH}",
      ]
  # Push new image
  - name: "gcr.io/cloud-builders/docker"
    args: ["push", "gcr.io/$PROJECT_ID/${_ENV}-server"]
  # Deploy to Cloud Run
  - name: "gcr.io/cloud-builders/gcloud"
    args:
      [
        "beta",
        "run",
        "deploy",
        "${_ENV}-server",
        "--image",
        "gcr.io/$PROJECT_ID/${_ENV}-server",
        "--region",
        "us-central1",
      ]

substitutions:
  _ENV: production
  _PATH: .
  _KEYRING: my-project-keyring
  _KEY: cloudbuild

images:
  - "gcr.io/$PROJECT_ID/${_ENV}-server"

Also, don't forgot to add an ignore file for Cloud Build:

packages/server/.gcloudignore

node_modules
dist

Web

Next, let's do the same with the web.

packages/web/cloudbuild.yaml

steps:
  # Pull last build for caching
  - name: "gcr.io/cloud-builders/docker"
    entrypoint: "bash"
    args: ["-c", "docker pull gcr.io/$PROJECT_ID/${_ENV}-web:latest || exit 0"]
  # Build new image
  - name: "gcr.io/cloud-builders/docker"
    args:
      [
        "build",
        "-t",
        "gcr.io/$PROJECT_ID/${_ENV}-web",
        "--cache-from",
        "gcr.io/$PROJECT_ID/${_ENV}-web",
        "${_PATH}",
      ]
  # Push new image
  - name: "gcr.io/cloud-builders/docker"
    args: ["push", "gcr.io/$PROJECT_ID/${_ENV}-web"]
  # Deploy to Cloud Run
  - name: "gcr.io/cloud-builders/gcloud"
    args:
      [
        "beta",
        "run",
        "deploy",
        "${_ENV}-web",
        "--image",
        "gcr.io/$PROJECT_ID/${_ENV}-web",
        "--region",
        "us-central1",
      ]

substitutions:
  _ENV: production
  _PATH: .

images:
  - "gcr.io/$PROJECT_ID/${_ENV}-web"

packages/web/.gcloudignore

node_modules
.next

Deploy

Now that we have our Cloud Build configuration done, we can go ahead and simply deploy our server:

# Deploy production from root of project
gcloud builds submit --config packages/server/cloudbuild.yaml --substitutions=_PATH=packages/server

# Deploy staging from root of project
gcloud builds submit --config packages/server/cloudbuild.yaml --substitutions=_PATH=packages/server,_ENV=staging

# Deploy production from packages/server
gcloud builds submit --config cloudbuild.yaml

And web:

# Deploy production from root of project
gcloud builds submit --config packages/web/cloudbuild.yaml --substitutions=_PATH=packages/web

# Deploy staging from root of project
gcloud builds submit --config packages/web/cloudbuild.yaml --substitutions=_PATH=packages/web,_ENV=staging

# Deploy production from packages/web
gcloud builds submit --config cloudbuild.yaml

CD / Domains

Our services should be deploy! Go in the Cloud Run section of the Google Cloud Console and you'll be able to see them running.

The last thing is enabling Cloud Build triggers, and domains.

You can view triggers under the trigger section of Cloud Build.

To add CD from GitHub:

  • Click "Add trigger"
  • Connect GitHub/other VCS
  • Select repo
  • Configure branch (for only master, set it to master)
  • Click to use Cloud Build configuration file
  • Enter path packages/server/cloudbuild.yaml
  • Add a substitution for _PATH to packages/server
  • Repeat for web (s/server/web)

Now, when you push, it will automatically trigger building. It is recommended to only use this with your staging/testing environment, not production.

Add another substitution for _ENV as staging to deploy to staging

For domains, go to the domain mapping section of Cloud Run to setup domains to point to your services.