#Dagger v0.10.0 Multistage Builds

1 messages ยท Page 1 of 1 (latest)

delicate cove
#

First let me say, I love the new API and direction dagger is going. However, I no longer understand how to make multi-stage builds. Does anyone have an example using the new API with the python SDK?

trim verge
#

Hey @delicate cove! Thanks for the feedback party_gopher. 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"})
}
delicate cove
#

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 !

rough tiger
# delicate cove Hey <@628392087880073217> -- thank you for the quick reply! The new API is a lot...

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?

fallen surge
rough tiger
#

So you can still use the arm docker image and compile the binary for amd64

fallen surge
#

Yeah, but pocketci is building a go binary in a container, does it matter that it's Python creating the pipeline?

rough tiger
#

But maybe that's not the case ๐Ÿ˜›

fallen surge
#

Oh, I got my wires crossed here. I'm missing context.

rough tiger
#

@delicate cove which kind of app are you trying to build with Dagger?

fallen surge
#

Seems like it's just building a container image in another architecture. Not necessarily the "app".

#

So, needs to use dag.container(platform=...)

rough tiger
fallen surge
#

Yep

rough tiger
#

@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

delicate cove
#

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.

rough tiger
#

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

delicate cove
#

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 ).

rough tiger
delicate cove
#

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.

fallen surge
#

Not sure what command from the container you can use to get into a shell.

#

Btw, that await in the return seems unnecessary.

delicate cove
#

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.

fallen surge
#

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.

delicate cove
#

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

fallen surge
#

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)
fallen surge
# delicate cove As mentioned before I'm trying to build a simple dotnet starter app (it's an emp...

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
delicate cove
#

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. ๐Ÿ™‚

fallen surge
#

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.

delicate cove
#

Appreciate it @fallen surge! -- I will after my daily meetings.