00%
blog.info()
← Back to Home
SEQUENCE // DevOps

Doubling Build Speed

Author Thorn Hall
0

Background

As a refresher, this site has two components

  • The build phase, which generates the HTML, CSS, and JavaScript, as well as OG images, architecture diagrams, and WASM files
  • The runtime phase, which serves the pre-generated files to you over the network

This article is focusing on the build phase, and is the story of how I doubled the speed of my build pipeline.

The Old Pipeline

Let's reconstruct my original build pipeline first to fully understand how and why the changes I made increased the build speed.

The first step: naming your job and the triggers.

1name: Build and Deploy
2
3on:
4  push:
5    branches: [ "main" ]

This tells Github to run this pipeline whenever I push a change to the main branch. The main branch is the central location for all of my production code.

Now let's add the actual pipeline logic.

First, we will add tests to the pipeline. We need our automated tests to pass in order for the deployment to succeed. If they fail, it could mean we broke something with our changes.

 1name: Build and Deploy
 2
 3on:
 4  push:
 5    branches: [ "main" ]
 6
 7jobs:
 8  test:
 9    runs-on: ubuntu-latest
10    steps:
11      - uses: actions/checkout@v4
12      - uses: actions/setup-go@v4
13        with:
14          go-version: '1.24.3'
15      - name: Run Tests
16        run: go test -v -race ./...

In this block, we defined the jobs part of the YML, which defines what will actually happen when I push a change to the main branch.

Now, let's move onto the build process. Note that the following is a sub element of jobs, just like test.

 1build:
 2    runs-on: ubuntu-latest
 3    steps:
 4      - uses: actions/checkout@v4
 5      - uses: actions/setup-go@v4
 6        with:
 7          go-version: '1.24.3'
 8
 9      - uses: acifani/setup-tinygo@v1
10        with:
11          tinygo-version: '0.39.0'
12
13      - name: Install Graphviz
14        run: sudo apt-get update && sudo apt-get install -y graphviz
15
16      - run: go run cmd/builder/main.go
17
18      - name: Verify OG Images
19        run: |
20          if [ -z "$(ls -A public/assets/og/*.png 2>/dev/null)" ]; then
21             echo "Error: No OG images generated!"
22             exit 1
23          fi          
24
25      - run: GOOS=linux GOARCH=amd64 go build -o myblog cmd/server/main.go
26
27      - name: Upload Artifacts
28        uses: actions/upload-artifact@v4
29        with:
30          name: release-files
31          path: |
32            myblog
33            public/
34            assets/            
35          retention-days: 1

This block is longer but it performs all of the necessary steps to build our application binary.

  • First, it sets up Go.
  • Then, it sets up TinyGo, which is needed for the WASM game of life simulation on the homepage.
  • It installs Graphviz, which is needed for the architecture diagrams.
  • Then, it runs the builder application logic which generates HTML files, WASM, and architecture diagrams.
  • It has a verification step to make sure OG images were actually generated by the builder.
  • Compiles the Go project into a single binary that can be run on Linux.
  • Finally, it uploads the binary and the HTML/WASM files generated by the builder, which will be used by the deploy step.

Note: There is also a Deploy job, which actually takes the uploaded artifacts and uploads them to the server running my blog, but I'm going to exclude that for brevity as it didn't need to be optimized at all. I wanted to mention it, though, to have a holistic view on what the pipeline does.

Let's discuss some optimizations we can make to increase the speed of this pipeline.

Optimizing Installations

Let's recap what the builder is doing in the first few steps.

  • First, it sets up Go.
  • Then, it sets up TinyGo, which is needed for the WASM game of life simulation on the homepage.
  • It installs Graphviz, which is needed for the architecture diagrams.

This can be optimized. We shouldn't need to do this every time we make a change to the code. What if we built a Docker image that comes with all of this pre-installed? This has a few benefits.

  • Our build will run in its own container which will not change unless we change it. The Github Action runners' OS or other internals could change otherwise, potentially breaking our build without this container.
  • We can pre-package Go, TinyGo, and Graphviz all in the Docker image so that we don't need to re-download and install them every time.

To do this, we create a Dockerfile.build file. That file looks like this:

 1FROM ubuntu:22.04
 2
 3ENV DEBIAN_FRONTEND=noninteractive
 4
 5RUN apt-get update && apt-get install -y \
 6    curl \
 7    git \
 8    build-essential \
 9    graphviz \
10    && rm -rf /var/lib/apt/lists/*
11
12RUN apt-get update && apt-get install -y \
13    curl \
14    wget \
15    git \
16    build-essential \
17    graphviz \
18    && rm -rf /var/lib/apt/lists/*
19
20RUN wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
21    && apt-get update \
22    && apt-get install -y ./google-chrome-stable_current_amd64.deb \
23    && rm google-chrome-stable_current_amd64.deb \
24    && rm -rf /var/lib/apt/lists/*
25
26RUN curl -OL https://go.dev/dl/go1.24.3.linux-amd64.tar.gz \
27    && tar -C /usr/local -xzf go1.24.3.linux-amd64.tar.gz \
28    && rm go1.24.3.linux-amd64.tar.gz
29
30ENV PATH=$PATH:/usr/local/go/bin
31
32RUN curl -L -o tinygo_0.39.0_amd64.deb https://github.com/tinygo-org/tinygo/releases/download/v0.39.0/tinygo_0.39.0_amd64.deb \
33    && dpkg -i tinygo_0.39.0_amd64.deb \
34    && rm tinygo_0.39.0_amd64.deb
35
36WORKDIR /app
37
38COPY go.mod go.sum ./
39RUN go mod download

Note that it also installs Chrome, as that's needed for our OG image generation during the app build step. Now that we have this docker image, all our Github Actions pipeline needs to do is download the image from the Docker registry. It no longer needs to re-install all of the dependencies that our app needs to build the static site.

We also need to tell the build job that it will be using the docker image:

1  build:
2      runs-on: ubuntu-latest
3      container:
4        image: ghcr.io/thornhall/my-blog-builder:latest

Optimizing App Builder

Now we need to optimize this step:

1- run: go run cmd/builder/main.go

To optimize this, we need to understand what it's doing:

  • It builds the Go application binary for the builder
  • It builds the HTML files
  • It builds OG images
  • It builds architecture diagrams
  • It builds WASM files
  • It compresses all of them

One thing to notice is that we don't need to redo all of this work for every change to the repo. Often times, we can re-use the HTML files that were generated from the previous step. For example, the articles I write don't change often, so why would we regenerate them every single time? The same is true by extension for the OG images, which are based off the articles.

To optimize this, we will cache files from previous builds and reuse them when we can. We will also cache the first step, which is building the application binary.

 1- name: Cache Go Modules
 2    uses: actions/cache@v4
 3    with:
 4    # Note: be sure to specify the path for GOCACHE and GOMODCACHE in the env to match these
 5    path: |
 6        /root/go/pkg/mod
 7        /root/.cache/go-build        
 8    key: go-deps-${{ runner.os }}-${{ hashFiles('**/go.sum') }}
 9    restore-keys: |
10        go-deps-${{ runner.os }}-
11                
12- name: Restore Previous Build Assets
13    uses: actions/cache@v4
14    with:
15    path: |
16        public/             
17        .build_cache.json        
18    key: build-assets-${{ runner.os }}-${{ hashFiles('templates/**', 'cmd/builder/**') }}
19    restore-keys: |
20                build-assets-${{ runner.os }}-

To do this, we use the cache v4 action. Note that we cache our Go modules separately from our static assets such as HTML files. This is so we don't need to re-download Go modules when we're making simple changes to our app.

Cache Go Modules tells the Github pipeline to only invalidate the cache if the go.sum file changes, which means we introduced a new dependency to be downloaded.

Restore Previous Build Assets tells the pipeline to invalidate the cache if we change the builder itself or any of the HTML templates.

Note: I also needed to add application logic in builder.go to check for already-existing files before generating them, but I'm not including that here as the main focus of this article is the pipeline itself. You could imagine it does something simple like checking if the file path it is trying to generate is already present in the environment it's running in before generating the file.

Optimizing Artifacts

We can perform one last optimization. With the generated artifacts (the Go binary + HTML files + WASM files + images and etc), we can compress them before uploading. This will decrease the time the runner spends sending data over the network, speeding up the pipeline.

 1- name: Package Release
 2    run: |
 3    GOOS=linux GOARCH=amd64 go build -o myblog cmd/server/main.go
 4    tar -czf release.tar.gz myblog public/ assets/    
 5
 6- name: Upload Artifacts
 7    uses: actions/upload-artifact@v4
 8    with:
 9    # Note: be sure to use the same name and to unzip in your deploy step
10    name: release-bundle
11    path: release.tar.gz
12    retention-days: 1

Summary

Here's a list of the optimizations we performed and why we performed them.

  • We built our application with a docker image so that the Github pipeline runner doesn't need to download Go, TinyGo, Graphviz, and Chrome on every run. We also get the benefit of reproducible and isolated builds that don't change when the Github runner changes.
  • We optimized our app builder to cache its artifacts. If nothing on the critical path changes, we can reuse what we generated on the previous run. This dramatically improves speed for when we make a simple change such as adding a new article.
  • We compressed the build artifacts (Go executable, images, HTML files, etc) so that when they're uploaded by the runner, it goes much faster.

The Final Result

My build pipeline went from taking over 4 minutes to run to now taking around 1 minute and 40 seconds. The feedback loop between making a change and seeing the result is now much smaller which improves iteration speed and attention spans.

View Abstract Syntax Tree (Build-Time Generated)
Document
Heading
Text "Background"
Paragraph
Text "As a refresher, this site h..."
Text " components"
List
ListItem
TextBlock
Text "The build phase, which gene..."
Text " files"
ListItem
TextBlock
Text "The runtime phase, which se..."
Text " network"
Paragraph
Text "This article is focusing on..."
CodeSpan
Text "build"
Text " phase, and is the story of..."
Text " pipeline."
Heading
Text "The Old"
Text " Pipeline"
Paragraph
Text "Let's reconstruct my origin..."
Text " speed."
Paragraph
Text "The first step: naming your..."
Text " triggers."
FencedCodeBlock code: "name: Build and D..."
Paragraph
Text "This tells Github to run th..."
CodeSpan
Text "main"
Text " branch. The "
CodeSpan
Text "main"
Text " branch is the central loca..."
Text " code."
Paragraph
Text "Now let's add the actual pi..."
Text " logic."
Paragraph
Text "First, we will add tests to..."
Text " changes."
FencedCodeBlock code: "name: Build and D..."
Paragraph
Text "In this block, we defined the "
CodeSpan
Text "jobs"
Text " part of the YML, which def..."
CodeSpan
Text "main"
Text " branch."
Paragraph
Text "Now, let's move onto the bu..."
CodeSpan
Text "jobs"
Text ", just like "
CodeSpan
Text "test"
Text "."
FencedCodeBlock code: "build: "
Paragraph
Text "This block is longer but it..."
Text " binary."
List
ListItem
TextBlock
Text "First, it sets up"
Text " Go."
ListItem
TextBlock
Text "Then, it sets up TinyGo, wh..."
Text " homepage."
ListItem
TextBlock
Text "It installs Graphviz, which..."
Text " diagrams."
ListItem
TextBlock
Text "Then, it runs the builder a..."
Text " diagrams."
ListItem
TextBlock
Text "It has a verification step ..."
Text " builder."
ListItem
TextBlock
Text "Compiles the Go project int..."
Text " Linux."
ListItem
TextBlock
Text "Finally, it uploads the bin..."
Text " step."
Paragraph
Emphasis
Text "Note"
Text ": There is also a Deploy jo..."
Text " does."
Paragraph
Text "Let's discuss some optimiza..."
Text " pipeline."
Heading
Text "Optimizing"
Text " Installations"
Paragraph
Text "Let's recap what the builde..."
Text " steps."
List
ListItem
TextBlock
Text "First, it sets up"
Text " Go."
ListItem
TextBlock
Text "Then, it sets up TinyGo, wh..."
Text " homepage."
ListItem
TextBlock
Text "It installs Graphviz, which..."
Text " diagrams."
Paragraph
Text "This can be optimized. We s..."
Text " benefits."
List
ListItem
TextBlock
Text "Our build will run in its o..."
Text " container."
ListItem
TextBlock
Text "We can pre-package Go, Tiny..."
Text " time."
Paragraph
Text "To do this, we create a "
CodeSpan
Text "Dockerfile.build"
Text " file. That file looks like"
Text " this:"
FencedCodeBlock code: "FROM ubuntu:22.04 "
Paragraph
Text "Note that it also installs ..."
Text " step."
Text "Now that we have this docke..."
Text " to"
Text "re-install all of the depen..."
Text " site."
Paragraph
Text "We also need to tell the bu..."
Text " image:"
FencedCodeBlock code: " build: "
Heading
Text "Optimizing App"
Text " Builder"
Paragraph
Text "Now we need to optimize this"
Text " step:"
FencedCodeBlock code: "- run: go run cmd..."
Paragraph
Text "To optimize this, we need t..."
Text " doing:"
List
ListItem
TextBlock
Text "It builds the Go applicatio..."
Text " builder"
ListItem
TextBlock
Text "It builds the HTML"
Text " files"
ListItem
TextBlock
Text "It builds OG"
Text " images"
ListItem
TextBlock
Text "It builds architecture"
Text " diagrams"
ListItem
TextBlock
Text "It builds WASM"
Text " files"
ListItem
TextBlock
Text "It compresses all of"
Text " them"
Paragraph
Text "One thing to notice is that..."
Text " articles."
Paragraph
Text "To optimize this, we will "
CodeSpan
Text "cache"
Text " files from previous builds..."
Text " binary."
FencedCodeBlock code: "- name: Cache Go ..."
Paragraph
Text "To do this, we use the cach..."
Text " app."
Paragraph
CodeSpan
Text "Cache Go Modules"
Text " tells the Github pipeline ..."
CodeSpan
Text "go.sum"
Text " file changes, which means ..."
Text " downloaded."
Paragraph
CodeSpan
Text "Restore Previous Build Assets"
Text " tells the pipeline to inva..."
Text " templates."
Paragraph
Emphasis
Text "Note"
Text ": I also needed to add appl..."
CodeSpan
Text "builder.go"
Text " to check for already-exist..."
Text " file."
Heading
Text "Optimizing"
Text " Artifacts"
Paragraph
Text "We can perform one last opt..."
CodeSpan
Text "compress"
Text " them before uploading. Thi..."
Text " pipeline."
FencedCodeBlock code: "- name: Package R..."
Heading
Text "Summary"
Paragraph
Text "Here's a list of the optimi..."
Text " them."
List
ListItem
TextBlock
Text "We built our application wi..."
Text " changes."
ListItem
TextBlock
Text "We optimized our app builde..."
Text " article."
ListItem
TextBlock
Text "We compressed the build art..."
Text " faster."
Heading
Text "The Final"
Text " Result"
Paragraph
Text "My build pipeline went from..."
Text " spans."