decorative image for blog on building rootless docker images with zendphp
July 28, 2022

Building Rootless Docker Images With ZendPHP

PHP Development

Security is a top concern for teams developing containerized applications. In this blog, we look at how teams can better secure Docker containers by building rootless Docker images with ZendPHP.

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.

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

Getting 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

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:


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:


Where daemon is the above bash script, and run now looks like the following:

#!/usr/bin/with-contenv sh

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

As examples:

# File: /etc/cont-init.d/

set -e

chown -R zendphp.zendphp data
chmod 0775 data
chmod -R u+rw data
# File: /etc/cont-init.d/

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/

set -e

./vendor/bin/laminas cache:seed

Regarding the second example, 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!

Final Thoughts

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.

Try ZendPHP Free

Additional Resources