
Building Rootless Docker Images With ZendPHP
If your team is considering building rootless Docker images, chances are you're doing it to help harden your images against potential security issues.
In this blog, we walk through how to build rootless Docker images using ZendPHP, and a few best practices you should consider along the way.
Why Build Rootless Docker Images?
In a previous post, we detailed the security implications of using containers, and why you might want to run them rootless.
The problems arise due to the fact that the Docker daemon is often running as root, which means that if an attacker is able to escape the container into the host system, they might get access to:
- the filesystem
- secrets stored on the filesystem or in the ENV
- your firewalled network
The best way to fix these issues is to run the daemon as a non-root user; however, as that post points out, there are some considerable complexities and drawbacks to doing so.
The other option is to lock-down your containers and have them run services as non-root users. This can be done with or without running the host daemon as root, and is one more tool in a layered security effort.
Back to topSpecifying an Unprivileged User
Docker provides a native mechanism for running a container service as a non-root user.
Within a Dockerfile
, specify the user to run using the USER
directive:
# Specifying a user
USER notroot
# Specifying a user and group
USER notroot:notrootgroup
# Using a UID
USER 1000
# Using a UID and GID
USER 1000:1000
When you do this, any commands (RUN
, CMD
, or ENTRYPOINT
instructions) following that directive run as that user.
You can do similarly by using the --user
option to docker run
. This approach works in many situations, so long as file and executable permissions within the container allow it.
However, if an attacker is able to exploit a vulnerability in your container, they could possibly gain privilege escalations that would give them root within the container; once this happens, any exploit that allows them to break out of the container means they have access on the host equivalent to the user running the container daemon.
Back to topGetting More Robust With an S6 Overlay
System daemons in Docker can be complex to manage. Linux has had a number of systems over the years for managing system daemons, ranging from init.d to supervisord to systemd.
Running system daemons in Docker poses additional challenges, including:
- How do you recover when there is an error?
- How do you fail gracefully if an error is unrecoverable?
- If you have more than one system daemon running, how do you shut them all down gracefully when halting the container? (For example, to close I/O connections, including network or filesystem access.)
Many of these process management systems have developed into massive systems of their own over the years, and are largely unsuitable for Docker.
One lightweight alternative to these systems is s6, for which a Docker-specific overlay was created, s6-overlay. s6 was built with security, performance, and memory conservation in mind, and is a great fit for Docker.
Because of the way s6 operates, daemons you run in containers with the s6-overlay cannot gain the necessary privileges to escape the container.
On top of that, the s6-overlay provides additional security features:
- By default, it resets the container ENV so that internal processes cannot see these variables unless you specifically need them for the process. For such cases, s6-overlay provides a utility called
with-contenv
that will inject the container ENV into the process. - You can restrict what processes can log.
- You can make the filesystem read-only.
- You can set a different default user than root for running processes.
- You can run daemons under non-root users easily, via built-in tooling (the
s6-setuidgid
command).
The key takeaway is that s6-overlay provides a multi-faceted approach to securing your containers, including the ability to run as a non-root user. While the process manager itself will run as root, the daemons themselves can run as any user.
If they do not run as root, because of the way s6 works, they cannot elevate privileges.
Building a Rootless Docker Image With ZendPHP
ZendPHP Docker images all include the s6-overlay by default, though they do not specify a non-privileged user by default.
This choice allows them to work in familiar ways for those who know Docker already, but gives developers who want the advanced security and process isolation and management features of s6 the ability to use it immediately.
To make use of the s6-overlay in ZendPHP containers, you will need to make a few minor changes.
Creating Services for Rootless Docker Images
First, you will need to create one or more daemons for s6.
A daemon is a directory containing a run
executable representing the daemon itself.
This executable is essentially a shell script, but the recommendation is that the shell be one of s6’s execlineb
or the s6-overlay’s with-contenv
shells:
#!/usr/bin/execlineb -P
will remove environment variables prior to executing the script.#!/usr/bin/with-contenv sh
will keep environment variables defined for the container and then execute the script.
With each of these, you can specify a binary directly to execute; however, if you need to do any logical operations, it often makes sense to create a shell script to run.
As an example, if you are defining a container that will run an OpenSwoole-based Mezzio application, you might have a script that looks like this:
#!/bin/bash
set -e
cd /var/www && ./vendor/bin/laminas mezzio:swoole:start
Since environment variables are a common way to provide things such as API keys and database credentials to your application, here’s a full example.
First, the directory structure looks like this:
/etc/services.d/
app/
daemon
run
Where daemon
is the above bash script, and run
now looks like the following:
#!/usr/bin/with-contenv sh
/etc/services.d/swoole/daemon
s6-overlay version in ZendPHP Docker images
The ZendPHP Docker images pre-date the v3 release of s6-overlay.
As such, our examples demonstrate the v2 usage, which includes:
execlineb
,with-contenv
, and others are all in the/usr/bin/
tree.- s6 initialization and daemon directories are in
/etc/
.
s6 will scan the /etc/services.d
tree for directories with run
executables, and run each on initialization.
In this way, you can have multiple services running on the same machine; this can be incredibly useful for doing things such as running cronjobs or custom logging.
If you have multiple services, you should define exactly one as the primary service. If that service ends in any way — whether naturally or through errors — it will then trigger shutdown of the container.
The way it does that is to define a finish
script as a peer to the run
script, with the following contents:
#!/usr/bin/execlineb -S0
s6-svscanctl -t /var/run/s6/services
This script will send a halt signal to all other services running when invoked, shutting down the system cleanly.
finish Semantics Change in v3
In s6-overlay version 3 releases, finish
scripts need to call the overlay’s halt
command instead. The example above is based on version 2, on which ZendPHP Docker images are based at the time of writing.
When creating your Dockerfile, you will ADD
or COPY
your services.d
directory to the container. (You can also map them in as volumes, if desired.)
Changing Your Entrypoint
The second thing you need to do to make use of s6-overlay in ZendPHP Docker images is to change your container’s ENTRYPOINT
. Normally this points to the daemon you are running in your container.
With s6, you point it to /init
:
ENTRYPOINT ["/init"]
Making s6-overlay the entrypoint means that it will start up any defined services before executing any other commands you might define (e.g., via the CMD
Dockerfile directive, or when calling docker run
).
Making the Service Rootless
So, now we know the basics for enabling s6-overlay features in ZendPHP; how do we make the service run under a different user?
You can still use the USER
directive in your Dockerfile, but it has limitations.
The better solution is to use s6’s s6-setuidgid
utility as part of your run
script.
Let’s say we define a group and a user in our Dockerfile:
RUN groupadd --gid 1001 zendphp; \
useradd --uid 1001 --gid 1001 -m zendphp
We can rewrite the run
script to run our daemon under that user:
#!/usr/bin/with-contenv sh
s6-setuidgid zendphp:zendphp /etc/services.d/swoole/daemon
If you do this, it’s good to ensure your PHP scripts are all owned by that user and/or group. Let’s see how to do that, and some other initialization tasks.
Container Initialization
s6-overlay scans another directory, /etc/cont-init.d
, for executable files during container initialization. You can use this to ensure that your container is in a specific state when it begins accepting requests.
Some things I often do:
- Prime caches
- Generate static assets
- Set permissions
/etc/cont-init.d
scripts are run as root, and execute in the defined WORKDIR
of your container, which allows you to do things your user might not normally (such as setting permissions).
Scripts are executed in order, so if a specific order is required, prefix the scripts with numbers or letters to provide logical ordering:
/etc/cont-init.d/
00-permissions.sh
01-logging.sh
# etc
As examples:
#!/bin/bash
# File: /etc/cont-init.d/00-permissions.sh
set -e
chown -R zendphp.zendphp data
chmod 0775 data
chmod -R u+rw data
#!/bin/bash
# File: /etc/cont-init.d/01-logging.sh
set -e
echo "Setting permissions for output files..."
chown --dereference zendphp /dev/stdout /dev/stderr
echo "Permissions properly set."
#!/usr/bin/with-contenv /bin/bash
# File: /etc/cont-init.d/02-cache-seed.sh
set -e
./vendor/bin/laminas cache:seed
Regarding the second example, 01-logging.sh
: because we are running the daemon as a non-root user, we actually do not have permissions by default to write to the Docker STDOUT and STDERR handles, and need to explicitly provide permissions to do so!
Back to top
Final Thoughts on Rootless Docker Images
How to run a container as a non-root user can be complex, and there are many considerations to make to ensure that both the container and the host remain secure. While the USER
directive in a Dockerfile
can provide some security, due to the nature of the default initialization process in containers, there is still the ability to escalate privileges within the container.
Initialization systems such as s6 provide tools to help you lock down your containers further, as well as allow you to expand the capabilities of what your container can do, such as running multiple services.
ZendPHP containers make use of these capabilities to allow your business to create layered security for your applications.
Try ZendPHP for Free
ZendPHP, including orchestration and observability functionality introduced with ZendHQ, is free to try. Try it out today via the link below.
Additional Resources
- Blog - Rootless Containers and Why They Matter
- Blog - PHP Orchestration With ZendPHP Docker Images
- Blog - Cloud Orchestration With ZendPHP
- Blog - PHP Docker Images: Tips and Tricks
- Blog - The Impact of PHP ARM Architecture Support