article

Serverless v3.0.0

vector graphic of gears with different icons

Breaking Changes: v2.0.0 to v3.0.0

Some time ago we wrote about Serverless deployments v2.0.0 and a lot has changed and improved since then, so it's time for an update.

One of the biggest improvements to the workflow is GitHub Actions. No need for external CI/CD systems to generate new versions any more. GitHub also improved Releases, and Conventional Commits reached v1.0.0.

Continous Deployment of Serverless Applications

Let's walk through the details of a fully automated continous integration and deployment system with all those fancy new toys. The one thing that hasn't changed since the last article is our Git workflow: we recommend the Git Feature Branch Workflow. The merge strategy doesn't matter much, rebase, squash and merge all work with the following tools and flows.

Use Conventional Commits

We use Semantic Release to calculate version numbers based on commit messages. The 'de facto' standard for these commit messages has been defined in Conventional Commits. In short, your commit messages start with a keyword, a scope and then a short description of the change. Commit messages should be written in present tense ("closes", not "closed"):

  • fix(accounts): calculation error on totals, closes #123 to create a Patch Release or
  • feat(users): create and update user details, closes #123 to create a Minor Release

You can find a quick summary here.

Add Semantic Release and GitHub Actions

Actions is a CI/CD product from GitHub. You can define "workflows" as simple yml files to go through certain steps such as linting, testing, building etc. They work for pretty much any project / programming language. Every Action ususally has a trigger ("on") and jobs. You can read more about Events that trigger workflows, Building and testing Node.js Actions or even Custom JavaScript Actions.

We usually use a simple "test, build & release" Action, for example for our integration Actions like Cloudflare Preview URL.

Below you can find a slightly more complex Action that restricts permissions and runs checkout, npm install, linter, codestyle checks, tests, coverage reports and semantic release. See Hardening GitHub Actions for more information about security/permission steps taken in this Action.

./github/workflows/publish.yml:

name: Semantic Release
on:
push:
branches:
- 'main'
permissions:
actions: none
checks: none
contents: write
deployments: write
issues: read
packages: none
pull-requests: write
repository-projects: none
security-events: none
statuses: none
jobs:
publish:
env:
CI: true
NODE_ENV: test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/[email protected]
- uses: actions/setup-[email protected]
with:
node-version: '16'
cache: 'npm'
- name: Install Dependencies
run: npm ci --ignore-scripts
- name: Run Linter
run: npm run lint
- name: Validate Codestyle
run: npm run codestyle
- name: Test
run: npm test
env:
DISABLE_REQUEST_LOGGING: true
LOG_LEVEL: error
- name: report coverage
uses: codecov/codecov-[email protected]
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Semantic Release
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

The magic line to convert our Conventional Commit messages into Releases is run: npx semantic-release. This will run semantic-release after the tests, lint etc. are done and generate a release with fancy notes based on the commits that have been merged. For example, if you currently have a v1.2.0 deployed, and merge a fix, semantic-release will publish v1.2.1.

You can see how Semantic Releases look like in our open source repos. The important piece here is, that each release also comes with a tag. This is interesting and useful for a variety of reasons, for example compliance ("what has been deployed when?"), and of course the developer experience (DX). You can see the currently deployed version in each environment, without checking cryptic 6-digit SHAs. The tag is also the ingredient required for the next step:

Automated Deployment

In Google Cloud Build (or AWS CodeBuild or Azure Pipelines) you can select a source repository for deployments and pick "tags" as a build trigger. .v* for example will deploy every new release. If you want to deploy a Major release in a new environment, you can switch the previous trigger settings to .v1.* to only deploy v1 releases, but not v2. This is a nice way to test breaking changes.

screenshot of cloud build trigger settings

Cloud Build allows you to continously deploy your application. On GCP this works with a cloudbuild.yml file:

steps:
- name: 'gcr.io/cloud-builders/docker'
entrypoint: 'bash'
args:
- '-c'
- |
docker pull us-central1-docker.pkg.dev/$PROJECT_ID/api-demo/api:latest || exit 0
- name: 'gcr.io/cloud-builders/docker'
args:
- build
- -t
- us-central1-docker.pkg.dev/$PROJECT_ID/api-demo/api:$TAG_NAME
- -t
- us-central1-docker.pkg.dev/$PROJECT_ID/api-demo/api:latest
- .
- --cache-from
- us-central1-docker.pkg.dev/$PROJECT_ID/api-demo/api:latest
- name: 'gcr.io/cloud-builders/docker'
args:
['push', 'us-central1-docker.pkg.dev/$PROJECT_ID/api-demo/api:$TAG_NAME']
- name: 'gcr.io/cloud-builders/gcloud'
args:
- run
- deploy
- api-demo
- --image=us-central1-docker.pkg.dev/$PROJECT_ID/api-demo/api:$TAG_NAME
- --region=us-central1
- --memory=256Mi
- --platform=managed
- --allow-unauthenticated
- --min-instances=0
- --max-instances=5
images:
- 'us-central1-docker.pkg.dev/$PROJECT_ID/api-demo/api:$TAG_NAME'
- 'us-central1-docker.pkg.dev/$PROJECT_ID/api-demo/api:latest'
timeout: 1800s

$TAG_NAME is the version number, so all your images and deployments have a semantic version number and you can clearly identify what's running where. There are four steps here:

  1. fetch last image from cache (if there's a cached image)
  2. build the code and Dockerfile
  3. push the image to the registry
  4. deploy the image on Cloud Run

Note: We're using the new Artifact Registry instead of the deprecated Container Registry, so the registry urls are slightly different. You can create your Artifact Repository here and adjust the registry region in the cloudbuild file.

Go live on managed k8s

Google Cloud, AWS and Azure all have managed Kubernetes products which are great to focus on software development and leave the k8s configs, helm charts and autoscale yamls to the DevOps Pros. In the previous section we defined a step to deploy to Cloud Run. For GCP, you may need to enable the API, billing etc. on your project:

If the build is green, you should see a new service in the Cloud Run console. When you click on the service, at the top you see the new public URL (ie. app-name-123.run.app) for your service. Link a domain to that URL and šŸŽ‰, your API is now live.

Summary

Let's recap a everyday workflow:

  • Developer picks an issue and works on this amazing new feature for your product
  • Developer pushes changes to a feature branch and opens a Draft Pull Request. A Pull Request Preview is automatically deployed via the GitHub/Google Cloud Build integration and changes can be tested
  • Developer marks the Pull Request as "Ready for Review"
  • Team reviews the PR, discusses the changes and approves (hopefully ;))
  • Pull Request is merged into main by the developer or reviewer
  • GitHub publish.yml Action is triggered ("push to main") and runs tests, lint etc.
  • If everything goes well, npx semantic-release executes, calculating a new version number, summarizing the release notes and creates a new tag and release
  • Google Cloud Build trigger is fired on "new tag", builds your app and pushes the Docker image to the Artifact Registry
  • Cloud Build starts the Cloud Run deployment with the new image (which has the version number)
  • Cloud Run starts the new service and changes the traffic allocation to the new instance
  • The change is live, start over with the next feature šŸ˜Ž

Cloud Run enables you to split traffic between multiple revisions, so you can perform gradual rollouts such as canary deployments or blue/green deployments. Combined with automatic rollbacks, you can skip your staging environment alltogether and continously deploy to production. Developers can easily revert changes by merging a commit to main.

Thanks for reading! If you have any questions or comments, please reach out on Twitter or start a discussion on GitHub.

Author

portrait of Patrick Heneise

Patrick Heneise

Chief Problem Solver and Founder at Zentered