Skip to main content

Faster builds

tl;dr

To build faster in your CI:

  • Create and use BuildKit builders to offload resource-intensive builds. Specify the --builder flag to use the created BuildKit builders.
  • Specify the --registry flag to automatically add caching to your Compose build model.
  • For fine-tuned optimization, create a Compose file for your CI to override the build parameters.

Problem

By default, Preevy runs the build in the Docker server of the remote machine that Preevy provisions. Builds are often more resource-intensive than the environment runtime - but those resources are only required when the environment is created or updated.

In addition, builds often run with no cache (especially when the machine was just created, e.g. a new PR on a new branch) taking longer than they should. Configuring a remote cache manually is possible, but requires some work.

Solutions

  1. Offloading the build step to a specialized server can reduce the memory, disk, and CPU requirements of the machines provisioned for environments. It can also help speed up the build step so Preview Environments can be created faster in your CI.
  2. Reusing cached layers from previous builds can accelerate the build by skipping expensive build steps (e.g .yarn install). When creating Preview Environments In CI, cached image layers from the base branch or previous builds of the same branch can be reused.

Starting with version 0.0.57, Preevy runs a separate build step using the docker buildx bake command, before running the deploy step using docker compose up. Preevy can customize the build step to make use of BuildKit builders and automatically configure caching for the build. These two features work together to speed up the creation of Preview Environments in your CI.

Part 1: Offload the build

Preevy can use BuildKit builders to offload the build step out of the environment machine.

Specify a builder using the --builder flag at the preevy up command. If not specified, the default builder will be used.

Out-of-the-box, Docker's default builder uses the Docker driver. This driver uses the connected Docker Server to build. Preevy sets the Docker Server to the provisioned machine's Docker Server (using the DOCKER_HOST environment variable), so the build runs there.

To run the build on the local machine (where the preevy CLI runs), or a remote server, configure a builder with a different driver. The docker buildx create command can be used to create a builder.

Choosing a builder driver

  • The Docker container driver is the simplest option - it will run the build on the Docker server of the local machine.
  • Use the Kubernetes driver to run the build on a Kubernetes cluster. Kubernetes can be set up to allocate powerful servers to the build.
  • Use the Remote driver to connect to a remote BuildKit daemon.
  • Use a 3rd party service like Depot to host the build. Preevy can work with any builder that runs via docker buildx build.

Setting up a builder in GitHub Actions

For GitHub actions, the setup-buildx-action can be used to simplify builder management. The generated builder name can then be specified using the --builder flag in the preevy-up action - see example.

Part 2: Automatically configure cache

Preevy can automatically add the cache_to and cache_from directives in the build section of the Compose file to specify a layered cache to be used when building your images.

To share the cache across different CI runs, it needs to be stored on a remote backend - not on the build machine, which is usually ephemeral.

Exporting a cache to a remote backend is not supported on the default Docker builder (see the table here), so in order to use this feature, define and use a different BuildKit builder as described in part 1.

Generated image refs

To allow reusing the cached image layers, stable IDs are required for each image - the image refs. Preevy generates image refs for each service comprising of the Compose project name (usually the directory name), service name, and the current git hash. It will then use the generated image refs to add cache_from and cache_to directives for each service build.

At the end of the build step, images will be pushed to the registry. Preevy will then run the provisioning step (docker compose up) with a modified Compose file which has the built image refs for each service. The result is a build that automatically uses the specified registry as a cache.

Using an image registry as a cache backend

An image registry can serve as a cache backend.

When the --registry flag is specified, Preevy can automatically add cache directives that use the registry to the Compose project.

Example

With this docker-compose.yaml:

name: my-project  # if not specified, the Compose file directory name is used
services:
frontend:
build: .

And using the git hash 12abcdef.

The command:

preevy up --registry my-registry --builder my-builder

This will result in the following interim build Compose file:

services:
frontend:
build:
context: .
tags:
- my-registry/preevy-my-project-frontend:latest
- my-registry/preevy-my-project-frontend:12abcdef
cache_to:
- type=registry,ref=my-registry/preevy-my-project-frontend:latest,mode=max,oci-mediatypes=true,image-manifest=true
cache_from:
- my-registry/preevy-my-project-frontend:latest
- my-registry/preevy-my-project-frontend:12abcdef

At the end of the build step, the tagged image refs will be pushed to the my-registry registry.

The following Compose file will be deployed to the machine:

services:
frontend:
build:
image: my-registry/preevy-my-project-frontend:12abcdef

AWS ECR dance

Using Amazon Elastic Container Registry as your image registry requires creating a "repository" before pushing an image. When creating image refs for ECR, Preevy uses a slightly different scheme, because image names (the part after the slash) cannot be dynamic - so the dynamic part is moved to the tag.

For example, with the same project and registry above:

  • Non-ECR image ref: my-registry/my-project-frontend:12abcdef
  • ECR image ref for the existing repository my-repo: my-registry/my-repo:my-project-frontend-12abcdef

Preevy uses the ECR image ref scheme automatically when it detects an ECR registry name. This behavior can be enabled manually by specifying --registry-single-name=<repo>. Example: --registry my-registry --registry-single-name=my-repo. Auto-detection of ECR-style registries can be disabled by specifying --no-registry-single-name.

Choosing a registry

Several options exist:

  • Creating a registry on the same cloud provider used by Preevy to provision the environment machines is usually inexpensive: ECR for AWS, GAR for Google Cloud, ACR for Azure.
  • Creating a registry on the CI provider, e.g. GHR on GitHub actions.
  • Docker Hub
  • ttl.sh is a free, ephemeral, and anonymous image registry.
  • Other 3rd party registries exist with some free tiers: JFrog, Treescale, Canister, GitLab

Careful when using a builder without a registry

Without a registry, Preevy will add the --load flag to the docker buildx bake command to load built images to the environment's Docker server. If the builder does not reside on the same Docker server, built images will be transferred over the network. So, when using a builder other than the default Docker builder, it is advised to also use a registry.

Using GitHub Actions cache

GitHub Actions can also be used as a cache backend. The Preevy GitHub Plugin can add suitable cache directives to your services. Specify the --github-add-build-cache flag to enable this feature.

See the relevant section in the Docker docs on how to enable authentication of the Docker CLI to the GitHub cache in your CI.

Using other cache backends

More backends are described in the Docker docs.

Manual optimization

If you already have an efficient build pipeline that creates images for the current commit, you can skip Preevy's build step entirely and provision an environment with existing images.

Specify --no-build to skip the build step. Preevy will run docker compose up --no-build with the given Compose file, which needs to have an image property for each service.

  • --no-build: Skip the build step entirely
  • --registry=<name>: Registry to use. Implies creating and pushing an image ref for each service at the build. Default: Do not use a registry and load built images to the environment's Docker server
  • --builder=<name>: Builder to use. Defaults to the current buildx builder.
  • --registry-single-name=<repo>: Use single name (ECR-style repository) in image refs.
  • --no-registry-cache: Do not add cache_from and cache_to directives to the build Compose file
  • --no-cache: Do not use cache when building the images
  • --github-add-build-cache: Add GHA cache directives to all services

Real-world performance: A case study

Optimizing the CI build involves using multiple techniques while balancing their benefits and constraints. It might be useful to test and measure some combinations to make sure your CI setup works best for your specific use case.

We tested a simple app comprising two built images (in addition to an external db image). In each run, Preevy was used to provision a Preview Environment in GitHub Actions on Google Cloud.

Environment machine sizes

Two machine sizes were tested:

e2-small: 2GB of memory, 0.5-2 vCPUs

e2-medium: 4GB of memory, 1-2 vCPUs

The small machine is good enough for running the app and costs exactly half of the bigger machine.

Build flag configurations

A few variations of the builder, registry, and cache were tested:

BuilderRegistryCachepreevy up flags
1Environment machinenonenoneNone - this is the default build mode
2CI machinenonenone--builder=X
3CI machinenoneGHA--builder=X
--github-add-build-cache
4CI machineGHCRnone--builder=X
--registry=ghcr.io
5CI machineGHCRGHA--builder=X
--registry=ghcr.io
--github-add-build-cache
6CI machineGARnone--builder=X
--registry=my-docker.pkg.dev
7CI machineGARGHA--builder=X
--registry=my-docker.pkg.dev
--github-add-build-cache
Legend:

GHA: GitHub Actions cache GHCR: GitHub Container Registry GAR: Google Artifact Registry

CI scenarios

A few scenarios were tested to simulate CI runs in different stages of the development process:

ScenarioDescriptionCode changesEnvironment machine exists?Registry and cache populated?
AFrom scratch - not likely in CINoNo
BCommit to existing PR,
no code changes
YesYes
CCommit to existing PR,
code changes
A JSX fileYesYes
DCommit to existing PR, dep changespackage.jsonYesYes
EFirst commit to new PRNoYes

Measurements

We measured the following steps in the build job:

VM preparation time was not measured.

Results summary

Offloading the build to the stronger CI machine can reduce the cost of running preview environments significantly - in this sample case by nearly 50%!

  • For the small environment machine, the build was decidedly faster when done on the CI machine.
  • For the bigger environment machine, it was faster to build a new PR on the CI machine, and especially fast with the GitHub registry (which has a good network connection to the CI machine).

Discussion

Network transfers are a major cause of long builds. Both our GAR and the Environment VMs were in the same region, which is geographically remote from GitHub's hosted CI runners.

Building on the Environment machine is advantageous: It does not require cache import/export, nor registry download/upload, and utilizes fully a local cache.

The performance benefits of using a registry and/or cache can be seen when building cross-branch.

Full results

Scenario A: from scratch

This is an unlikely scenario in CI, but it serves as a control group for the others.

e2-small machine
builderregistrycachesetup timebuild timedeploy timetotal time
CI machineGHA1811634169
CI machineGAR79472172
CI machine314237182
CI machineGARGHA1310566183
Environment012859187
CI machineGHCR95310911152
CI machineGHCRGHA145311011168
e2-medium machine
builderregistrycachesetup timebuild timedeploy timetotal time
Environment0692695
CI machineGHCR2474695
CI machineGHCRGHA115051113
CI machineGAR107645130
CI machine311530148
CI machineGHA712030157
CI machineGARGHA119256159

Scenario B: commit to existing PR, no code changes

e2-small machine
builderregistrycachesetup timebuild timedeploy timetotal time
Environment09615
CI machineGHCR108523
CI machineGHCRGHA911524
CI machineGAR534544
CI machineGHA951565
CI machineGARGHA1358576
CI machine210129132
e2-medium machine
builderregistrycachesetup timebuild timedeploy timetotal time
Environment08413
CI machineGHCR37515
CI machineGHCRGHA137524
CI machineGAR1036551
CI machineGARGHA1334552
CI machine29629127
CI machineGHA1510829152

Scenario C: commit to existing PR with code changes

e2-small machine
builderregistrycachesetup timebuild timedeploy timetotal time
Environment092736
CI machineGHCR2243157
CI machineGHCRGHA9305291
CI machineGAR12533095
CI machineGARGHA125932102
CI machineGHA97828115
CI machine611230147
e2-medium machine
builderregistrycachesetup timebuild timedeploy timetotal time
Environment092635
CI machineGHCR3283466
CI machineGAR4672899
CI machineGARGHA126329104
CI machineGHCRGHA104756113
CI machineGHA149126132
CI machine311030143

Scenario D: commit to existing PR with package.json changes

e2-small machine
builderregistrycachesetup timebuild timedeploy timetotal time
CI machineGHCRGHA104352105
CI machine210128131
CI machineGHA99728134
CI machineGARGHA177848143
CI machineGAR69648151
Environment012330153
e2-medium machine
builderregistrycachesetup timebuild timedeploy timetotal time
Environment0292756
CI machineGHCRGHA94948106
CI machineGHCR26451116
CI machine210130132
CI machineGAR710047155
CI machineGHA1212131163
CI machineGARGHA1610447167

Scenario E: first commit to new PR (machine does not exist)

e2-small machine
builderregistrycachesetup timebuild timedeploy timetotal time
CI machine311737157
CI machineGAR68869164
CI machineGARGHA179166174
Environment015356210
CI machineGHCR74610661119
CI machineGHCRGHA134110821136
e2-medium machine
builderregistrycachesetup timebuild timedeploy timetotal time
CI machineGHCR886278
CI machineGAR4285991
CI machineGHCRGHA21165794
Environment0712696
CI machineGARGHA173057104
CI machineGHA118226119
CI machine79427128