I've done a fair amount of work with Containerfiles recently, working with image mode hosts. I wasn't doing anything particularly complicated in design—some multi-stage builds, some heredoc usage. Nothing really challenged my fundamental understanding of how podman build reads and executes instructions.
Then I started building examples for the Early Access program of Red Hat Hardened Images and, in particular, using the python image from the catalog for a few personal projects. The project has been evolving quickly over the past six months, and one of those changes made me question everything I thought I knew about container builds.
I had a simple Flask application based on a UBI image that I wanted to run in a Hardened Image, and the Containerfile looked a little like this one:
FROM {hummingbird-registry}/python:3.14
# Set the working directory in the container
WORKDIR /app
# Copy the application files to the target directory
# COPY always executes as root
COPY --chown=65532 app.py .
COPY --chown=65532 index.html static/
# Set environment variables for Python
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Switch to root from the non-root user to install dependencies
# By default, these install in /tmp/.local for the non-root user
USER root
RUN pip install flask
# Switch to the default non-root user for runtime
USER ${CONTAINER_DEFAULT_USER}
# Expose the port Flask will listen on
EXPOSE 8080
# Appropriately set the stop signal for the python interpreter executed as PID 1
STOPSIGNAL SIGINT
ENTRYPOINT ["python", "./app.py"]That's a pretty straightforward build: copy the application files as the right user, then run pip install to get the dependencies. But here's what happened when I executed the first build:
STEP 7/11: RUN pip install flask
error running container: from /usr/bin/crun creating container for [/bin/sh -c pip install flask]: executable file `/bin/sh` not found: No such file or directoryWhy is /bin/sh missing? And what's calling it in the first place?
The first answer is simple. These images are distroless, which means that shells and other tools not directly used by python are removed from the final image. This reduces the footprint and the attack surface. But it also means we need to revisit how container builds actually function—or I needed to, at least.
First, let's make sure pip is actually available. We can just run the container and check.
podman run --rm quay.io/hummingbird/python:3.14 /usr/bin/pip install flask
Defaulting to user installation because normal site-packages is not writeable
Collecting flask
Downloading flask-3.1.3-py3-none-any.whl.metadata (3.2 kB)
Collecting blinker>=1.9.0 (from flask)
Downloading blinker-1.9.0-py3-none-any.whl.metadata (1.6 kB)
Collecting click>=8.1.3 (from flask)
< truncated output >
Successfully installed blinker-1.9.0 click-8.3.2 flask-3.1.3 itsdangerous-2.2.0 jinja2-3.1.6 markupsafe-3.0.3 werkzeug-3.1.8OK, so pip is available in the image. That means the issue lies with the RUN instruction itself. I mentioned heredoc support I've been using in image mode for a reason, as it should have been a clue why this pip install failed in the distroless python. The RUN instruction operates inside the context of the image and executes the command provided via a shell.
If you haven't seen it before, the heredoc support replaces the usual " line continuation with \" practice. The outcome is about the same, but the way we get there changes. In practice it looks like this:
RUN<<EOF
dnf -y install curl httpd
dnf clean all
rm -rf /var/cache/dnf/*
EOFThis is clearly the same format and style used by shells like bash and zsh. It should have also been a big hint that not having a shell available might affect how RUN works.
We all use this shell support regularly, probably without really thinking about the implications. Usually, doing any of the following actions will just work:
- Continuing a command on multiple lines for readability
- Expanding an image-defined environment variable (
ENV) in a command - Using a
heredocto combine multiple complex commands in a single layer
These all work because the RUN command uses the shell inside the container to execute whatever is written as a command line. That was the source of the error in this build: it tried to /bin/sh -c pip install flask—but alas, /bin/sh is nowhere to be found.
What I had forgotten was that the RUN command also supports the exec format. What's that, you ask?
Well, we all use that format regularly too, in the CMD or ENTRYPOINT instructions. For years, the common wisdom for CMD instructions has been to use the bracketed argument list format. This is how we run as PID 1, trap signals, and more. We probably don't even think much about how we structure RUN, CMD, or ENTRYPOINT commands anymore.
So you too might have forgotten that the RUN instruction can also use the same exec format to run directly instead of as a child process in a shell. In practice, that means my working pip install line looks more like a CMD instruction:
RUN ["/usr/bin/pip", "install", "flask" ]In a distroless image, it's still possible to directly call the binaries in the container. Not having a shell doesn't necessarily mean you need to jump to a builder variant to use tools already installed. You just might need to adapt some of the expectations of how those tools are called.
Resources
- To learn more, visit the Red Hat Hardened Images product page.
- To discover your next favorite image, head to red.ht/hi and find the tools and runtimes to support your workload.