- Published on
Shipping conda environments to production using pixi
- Authors
In the recent months, we have seen pixi become a popular package manager for the conda ecosystem. This is because it provides a simple and fast way to install environments and get started with coding. One huge advantage of pixi is that it comes with support for lockfiles out of the box. When using conda, we always need to deal with additional setup steps to ensure that our environments are reproducible. Pixi, on the other hand, provides a lockfile that we can commit to our repository. Also, when the lockfile changes, pixi makes sure that the environment on our machine is updated accordingly. For more reasons to use pixi over conda/mamba/micromamba, see the blog post from our friends at prefix-dev.
At QuantCo, we already migrated more than 40 repositories to pixi and are very happy with the results. This way, we can ensure that all developers are using the same environment and that our pipelines are reproducible.
However, with a new package management tool, we also need a new way to ship our environments to production. In this blog post, we show two ways to do that using pixi. First, we show how to install pixi environments in docker containers. Second, for situations where we don't have access to docker in our production environment (for example when the target environment is Windows), we show how to use our new pixi-pack tool to pack and unpack pixi environments.
Containerizing pixi environments
Uwe Korn has written a blog post on Deploying conda environments in docker containers. He explained how to create a Dockerfile using conda-lock
and making it as efficient as possible. We will show how to do the same with pixi. As an example, we are using a simple FastAPI web server that we want to deploy to production. The code is available in pavelzw/pixi-docker-example.
Naive approach
We start out with the official pixi docker container, copy our code into our container, and then run pixi install --locked
to install our environment.
FROM ghcr.io/prefix-dev/pixi:0.23.0 AS build
WORKDIR /app
COPY . .
RUN pixi install --locked
EXPOSE 8000
CMD [ "pixi", "run", "start" ]
Note: Please make sure that .pixi
is in the .dockerignore
file. Otherwise, docker will copy the environment into the container which could lead to problems when the build machine is a different platform or architecture than the target machine.
Let's look at the image size of this container:
$ docker image ls pixi-docker-example:1-naive-approach
REPOSITORY TAG IMAGE ID CREATED SIZE
pixi-docker-example 1-naive-approach dff14d81f526 2 seconds ago 691MB
This is pretty big for a simple application that only displays Hello, World!
. Looking at the layers of the container, we see that the pixi install
step is the largest layer.
$ docker history pixi-docker-example:1-naive-approach
IMAGE CREATED CREATED BY SIZE COMMENT
dff14d81f526 2 seconds ago CMD ["pixi" "run" "start"] 0B buildkit.dockerfile.v0
<missing> 2 seconds ago EXPOSE map[8000/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 2 seconds ago RUN /bin/sh -c pixi install --locked # build… 577MB buildkit.dockerfile.v0
<missing> 6 seconds ago COPY . . # buildkit 97.2kB buildkit.dockerfile.v0
<missing> 20 hours ago WORKDIR /app 0B buildkit.dockerfile.v0
<missing> 8 days ago COPY --chown=root:root --chmod=0555 /pixi /u… 36.7MB buildkit.dockerfile.v0
<missing> 5 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 5 weeks ago /bin/sh -c #(nop) ADD file:a5d32dc2ab15ff0d7… 77.9MB
<missing> 5 weeks ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 5 weeks ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 5 weeks ago /bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH 0B
<missing> 5 weeks ago /bin/sh -c #(nop) ARG RELEASE 0B
Remove the cache directory
The problem with this approach is that pixi install
not only installs the required packages but also places them in the rattler cache in ~/.cache/rattler
. On our local machine, this is not a problem since we probably want to reuse the cache and the files are hardlinked, so they shouldn't take up additional space. In docker containers, on the other hand, the cache is not reused and the files are copied instead of hardlinked.
The simple fix for this is to remove the rattler cache directly after the pixi install
(in the same step, so that it doesn't create a new layer).
FROM ghcr.io/prefix-dev/pixi:0.23.0
WORKDIR /app
COPY . .
RUN pixi install --locked && rm -rf ~/.cache/rattler
EXPOSE 8000
CMD [ "pixi", "run", "start" ]
We reduced our image size from 691MB
to 402MB
:
$ docker image ls pixi-docker-example:2-remove-cache
REPOSITORY TAG IMAGE ID CREATED SIZE
pixi-docker-example 2-remove-cache ed7b1a5b39d2 1 second ago 402MB
$ docker history pixi-docker-example:2-remove-cache
IMAGE CREATED CREATED BY SIZE COMMENT
ed7b1a5b39d2 1 second ago CMD ["pixi" "run" "start"] 0B buildkit.dockerfile.v0
<missing> 1 second ago EXPOSE map[8000/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 1 second ago RUN /bin/sh -c pixi install --locked && rm -… 287MB buildkit.dockerfile.v0
<missing> 6 seconds ago COPY . . # buildkit 97.1kB buildkit.dockerfile.v0
<missing> 20 hours ago WORKDIR /app 0B buildkit.dockerfile.v0
<missing> 8 days ago COPY --chown=root:root --chmod=0555 /pixi /u… 36.7MB buildkit.dockerfile.v0
<missing> 5 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 5 weeks ago /bin/sh -c #(nop) ADD file:a5d32dc2ab15ff0d7… 77.9MB
<missing> 5 weeks ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 5 weeks ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 5 weeks ago /bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH 0B
<missing> 5 weeks ago /bin/sh -c #(nop) ARG RELEASE 0B
Multi-stage builds
In the docker history
above, we can see a COPY --chown=root:root --chmod=0555 /pixi...
layer that weighs in at 36.7MB
. This is actually the layer from the ghcr.io/prefix-dev/pixi:0.23.0
image that installs pixi
into the container. At runtime, we don't need the pixi
binary, so we can use a multi-stage build to reduce the image size even further. Instead of running pixi run start
, we can activate the environment using an activation script generated by pixi shell-hook
and run the uvicorn
command after activation.
Note: This is an alternative to the "remove the cache directory" approach. With multi-stage builds, we don't need to remove the cache directory, as it is not copied to the final image.
FROM ghcr.io/prefix-dev/pixi:0.23.0 AS build
WORKDIR /app
COPY . .
RUN pixi install --locked
RUN pixi shell-hook -s bash > /shell-hook
RUN echo "#!/bin/bash" > /app/entrypoint.sh
RUN cat /shell-hook >> /app/entrypoint.sh
RUN echo 'exec "$@"' >> /app/entrypoint.sh
FROM ubuntu:24.04 AS production
WORKDIR /app
COPY /app/.pixi/envs/default /app/.pixi/envs/default
COPY /app/entrypoint.sh /app/entrypoint.sh
COPY ./my_project /app/my_project
EXPOSE 8000
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "uvicorn", "my_project:app", "--host", "0.0.0.0" ]
Please note that the path to the environment must be the same in both stages as conda environments are not relocatable. Using this trick, we can reduce the image from 402MB
to 363MB
.
$ docker image ls pixi-docker-example:3-multi-stage
REPOSITORY TAG IMAGE ID CREATED SIZE
pixi-docker-example 3-multi-stage 0c2add89dbdb 7 seconds ago 363MB
$ docker history pixi-docker-example:3-multi-stage
IMAGE CREATED CREATED BY SIZE COMMENT
0c2add89dbdb 7 seconds ago CMD ["uvicorn" "my_project:app" "--host" "0.… 0B buildkit.dockerfile.v0
<missing> 7 seconds ago ENTRYPOINT ["/app/entrypoint.sh"] 0B buildkit.dockerfile.v0
<missing> 7 seconds ago EXPOSE map[8000/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 7 seconds ago COPY ./my_project /app/my_project # buildkit 532B buildkit.dockerfile.v0
<missing> 7 seconds ago COPY /app/entrypoint.sh /app/entrypoint.sh #… 617B buildkit.dockerfile.v0
<missing> 49 minutes ago COPY /app/.pixi/envs/default /app/.pixi/envs… 287MB buildkit.dockerfile.v0
<missing> 49 minutes ago WORKDIR /app 0B buildkit.dockerfile.v0
<missing> 6 days ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 6 days ago /bin/sh -c #(nop) ADD file:7f5ee17de6aff2b67… 76.2MB
<missing> 6 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 6 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 6 days ago /bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH 0B
<missing> 6 days ago /bin/sh -c #(nop) ARG RELEASE 0B
Use a production pixi environment
One great feature that makes pixi stand out from all the other conda package managers is multi environment support. This means that we can define multiple environments with different dependencies in one pixi.toml
file. For more information, please check out the official documentation.
For this, we can create a dev
feature in our pixi.toml
file where all our dependencies (like pytest
, ruff
, mypy
, etc.) are installed. In addition to that, we create a default
environment that contains all dependencies for development and a prod
environment that only contains the dependencies needed for production. With solve-group
we can make sure that all dependencies in the prod
environment match exactly the dependencies in our default
environment. This way, we can make sure that we test against the same dependencies that we deploy to production.
# ...
[dependencies]
fastapi = "*"
uvicorn = "*"
[feature.dev.dependencies]
pytest = "*"
ruff = "*"
mypy = "*"
[environments]
default = { features = ["dev"], solve-group = "prod" }
prod = { features = [], solve-group = "prod" }
FROM ghcr.io/prefix-dev/pixi:0.23.0 AS build
WORKDIR /app
COPY . .
RUN pixi install --locked -e prod
RUN pixi shell-hook -e prod -s bash > /shell-hook
RUN echo "#!/bin/bash" > /app/entrypoint.sh
RUN cat /shell-hook >> /app/entrypoint.sh
RUN echo 'exec "$@"' >> /app/entrypoint.sh
FROM ubuntu:24.04 AS production
WORKDIR /app
COPY /app/.pixi/envs/prod /app/.pixi/envs/prod
COPY /app/entrypoint.sh /app/entrypoint.sh
COPY ./my_project /app/my_project
EXPOSE 8000
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "uvicorn", "my_project:app", "--host", "0.0.0.0" ]
By only installing the dependencies we actually need, we can reduce the image size from 363MB
to 265MB
.
$ docker image ls pixi-docker-example:4-prod-environment
REPOSITORY TAG IMAGE ID CREATED SIZE
pixi-docker-example 4-prod-environment 730ff324486f 1 second ago 265MB
$ docker history pixi-docker-example:4-prod-environment
IMAGE CREATED CREATED BY SIZE COMMENT
730ff324486f 1 second ago CMD ["uvicorn" "my_project:app" "--host" "0.… 0B buildkit.dockerfile.v0
<missing> 1 second ago ENTRYPOINT ["/app/entrypoint.sh"] 0B buildkit.dockerfile.v0
<missing> 1 second ago EXPOSE map[8000/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 1 second ago COPY ./my_project /app/my_project # buildkit 532B buildkit.dockerfile.v0
<missing> 1 second ago COPY /app/entrypoint.sh /app/entrypoint.sh #… 618B buildkit.dockerfile.v0
<missing> 48 minutes ago COPY /app/.pixi/envs/prod /app/.pixi/envs/pr… 188MB buildkit.dockerfile.v0
<missing> 50 minutes ago WORKDIR /app 0B buildkit.dockerfile.v0
<missing> 6 days ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 6 days ago /bin/sh -c #(nop) ADD file:7f5ee17de6aff2b67… 76.2MB
<missing> 6 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 6 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
<missing> 6 days ago /bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH 0B
<missing> 6 days ago /bin/sh -c #(nop) ARG RELEASE 0B
Use a distroless base image
Another significant portion of our image size is the base image: ubuntu:24.04
weighs in at 76.2MB
. Instead of using a full-fledged Ubuntu image, we can use a distroless base image like gcr.io/distroless/base-debian12
provided by the distroless project which only contains the bare minimum to run a container. The only thing that is needed when running conda environments is a libc
on our system.
Note: This is also the reason why we can't use alpine
as a base image for conda environments, as alpine
uses musl
instead of glibc
.
Since distroless images are barebones, they don't have a shell that can execute our entrypoint.sh
. Instead, we need to look at the outputs of pixi shell-hook
and set all env variables that are being set in the activation script accordingly.
# executed in build container, should match runtime environment
$ pixi shell-hook -e prod
export PATH="/app/.pixi/envs/prod/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export CONDA_PREFIX="/app/.pixi/envs/prod"
export PIXI_ENVIRONMENT_NAME="prod"
export PIXI_PROJECT_ROOT="/app"
export PIXI_PROJECT_NAME="pixi-docker-example"
export PIXI_PROJECT_MANIFEST="/app/pixi.toml"
export CONDA_DEFAULT_ENV="pixi-docker-example:prod"
export PIXI_EXE="/usr/local/bin/pixi"
export PIXI_ENVIRONMENT_PLATFORMS="linux-aarch64,linux-64,osx-arm64,win-64"
export PIXI_PROJECT_VERSION="NO_VERSION_SPECIFIED"
export PIXI_IN_SHELL="1"
export PIXI_PROMPT="(pixi-docker-example:prod) "
Most environment variables are only needed for pixi itself, so we can remove them. The only variables that are needed are PATH
and CONDA_PREFIX
.
Note: Beware that this can differ from project to project as packages can define arbitrary activation scripts.
FROM ghcr.io/prefix-dev/pixi:0.23.0-bookworm AS build
WORKDIR /app
COPY . .
RUN pixi install --locked -e prod
FROM gcr.io/distroless/base-debian12 AS production
WORKDIR /app
COPY /app/.pixi/envs/prod /app/.pixi/envs/prod
COPY ./my_project /app/my_project
EXPOSE 8000
# from pixi shell-hook -e prod
ENV PATH=/app/.pixi/envs/prod/bin:$PATH
ENV CONDA_PREFIX=/app/.pixi/envs/prod
CMD [ "uvicorn", "my_project:app", "--host", "0.0.0.0" ]
This reduces the image size from 265MB
to 209MB
.
$ docker image ls pixi-docker-example:5-distroless
REPOSITORY TAG IMAGE ID CREATED SIZE
pixi-docker-example 5-distroless b2b5c97f6f9c 18 hours ago 209MB
$ docker history pixi-docker-example:5-distroless
IMAGE CREATED CREATED BY SIZE COMMENT
b2b5c97f6f9c 18 hours ago CMD ["uvicorn" "my_project:app" "--host" "0.… 0B buildkit.dockerfile.v0
<missing> 18 hours ago ENV CONDA_PREFIX=/app/.pixi/envs/default 0B buildkit.dockerfile.v0
<missing> 18 hours ago ENV PATH=/app/.pixi/envs/prod/bin:/usr/local… 0B buildkit.dockerfile.v0
<missing> 18 hours ago EXPOSE map[8000/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 18 hours ago COPY ./my_project /app/my_project # buildkit 532B buildkit.dockerfile.v0
<missing> 18 hours ago COPY /app/.pixi/envs/prod /app/.pixi/envs/pr… 188MB buildkit.dockerfile.v0
<missing> 19 hours ago WORKDIR /app 0B buildkit.dockerfile.v0
<missing> N/A 5.89MB
<missing> N/A 12.8MB
<missing> N/A 233kB
<missing> N/A 346B
<missing> N/A 497B
<missing> N/A 0B
<missing> N/A 64B
<missing> N/A 0B
<missing> N/A 149B
<missing> N/A 0B
<missing> N/A 1.46MB
<missing> N/A 22.9kB
<missing> N/A 271kB
Note: The distroless images are not super handy to debug, as they don't have a shell. If we need to debug our container, we can rebuild it with the debug
tag which includes a busybox shell.
FROM ghcr.io/prefix-dev/pixi:0.23.0-bookworm AS build
WORKDIR /app
COPY . .
RUN pixi install --locked -e prod
FROM gcr.io/distroless/base-debian12:debug AS production
WORKDIR /app
COPY /app/.pixi/envs/prod /app/.pixi/envs/prod
COPY ./my_project /app/my_project
EXPOSE 8000
# from pixi shell-hook -e prod
# add a /busybox to the PATH, otherwise we can't run any commands
ENV PATH=/app/.pixi/envs/prod/bin:$PATH
ENV CONDA_PREFIX=/app/.pixi/envs/default
ENTRYPOINT []
CMD [ "uvicorn", "my_project:app", "--host", "0.0.0.0" ]
We can spawn a shell in the container with the following command:
docker run --rm -it --entrypoint=sh pixi-docker-example:5-distroless-debug
pixi-pack
to ship environments
Use In some scenarios we might not have access to docker but want to run stuff on the target machine directly. For this, we could install pixi
on the target machine and run pixi install --locked -e prod
and then start our application. However, in some scenarios, our production setup is sealed off from the internet and we can't install pixi
or the packages we need.
There are two solutions to this problem, the first one is conda-pack and the second is conda-constructor.
conda-pack
conda-pack
takes an existing conda environment, zips it up and ships it to the target machine. There, we can unzip it, activate
the environment, then run conda-unpack
to cleanup prefixes from the active environment.
conda-pack
has the issue that if you tinker with your environment in some way, the packed environment might contain files that are not in a "clean" environment that you would get when installing everything from scratch. Thus, the environment.zip
that we create at one point in time might be different from the environment.zip
that we create at another point in time, even if all installed packages are the same. Also, conda-pack
requires the conda environment to be installed on the build machine, which might not be wanted or possible in some scenarios. This is particularly annoying when cross-building environments for different platforms as conda
doesn't allow us to install Windows environments on Linux and vice versa.
conda-constructor
conda-constructor
is a tool that creates conda environments from a construct.yaml
file.
name: my-environment
version: X
installer_type: all
channels:
- conda-forge
specs:
- fastapi
- uvicorn
It reads the construct.yaml
file, solves the environment, downloads the .conda
and .tar.bz2
packages and puts them into a single binary file that can be shipped to the target machine. On the target machine, we can call this binary file and install it to the desired location. We could use pixi list --json
and some bash magic to generate a construct.yaml
file with explicit package pinnings and then use constructor
to build an installer out of that. On Windows, the graphical installer works fine with a few kinks (constructor #813) but the command line installer has the issue that it doesn't provide any feedback on the progress or success of the installation (constructor #812). Also, the installer is designed to be a full-fledged installer that adds itself to the user's program list which is intended for some use cases but in our case, we just wanted to unpack the environment with no additional steps.
However, our use case was just a simple way to unpack a conda environment on the target machine without a fully fledged installer. For this, we created pixi-pack
, a tool that takes a pixi environment and packs it into a compressed archive.
pixi-pack
pixi-pack is a simple tool that takes a pixi environment and packs it into a compressed archive that can be shipped to the target machine.
It can be installed via
pixi global install pixi-pack
We can pack an environment with
pixi-pack pack --manifest-file pixi.toml --environment prod --platform linux-64
It looks at the solved dependencies for our environment from pixi.lock
, then downloads all .conda
and .tar.bz2
files and puts them into a single archive. The archive is a .tar
file that contains all the packages as a local channel.
# environment.tar
| pixi-pack.json
| environment.yml
| channel
| ├── noarch
| | ├── tzdata-2024a-h0c530f3_0.conda
| | ├── ...
| | └── repodata.json
| └── linux-64
| ├── ca-certificates-2024.2.2-hbcca054_0.conda
| ├── ...
| └── repodata.json
We can unpack the environment with
pixi-pack unpack environment.tar
which will create an env
directory with the installed environment as well as an activate.sh
(or activate.bat
on Windows) script that can activate the environment. Since pixi-pack
just downloads the .conda
and .tar.bz2
files from the conda repositories, we can trivially create packs for different platforms.
pixi-pack pack --platform win-64
If we don't happen to have the pixi-pack
binary on the target machine (no need to install it, we just need the executable as it's statically linked), we can also use conda
or micromamba
to install the environment.
tar -xvf environment.tar
micromamba create -p ./env --file environment.yml
# or
conda env create -p ./env --file environment.yml
Also, if we need to install additional files or scripts like the project code, we can create a conda package out of them using rattler-build and then add the package to the pack using pixi-pack --inject my-project.conda
, see here for a simple example. This will add the my-project.conda
package to the channel
directory in the pack and ensure that it is installed next to the other packages when running pixi-pack unpack
. Beware that this won't re-solve the environment, so we need to make sure that the package we are injecting is compatible with the other packages in the environment and all dependencies of our package are met in the pixi environment.
Conclusion
We've shown two ways to ship pixi environments to production. The first way - containerization - is the most common way to ship environments to production. It is easy to set up and maintain and can be used in most scenarios. The second way - using pixi-pack - is a fast, simple and reproducible alternative to conda-pack
and conda-constructor
that integrates well with the pixi
toolchain and can be used in scenarios where we can't use docker or need a simple way to ship environments to production.