Demystifying the Buildpacks frontend for BuildKit
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.
- Source code: https://github.com/tonistiigi/buildkit-pack
- Image: https://hub.docker.com/r/tonistiigi/pack
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 thebuild stage
- Augment the
build stage
with the env vars - Augment the
build stage
with aRUN
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 thebuild stage
- Mount a cache to the
/tmp
directory on thebuild stage
- Resolve an alpine image as the root of the
extract stage
- Mount the
build stage
to the/in
directory on theextract stage
- Augment the
extract stage
with aRUN
command. This command basically extracts the tarball from thebuild stage
to the/out
directory. - Resolve the run image (hardcoded as
docker.io/packs/cflinuxfs2:run
) as the root of therun
stage - Mount the
/out
directory of theextract stage
to therun 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.