Skip to main content

Understand Multi-Platform Support

Introduction

Dagger supports pulling container images and executing commands on platforms that differ from the underlying host.

For example, you can use Dagger to compile binaries that target different CPU architectures, test those binaries and push them to a registry bundled as a multi-platform container image, all on a single host.

This document explores these features through the lens of the Go SDK.

Requirements

This guide assumes that:

  • You have a Go development environment with Go 1.20 or later. If not, download and install Go.

  • You are familiar with the basics of the Go SDK and have it installed. If not, read the Go SDK guide and the Go SDK installation instructions.

  • You have Docker installed and running on the host system. If not, install Docker.

  • You have binfmt_misc configured on the host kernel (that is, on the machine where Dagger will run containers). This is necessary to execute binaries in containers that use a different architecture than that of the host.

    If the host is running Docker, this one-liner will setup everything (only needed once per boot cycle):

    docker run --privileged --rm tonistiigi/binfmt --install all

    Learn more in the binfmt documentation.

    note

    Dagger users running on MacOS can ignore these instructions. By default on MacOS, Dagger will run inside a Linux VM that should already be configured with binfmt_misc.

Terminology

Platform

  • A combination of OS and CPU architecture that executable code may target.
  • Registries compatible with the OCI Image Spec support pulling and pushing images with layers for different platforms all bundled together (see the spec here)

Emulation

  • A technique by which a binary built to target one CPU architecture can be executed on a different CPU architecture via automatic conversion of machine code.
  • Typically quite slow relative to executing on a native CPU.
  • For builds, cross-compilation will generally be much faster when it's an option.

Cross-compilation

  • A technique by which you can build a binary that targets Platform A on a machine of Platform B. I.e. cross-compilation enables you to build a Windows x86_64 binary from a Linux aarch64 host.

Examples

Pull images and execute commands for multiple architectures

This example demonstrates how to pull images for multiple different architectures and execute commands on each of them.

package main

import (
"context"
"fmt"
"os"

"dagger.io/dagger"
)

// list of platforms to execute on
var platforms = []dagger.Platform{
"linux/amd64", // a.k.a. x86_64
"linux/arm64", // a.k.a. aarch64
"linux/s390x", // a.k.a. IBM S/390
}

func main() {
ctx := context.Background()
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()

for _, platform := range platforms {
// initialize this container with the platform
ctr := client.Container(dagger.ContainerOpts{Platform: platform})

// this alpine image has published versions for each of the
// platforms above. If it was missing a platform,
// an error would occur when executing a command below.
ctr = ctr.From("alpine:3.16")

// execute `uname -m`, which prints the current CPU architecture
// being executed as
stdout, err := ctr.
WithExec([]string{"uname", "-m"}).
Stdout(ctx)
if err != nil {
panic(err)
}

// this should print 3 times, once for each of the architectures
// being executed on
fmt.Printf("I'm executing on architecture: %s\n", stdout)
}
}

As illustrated above, you can optionally initialize a Container with a specific platform. That platform will be used to pull images and execute any commands.

If the platform of the Container does not match that of the host, then emulation will be used for any commands specified in WithExec.

If you don't specify a platform, the Container will be initialized with a platform matching that of the host.

Create new multi-platform images

The next step builds on the previous example by:

  1. Building binaries for each of the platforms. We'll use Go binaries for this example.
  2. Combining those binaries into a multi-platform image that we push to a registry.

Start by running builds using emulation. The next example will show the changes needed to instead perform cross-compilation while still building a multi-platform image.

note

This example will fail to push the final image unless you change the registry to one that you control and have write permissions for.

package main

import (
"context"
"fmt"
"os"

"dagger.io/dagger"
)

// the platforms to build for and push in a multi-platform image
var platforms = []dagger.Platform{
"linux/amd64", // a.k.a. x86_64
"linux/arm64", // a.k.a. aarch64
"linux/s390x", // a.k.a. IBM S/390
}

// the container registry for the multi-platform image
const imageRepo = "localhost/testrepo:latest"

func main() {
ctx := context.Background()
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()

// the git repository containing code for the binary to be built
gitRepo := client.Git("https://github.com/dagger/dagger.git").
Branch("086862926433e19e1f24cd709e6165c36bdb2633").
Tree()

platformVariants := make([]*dagger.Container, 0, len(platforms))
for _, platform := range platforms {
// pull the golang image for this platform
ctr := client.Container(dagger.ContainerOpts{Platform: platform})
ctr = ctr.From("golang:1.20-alpine")

// mount in source code
ctr = ctr.WithDirectory("/src", gitRepo)

// mount in an empty dir where the built binary will live
ctr = ctr.WithDirectory("/output", client.Directory())

// ensure the binary will be statically linked and thus executable
// in the final image
ctr = ctr.WithEnvVariable("CGO_ENABLED", "0")

// build the binary and put the result at the mounted output
// directory
ctr = ctr.WithWorkdir("/src")
ctr = ctr.WithExec([]string{
"go", "build",
"-o", "/output/dagger",
"/src/cmd/dagger",
})

// select the output directory
outputDir := ctr.Directory("/output")

// wrap the output directory in a new empty container marked
// with the same platform
binaryCtr := client.
Container(dagger.ContainerOpts{Platform: platform}).
WithRootfs(outputDir)
platformVariants = append(platformVariants, binaryCtr)
}

// publishing the final image uses the same API as single-platform
// images, but now additionally specify the `PlatformVariants`
// option with the containers built before.
imageDigest, err := client.
Container().
Publish(ctx, imageRepo, dagger.ContainerPublishOpts{
PlatformVariants: platformVariants,
// Some registries may require explicit use of docker mediatypes
// rather than the default OCI mediatypes
// MediaTypes: dagger.Dockermediatypes,
})
if err != nil {
panic(err)
}
fmt.Println("Pushed multi-platform image w/ digest: ", imageDigest)
}

Use cross-compilation

The previous example results in emulation being used to build the binary for different architectures.

Emulation is great to have because it requires no customization of build options; the exact same build can be run for different platforms.

However, emulation has the downside of being quite slow relative to executing native CPU instructions.

While cross-compilation is sometimes much easier said than done, it's a great option for speeding up multi-platform builds when feasible.

Fortunately, Go has great built-in support for cross-compilation, so modifying the previous example to use this feature instead is straightforward:

package main

import (
"context"
"fmt"
"os"

"dagger.io/dagger"
platformFormat "github.com/containerd/containerd/platforms"
)

var platforms = []dagger.Platform{
"linux/amd64", // a.k.a. x86_64
"linux/arm64", // a.k.a. aarch64
"linux/s390x", // a.k.a. IBM S/390
}

// the container registry for the multi-platform image
const imageRepo = "localhost/testrepo:latest"

// util that returns the architecture of the provided platform
func architectureOf(platform dagger.Platform) string {
return platformFormat.MustParse(string(platform)).Architecture
}

func main() {
ctx := context.Background()
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()

gitRepo := client.Git("https://github.com/dagger/dagger.git").
Branch("086862926433e19e1f24cd709e6165c36bdb2633").
Tree()

platformVariants := make([]*dagger.Container, 0, len(platforms))
for _, platform := range platforms {
// pull the golang image for the *host platform*. This is
// accomplished by just not specifying a platform; the default
// is that of the host.
ctr := client.Container()
ctr = ctr.From("golang:1.20-alpine")

// mount in our source code
ctr = ctr.WithDirectory("/src", gitRepo)

// mount in an empty dir to put the built binary
ctr = ctr.WithDirectory("/output", client.Directory())

// ensure the binary will be statically linked and thus executable
// in the final image
ctr = ctr.WithEnvVariable("CGO_ENABLED", "0")

// configure the go compiler to use cross-compilation targeting the
// desired platform
ctr = ctr.WithEnvVariable("GOOS", "linux")
ctr = ctr.WithEnvVariable("GOARCH", architectureOf(platform))

// build the binary and put the result at the mounted output
// directory
ctr = ctr.WithWorkdir("/src")
ctr = ctr.WithExec([]string{
"go", "build",
"-o", "/output/dagger",
"/src/cmd/dagger",
})
// select the output directory
outputDir := ctr.Directory("/output")

// wrap the output directory in a new empty container marked
// with the platform
binaryCtr := client.
Container(dagger.ContainerOpts{Platform: platform}).
WithRootfs(outputDir)
platformVariants = append(platformVariants, binaryCtr)
}

// publishing the final image uses the same API as single-platform
// images, but now additionally specify the `PlatformVariants`
// option with the containers built before.
imageDigest, err := client.
Container().
Publish(ctx, imageRepo, dagger.ContainerPublishOpts{
PlatformVariants: platformVariants,
// Some registries may require explicit use of docker mediatypes
// rather than the default OCI mediatypes
// MediaTypes: dagger.Dockermediatypes,
})
if err != nil {
panic(err)
}
fmt.Println("published multi-platform image with digest", imageDigest)
}

The only changes we made to enable faster cross-compilation are:

  1. Pulling the base golang image for the host platform
  2. Configuring the Go compiler to target the specific platform

The final image is still multi-platform because each Container set as a PlatformVariant was initialized with a specific platform (after the cross-compilation has occurred, at the bottom of the for loop in the code above).

Add caching

Caching can significantly improve the speed of your builds in Dagger by saving and reusing the results of expensive operations, such as downloading dependencies or compiling code. Dagger supports caching via the withMountedCache() function, which mounts a cache directory from the host machine into the container.

The following code listing compiles the Go project twice. The first compilation will take longer because the cache directories are empty. The second compilation should be faster, as the cache directories will contain the necessary Go modules and build outputs from the first compilation:

package main

import (
"context"
"fmt"
"os"
"time"

"dagger.io/dagger"
)

func main() {
ctx := context.Background()
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout))
if err != nil {
panic(err)
}
defer client.Close()

gitRepo := client.Git("https://github.com/dagger/dagger.git").
Branch("85dbfe92e85ba7b67f7baec98514d3bd27588a82").
Tree()

build := func() {
ctr := client.Container()

// pull the golang image for the host platform
ctr = ctr.From("golang:1.20-alpine")

// ensure the binary will be statically linked and thus executable
// in the final image
ctr = ctr.WithEnvVariable("CGO_ENABLED", "0")

// mount in an empty dir to put the built binary
ctr = ctr.WithDirectory("/output", client.Directory())

// mount in our source code
ctr = ctr.WithDirectory("/src", gitRepo)

// mount caches for Go modules and build outputs
ctr = ctr.WithMountedCache("/go/pkg/mod", client.CacheVolume("go-mod"))
ctr = ctr.WithMountedCache("/root/.cache/go-build", client.CacheVolume("go-build"))

// set GOCACHE explicitly to point to our mounted cache
ctr = ctr.WithEnvVariable("GOCACHE", "/root/.cache/go-build")

// build the binary and put the result at the mounted output
// directory
ctr = ctr.WithWorkdir("/src")
ctr = ctr.WithExec([]string{
"go", "build",
"-o", "/output/dagger",
"./cmd/dagger",
})

if _, err := ctr.Stdout(ctx); err != nil {
panic(err)
}
}

fmt.Println("Running first build (cache will be empty)...")
startTime := time.Now()
build()
firstBuildDuration := time.Since(startTime)
fmt.Printf("First build took %s\n", firstBuildDuration)

fmt.Println("Running second build (cache will be used)...")
startTime = time.Now()
build()
secondBuildDuration := time.Since(startTime)
fmt.Printf("Second build took %s\n", secondBuildDuration)

fmt.Printf("Using cache improved build time by %s\n", firstBuildDuration-secondBuildDuration)
}

This example defines a build() function that sets up the container, mounts the cache directories, and compiles the Go project. The function runs twice, and the build time for each run is measured. The second build is faster, demonstrating the benefits of using cache mounts.

Support for non-Linux platforms

The previous examples work with different architectures but the OS of the platform is always linux.

As explored in our Get Started tutorial, Dagger can run cross-compilation builds that create binaries targeting other OSes such as Darwin (MacOS) and Windows.

Additionally, Dagger has limited support for some operations involving non-Linux container images. Specifically, it is often possible to pull these images and perform basic file operations, but attempting to execute commands will result in an error:

package main

import (
"context"
"fmt"
"os"

"dagger.io/dagger"
)

func main() {
ctx := context.Background()
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stderr))
if err != nil {
panic(err)
}
defer client.Close()

// pull a Windows base image
ctr := client.
Container(dagger.ContainerOpts{Platform: "windows/amd64"}).
From("mcr.microsoft.com/windows/nanoserver:ltsc2022")

// listing files works, no error should be returned
entries, err := ctr.Rootfs().Entries(ctx)
if err != nil {
panic(err) // shouldn't happen
}
for _, entry := range entries {
fmt.Println(entry)
}

// however, executing a command will fail
_, err = ctr.WithExec([]string{"cmd.exe"}).Stdout(ctx)
if err != nil {
panic(err) // should happen
}
}

FAQ

What is the default value of platform if I don't specify it?

The platform will default to that of the machine running your containers.

If you are running Dagger from MacOS, by default your containers will run in a Linux virtual machine, so your platform will default to either linux/amd64 (on Intel Macs) or linux/arm64 (on ARM Macs).

How do I know the valid values of platform?

The names of OSes and CPU architectures that we support are inherited from the OCI image spec, which in turn inherits names used by Go.

You can see the full list of valid platform strings by running the command go tool dist list. Some examples include:

  • linux/386
  • linux/amd64
  • linux/arm
  • linux/arm64
  • linux/mips
  • linux/mips64
  • linux/mips64le
  • linux/mipsle
  • linux/ppc64
  • linux/ppc64le
  • linux/riscv64
  • linux/s390x
  • windows/386
  • windows/amd64
  • windows/arm
  • windows/arm64

Whether a particular platform can be used successfully with Dagger depends on several factors:

  • Whether an image you are pulling has a published version for that platform
  • Whether QEMU emulation is supported for the architecture and has been configured (as described in Requirements above)
  • Whether the OS is Linux (command execution only works on Linux for now)