The most surprising thing about BuildKit’s Low-Level Builder (LLB) API is that it lets you construct Docker images not by telling BuildKit what you want, but by describing how to get there, command by command, file by file, as if you were writing a shell script inside a sandboxed filesystem.
Let’s see it in action. Imagine we want to build a simple Go application. Normally, you’d write a Dockerfile. With LLB, we’re going to build that same image programmatically.
First, we need to import the necessary BuildKit libraries.
import (
"context"
"fmt"
"io"
"os"
"github.com/moby/buildkit/client"
"github.com/moby/buildkit/client/llb"
"github.com/moby/buildkit/core/container/containerd/snapshotter"
"github.com/moby/buildkit/frontend/dockerfile/builder"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/session/auth/authprovider"
"github.com/moby/buildkit/session/secrets/secretsprovider"
"github.com/moby/buildkit/session/ssh સ/sshprovider"
"github.com/moby/buildkit/util/progress/progressui"
)
Now, let’s define our build process. We’ll start with a base image, golang:1.21-alpine, and then copy our application source into it, build it, and finally create a lean runtime image.
func main() {
ctx := context.Background()
// Connect to a BuildKit daemon.
// You can run `buildkitd` in the background or use Docker Desktop's integrated one.
c, err := client.NewClient(ctx, "unix:///var/run/docker.sock") // Or your BuildKit socket address
if err != nil {
panic(err)
}
// Define the LLB build source.
// This represents the root filesystem of our build environment.
base := llb.Image("golang:1.21-alpine").
// Copy our application source code into the image.
// Assuming your Go source file is named 'main.go' in the current directory.
Add(llb.Local("."), "/app").
// Set the working directory for subsequent commands.
Dir("/app").
// Run the Go build command.
// This executes `go build -o myapp .` inside the container.
Exec(llb.Shlex("go build -o myapp .")).
// Now, create a new stage for our runtime image.
// We start from scratch to keep the final image small.
NewStage().
// Use a minimal base image for the runtime.
Add(llb.Image("alpine:latest"), "/").
// Copy the compiled binary from the build stage to the runtime stage.
Copy(llb.Scratch().File("myapp"), "/myapp").
// Set the entrypoint for the runtime image.
Entrypoint([]string{"/myapp"})
// Define the build options.
// This includes the target platform, output format, etc.
// Here, we're building for the local machine and want a Docker image.
def := base.Marshal(llb.StateMarshalOptions{
// For local builds, you might not need platform unless cross-compiling.
// Platform: llb.Platform("linux/amd64"),
})
opts := []client.SolveOpt{
// We want a Docker image as output.
client.WithResult("docker-image://my-go-app:latest"),
// Define a session for progress reporting and other interactive features.
client.WithSession(
session.NewManager(
authprovider.NewDockerAuthProvider(c),
// Add other providers as needed, e.g., secrets, ssh
secretsprovider.New(nil),
sshprovider.New(nil),
),
),
}
// Execute the build.
// This sends the LLB definition to the BuildKit daemon and starts the build process.
result, err := c.Solve(ctx, def, nil, opts...)
if err != nil {
panic(err)
}
fmt.Printf("Build successful! Image tagged: %s\n", result.Exporter.Target)
}
This code defines a build graph. llb.Image("golang:1.21-alpine") is a node representing the base image. Add(llb.Local("."), "/app") is an edge that takes files from your local directory (.) and places them into the /app directory of the parent node. Exec(llb.Shlex("go build -o myapp .")) is another operation that runs a command within the context of the previous node. NewStage() effectively forks the graph, allowing us to create a separate, optimized final image. The Copy operation then moves the artifact from the build stage to the runtime stage.
The llb.State object base is not an image yet; it’s a directed acyclic graph (DAG) of operations. The Marshal function serializes this DAG into a format BuildKit understands. The client.Solve function sends this serialized DAG to the BuildKit daemon, which then executes the operations, caches intermediate results, and produces the final output specified by client.WithResult.
The key insight is that LLB treats every operation—fetching an image, copying a file, running a command—as a distinct, immutable step in a dependency graph. BuildKit can then optimize this graph, parallelize independent operations, and reuse cached layers across different builds. You’re not just building an image; you’re defining a computation.
The one thing most people don’t grasp is that the llb.Local(".") operation isn’t just copying files; it’s creating a distinct, versioned input to the build graph. If you change a file locally, BuildKit sees it as a new input, invalidating any subsequent build steps that depend on it, and triggering a re-build from that point forward. This granular dependency tracking is what makes BuildKit’s caching so powerful.
The next concept you’ll likely encounter is how to handle build secrets and multi-platform builds using LLB.