Innovate faster and cut risk with PHP experts from Zend Services.
Beginning to advanced PHP classes to learn and earn global certification.
Help me choose >
Submit support requests and browse self-service resources.
Matthew Weier O’Phinney
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.
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 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.
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
# Specifying a user and group
# Using a UID
# Using a UID and GID
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.
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:
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:
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.
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.
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:
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:
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:
The ZendPHP Docker images pre-date the v3 release of s6-overlay.
As such, our examples demonstrate the v2 usage, which includes:
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:
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.
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.)
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:
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).
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:
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.
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:
/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:
# File: /etc/cont-init.d/00-permissions.sh
chown -R zendphp.zendphp data
chmod 0775 data
chmod -R u+rw data
# File: /etc/cont-init.d/01-logging.sh
echo "Setting permissions for output files..."
chown --dereference zendphp /dev/stdout /dev/stderr
echo "Permissions properly set."
# File: /etc/cont-init.d/02-cache-seed.sh
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!
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 FreeZendPHP, including orchestration and observability functionality introduced with ZendHQ, is free to try. Try it out today via the link below.Try ZendPHP 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
Zend Product Manager, Zend by Perforce
Matthew began developing on Zend Framework (ZF) before its first public release, and led the project for Zend from 2009 through 2019. He is a founding member of the PHP Framework Interop Group (PHP-FIG), which creates and promotes standards for the PHP ecosystem — and is serving his second elected term on the PHP-FIG Core Committee.