Demystifying the Buildpacks frontend for BuildKit

Shem Leong
5 min readFeb 4, 2022

--

Photo by SHVETS production from Pexels

In this article, we’ll do a deep dive into the implementation of the Buildpacks frontend for BuikdKit and see how it actually works under the hood. However, before we do so, we’ll need to breeze through a few concepts.

What is a Container Image?

A container image consists of a config file and layer tarballs. Usually, they are made from a series of instructions like FROM, ADD, and RUN, which are placed in a Dockerfile and built with docker build. However, docker build is just one way of ending up with this output.

Cloud Native Buildpacks

Buildpacks belongs to the family of non-Dockerfile based image building tools. It takes your application source code and transforms it into an OCI image, usually without any additional input from the user. To use Buildpacks to package your code, you would run the Pack CLI, specifying your application source code as well as a builder, which is an ordered collection of buildpacks to be executed.

BuildKit

BuildKit is defined as a toolkit for converting source code into build artifacts in an efficient, expressive and repeatable manner. Note that this means that BuildKit is not limited to building OCI images, although it is typically used as such.

In this respect, it can be compared to docker build albeit with improved performance, storage management, feature functionality, and security. The most significant advantage BuildKit has is the ability to execute build steps in parallel, thanks to its concurrent dependency solver. BuildKit is also highly configurable, in terms of build definition formats (frontends), output formats (direct to registry, tarballs, cache warming) and cache imports / exports.

LLB

BuildKit builds are based on a low-level, binary intermediate format called LLB (low-level builder). The entire dependency graph of your build, how to execute and what to cache is defined in LLB.

Frontends

A frontend is a component that takes a human-readable build definition and converts it to LLB so that BuildKit can execute it. The most well known frontend is the Dockerfile frontend (dockerfile.v0). Since Docker v18.06, BuildKit has been integrated into docker build and can be enabled by setting the environment variable DOCKER_BUILDKIT=1.

There is also a special frontend called gateway (gateway.v0) that allows any image to be used as a frontend.

How buildkit-pack Works

buildkit-pack was authored by Tonis Tiigi, the maintainer of BuildKit. It is the ‘official’ BuildKit frontend for Buildpacks. buildkit-pack has been packaged as an image and pushed to the public Docker Hub registry. The last commit on the repository was, surprisingly, 3 years ago.

Although not explicitly stated in the README, the ‘high-level’ build definition supported by this frontend is actually a deprecated version of the CloudFoundry manifest. Within the manifest, you can specify the buildpack to use to build your application.

# syntax = tonistiigi/pack
---
applications:
- name: myapp
buildpack: python_buildpack
command: python hello.py

With Docker v18.06+:

DOCKER_BUILDKIT=1 docker build -f manifest.yml .

Where the manifest here contains # syntax = tonistiigi/pack as the first line. BuildKit detects the syntax keyword and converts it into a source opt.

With BuildKit:

buildctl build --frontend=gateway.v0 --opt source=tonistiigi/pack --local context=.

You will still require a manifest.yml. But in this case, you can go without the # syntax = tonistiigi/pack annotation.

BuildKit then starts a container using the tonistiigi/pack image and the build opts that you pass to the buildctl command.

Deep Diving into the Source Code

Looking at the buildkit-pack source code, you’ll find the following directory structure:

cmd
hack
vendor
.travis.yml
Dockerfile
LICENSE
README.md
build.go
manifest.go
vendor.conf

It’s actually a fairly small codebase with the important logic contained in within a few files.

Dockerfile

This Dockerfile is used to package the frontend as an image. It essentially compiles the Golang code into a single binary, pack . This executable is copied to bin/pack and set as the ENTRYPOINT of the runtime image.

manifest.go

Contains logic to parse a CloudFoundry manifest and for the following keys: applications, buildpack, command, env. And for each application, the following keys are parsed: name, buildpack, command, env.

This implication here is that buildkit-pack essentially limits us to a single buildpack per application. We can’t specify a custom builder, which is a huge drawback, as it means developers cannot simply write application code. They also have to be worry about buildpack selection.

Note that is it possible to not specify your own buildpack and allow auto detection to select one of the CloudFoundry system buildpacks.

build.go

Contains the bulk of the build definition transformation logic. It parses the build opts invoked with the bin/pack executable along with the manifest. It then uses the BuildKit LLB client to programmatically construct the LLB. The Builder design pattern is used here to assemble the LLB.

There are 3 stages (build, extract, run), each of them starts a container with a given base image and executes some instructions. The stages are nested one after another to generate the LLB.

The overall call flow is as follows.

Constructing the LLB:

  • Parse the build opts
  • Parse the manifest to obtain the buildpack, start command and env vars
  • Resolve the local context (application source code)
  • Resolve the build image (hardcoded as docker.io/packs/cflinuxfs2:build) as the root of the build stage
  • Augment the build stage with the env vars
  • Augment the build stage with a RUN command. This command runs the /packs/builder executable and outputs a tarball. /packs/builder is already contained in the build image and it is actually a compatibility layer that implements the Buildpacks lifecycle.
  • Mount the application source code to the /src directory on the build stage
  • Mount a cache to the /tmp directory on the build stage
  • Resolve an alpine image as the root of the extract stage
  • Mount the build stage to the /in directory on the extract stage
  • Augment the extract stage with a RUN command. This command basically extracts the tarball from the build stage to the /out directory.
  • Resolve the run image (hardcoded as docker.io/packs/cflinuxfs2:run) as the root of the run stage
  • Mount the /out directory of the extract stage to the run stage

Executing the LLB:

  • Converts the LLB to protobuf
  • Sends a SolveRequest to the buildkit daemon
  • Sends a ResolveImageConfigRequest to the buildkit daemon
  • Returns the SolveResponse

There are, unfortunately, some issues with this implementation:

  • Only works with local context, and does not support git / http sources.
  • The stack (build image and run image) is pretty much hardcoded.
  • Although the CloudFoundry manifest supports multiple applications (either for a monorepo or multiple process types), only the first application is taken into consideration.
  • The command use to execute the build is not the Cloud Native Buildpacks Pack CLI, but rather, it is a CloudFoundry specific implementation.

Conclusion

buildkit-pack is no longer being maintained, and as we have seen, it is not a generic implementation for a Buildpacks frontend. It is rather specific to CloudFoundry and does not support critical features such as providing a builder and using project descriptors. In fact, it was only meant to be a proof of concept to demonstrate the flexibility of BuildKit.

--

--

Shem Leong
Shem Leong

Written by Shem Leong

Software engineer and indieparent • Building a applimiter.com in public

No responses yet