#Dagger v0.10.0 Multistage Builds
1 messages ยท Page 1 of 1 (latest)
Hey @delicate cove! Thanks for the feedback
. Sure thing, the multi stage, as far as I now, has not changed in the past few releases. Doing multi stage builds with dagger is a lot less "dramatic" or involved than with Docker. What you typically do is build up the container with all the dependencies and steps that you need and then call functions like file or directory to get specific contents out of the container. Once you have those, you can then start up a new container and mount them in, thus achieving the same multi stage concept that docker offers. In the following example we are creating an alpine container where we install curl, and we then mount curl into a new alpine container. Is a bit silly, but it demonstrates the concept. If you store the dagger.Container object, you can call file or directory for the contents you care about, and then mount them on a new container:
@object_type
class Multistage:
@function
async def multistage(self) -> str:
curl = dag.container().from_("alpine:latest").with_exec(["apk", "add", "curl"]).file("/usr/bin/curl")
return await (
dag.container().from_("alpine:latest").with_file("/usr/bin/curl", curl).with_exec(["curl", "https://dagger.io"]).stdout()
)
Here you can see a more specific multi stage build, it's with Go because it's the one that I have around, but the APIs translate fairly easily between languages. In this example we are building a go binary inside a golang:1.12-alpine container. Then we are creating a new alpine:3.19 container with the dependencies we care about installed, after that we simply mount the binary file that was built previously and that is it!
func (m *Pocketci) BaseContainer(ctx context.Context, src *Directory) *Container {
goModCache := dag.CacheVolume("gomod")
goBuildCache := dag.CacheVolume("gobuild")
pocketci := dag.Container().
From("golang:1.21-alpine").
WithDirectory("/app", src).
WithWorkdir("/app").
WithEnvVariable("CGO_ENABLED", "0").
WithMountedCache("/go/pkg/mod", goModCache).
WithMountedCache("/root/.cache/go-build", goBuildCache).
WithExec([]string{"go", "build", "-ldflags", "-s -w", "-o", "pocketci", "./proxy"}).
File("pocketci")
return dag.
Container().
From("alpine:3.19").
WithExposedPort(8080).
WithFile("/pocketci", pocketci).
WithWorkdir("/").
WithExec([]string{"apk", "add", "--update", "--no-cache", "ca-certificates", "curl", "docker", "openrc"}).
WithExec([]string{"curl", "-LO", "https://github.com/dagger/dagger/releases/download/v0.9.7/dagger_v0.9.7_linux_amd64.tar.gz"}).
WithExec([]string{"tar", "xvf", "dagger_v0.9.7_linux_amd64.tar.gz"}).
WithExec([]string{"mv", "dagger", "/bin/dagger"}).
WithExec([]string{"rm", "dagger_v0.9.7_linux_amd64.tar.gz", "LICENSE"}).
WithEntrypoint([]string{"/pocketci"})
}
Hey @trim verge -- thank you for the quick reply! The new API is a lot more intuitive. I think I've been close all along, the issue I've been running into I think are machine specific. Building "linux/am64" images on "arm64" is really slow and leading me to believe I'm doing something wrong. Perhaps I am... Time will tell. But thank you @trim verge !
The slowness is probably caused because in order to build multi-arch images with python the only way is by using CPU emulation (QEMU). Haven't tried this myself but I'd assume that once the first build finishes, if you're using cache volumes correctly, subsequent builds should be considerably faster given that all pyc files (assuming CPython) should be already pre-generated. Maybe @fallen surge knows any tricks to make this better?
in order to build multi-arch images with python the only way is by using CPU emulation (QEMU)
How would you do it in Go?
Go has native support to cross compile
So you can still use the arm docker image and compile the binary for amd64
Yeah, but pocketci is building a go binary in a container, does it matter that it's Python creating the pipeline?
Oh, not really. I assumed the user is trying to build a python app ๐ซ
But maybe that's not the case ๐
Oh, I got my wires crossed here. I'm missing context.
@delicate cove which kind of app are you trying to build with Dagger?
Seems like it's just building a container image in another architecture. Not necessarily the "app".
So, needs to use dag.container(platform=...)
Yeahh.. I guess it really depends on what the user is doing with that container...
Yep
@delicate cove when possible, if you could share what you're doing in your pipeline that might help us to give you some advice on some possible improvements
Hey @rough tiger -- I'm trying to build a simple dotnet application. I have a few meetings to attend but after I'm done, I'll share what I have. Thank you all for your interest in this matter.
AFAIK dotnet also supports compiling to other architectures via the dotnet restore/publish -a flag. More info about that here: https://devblogs.microsoft.com/dotnet/improving-multiplatform-container-support/
So basically you can use the build container with the same architecture of your CPU and then in another step only fetch the resulting build artifacts to publish the final image
As mentioned before I'm trying to build a simple dotnet starter app (it's an empty project). I translated the generated Dockerfile:
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["DotnetNixDocker.csproj", "./"]
RUN dotnet restore "DotnetNixDocker.csproj"
COPY . .
WORKDIR "/src/"
RUN dotnet build "DotnetNixDocker.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "DotnetNixDocker.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DotnetNixDocker.dll"]
I translated the Dockerfile code to the following Python code:
def get_architecture(platform: str) -> str:
return platform[platform.find("/") + 1:]
@function
async def build_image(self, path: dagger.Directory) -> dagger.Container:
"""Build simple test dotnet application"""
platform = "linux/arm64"
build = (
dag.container(platform=dagger.Platform(platform))
.from_("mcr.microsoft.com/dotnet/sdk:8.0")
.with_workdir("/src")
.with_file("./DotnetNixDocker.csproj", path.file("./DotnetNixDocker.csproj"))
.with_exec(["dotnet", "restore", "DotnetNixDocker.csproj"])
.with_directory(".", path.directory("."))
.with_exec(["ls", "-ltr"])
.with_exec(["dotnet", "build", "DotnetNixDocker.csproj", "-c", "release", "-o", "/app/build"])
)
publish = (
build
.with_directory("/app/build", build.directory("/app/build"))
.with_exec(["dotnet", "publish", "DotnetNixDocker.csproj", "-c", "release", "-o", "/app/publish",
"/p:UseAppHost=false"])
)
return await (
dag.container(platform=dagger.Platform(platform))
.with_directory("/app/build", publish.directory("/app/publish"))
.with_exposed_port(8080)
.with_exposed_port(8081)
.with_entrypoint(["dotnet", "DotnetNixDocker.dll"])
)
To help debug I using the following helper function:
@function
async def build_image(self, path: dagger.Directory) -> dagger.Container:
"""Build simple test dotnet application"""
platform = "linux/arm64"
build = (
dag.container(platform=dagger.Platform(platform))
.from_("mcr.microsoft.com/dotnet/sdk:8.0")
.with_workdir("/src")
.with_file("./DotnetNixDocker.csproj", path.file("./DotnetNixDocker.csproj"))
.with_exec(["dotnet", "restore", "DotnetNixDocker.csproj"])
.with_directory(".", path.directory("."))
.with_exec(["ls", "-ltr"])
.with_exec(["dotnet", "build", "DotnetNixDocker.csproj", "-c", "release", "-o", "/app/build"])
)
publish = (
build
.with_directory("/app/build", build.directory("/app/build"))
.with_exec(["dotnet", "publish", "DotnetNixDocker.csproj", "-c", "release", "-o", "/app/publish",
"/p:UseAppHost=false"])
)
return await (
dag.container(platform=dagger.Platform(platform))
.with_directory("/app/build", publish.directory("/app/publish"))
.with_exposed_port(8080)
.with_exposed_port(8081)
.with_entrypoint(["dotnet", "DotnetNixDocker.dll"])
)
When I set the platform to linux/arm64 everything runs beautifully! But when I run the helper function with linux/amd64 -- it never finishes ๐
I started a re-run about 17 mins ago. Prior during my meeting I let it run for over 4 hours. Granted, I only included the platform for the docker build, I did not specify the architecture (nice find btw @rough tiger ).
yes exactly. You should use the arm architecture and the leverage the -a flag in publish/restore to build your app for the desired architecture ๐ช
It works, and so much faster too! Thanks a lot @rough tiger One last question. Is it possible to tag a build with dagger? I want to be able to interact with the image locally without having to push to a repository which it appears publish requires.
You can use:
dagger call build-image terminal --cmd=<cmd>
Not sure what command from the container you can use to get into a shell.
Btw, that await in the return seems unnecessary.
Interactive terminal is a nice feature. However, it's not working for me. This is the error I'm getting:
โ terminal 0.1s
โ start sh 0.1s
โ check tl81kn9ake9ue.j97aj62i2roug.dagger.local 8080/tcp 8081/tcp 0.1s
โ polling for port tl81kn9ake9ue.j97aj62i2roug.dagger.local:8080
โ port not ready: dial tcp 10.87.0.27:8080: connect: connection refused; elapsed: 461.208ยตs
Error: exited with code 1
@fallen surge thanks for the insight. Learning about the chaining function of dagger CLI I tried another approach exporting docker container contents as a tar ball using this command:
dagger call build-image --path="../../" as-tarball export --path dagger-built-image.tar
However, when attempting to load the tar ball I get this error:
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "dotnet": executable file not found in $PATH: unknown.
To be honest I'm not familiar with this function of Docker. But considering it's a built image, I'd expect dotnet to be in the runtime. I guess I'm missing something, just not sure what yet.
That could be the same issue you're getting in the interactive terminal.
With docker run you can override the entrypoint with -e to a shell.
What's the shell in this container? mcr.microsoft.com/dotnet/sdk
Overriding the endpoint with this command:
docker run --rm --entrypoint /bin/bash 408be999b30423040ea28c9d7715dbd75b848b9f4eed6b2eb6c13d1f5f68a919 -c "ls -ltr"
I get the following result:
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "/bin/bash": stat /bin/bash: no such file or directory: unknown.
It seems the container is not in a valid state. To answer your question @fallen surge the shell for mcr.microsoft.com/dotnet/sdk:8.0 and mcr.microsoft.com/dotnet/aspnet:8.0 is /bin/bash
Ok, so I'd strip a few steps from the function (just comment some lines) until this works:
dagger call build-image --path="../../" terminal --cmd=/bin/bash
You can start with just this to be sure:
return dag.container(platform=dagger.Platform(platform)).from_("mcr.microsoft.com/dotnet/sdk:8.0")
Small simplification here ๐:
-.with_directory(".", path.directory("."))
+.with_directory("", path)
I've noticed some differences between your code and the Dockerfile. I've split the Dockerfile stages into different functions, so you can more granularly call terminal on any one of those to debug:
@object_type
class Dotnet:
src: dagger.Directory
platform: str = "linux/arm64"
@function
def base(self) -> dagger.Container:
return (
dag.container(platform=dagger.Platform(self.platform))
.from_("mcr.microsoft.com/dotnet/aspnet:8.0")
.with_workdir("/src")
.with_exposed_port(8080)
.with_exposed_port(8081)
)
@function
def build(self) -> dagger.Container:
return (
dag.container(platform=dagger.Platform(self.platform))
.from_("mcr.microsoft.com/dotnet/sdk:8.0")
.with_workdir("/src")
.with_file("", self.src.file("DotnetNixDocker.csproj"))
.with_exec(["dotnet", "restore", "DotnetNixDocker.csproj"])
.with_directory("", self.src)
.with_exec(["dotnet", "build", "DotnetNixDocker.csproj", "-c", "Release", "-o", "/app/build"])
)
@function
def publish(self) -> dagger.Container:
return (
self.build()
.with_exec(["dotnet", "publish", "DotnetNixDocker.csproj", "-c", "Release", "-o", "/app/publish", "/p:UseAppHost=false"])
)
@function
async def final(self) -> dagger.Container:
base = self.base()
if app_uid := await base.env_variable("APP_UID"):
base = base.with_user(app_uid)
return (
base
.with_workdir("/app")
.with_directory("", self.publish().directory("publish"))
.with_entrypoint(["dotnet", "DotnetNixDocker.dll"])
)
For example, just the publish step:
dagger call --path="../../" publish terminal --cmd=/bin/bash
Figured one issue out. The publish image (final layer) did not have a base container defined, it was missing this .from_("mcr.microsoft.com/dotnet/aspnet:8.0") embarrassing. I'm looking into another issue right now:
Unhandled exception. System.IO.FileLoadException: Could not load file or assembly 'DotnetNixDocker, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null'.
but I think this is close to being resolved. ๐
Try my version, it has that solved and more.
Of course I couldn't test it so it may not work off the bat, but it's more faithful to the Dockerfile.
Also easier to debug.
Appreciate it @fallen surge! -- I will after my daily meetings.