BreadcrumbHomeResourcesBlog Building Rootless Docker Images With ZendPHP July 28, 2022 Building Rootless Docker Images With ZendPHPPHP DevelopmentModernizationBy Matthew Weier O’PhinneyIf 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.Table of ContentsWhy Build Rootless Docker Images?Specifying an Unprivileged UserGetting More Robust With an S6 OverlayBuilding a Rootless Docker Image With ZendPHPFinal Thoughts on Rootless Docker ImagesTable of Contents1 - Why Build Rootless Docker Images?2 - Specifying an Unprivileged User3 - Getting More Robust With an S6 Overlay4 - Building a Rootless Docker Image With ZendPHP5 - Final Thoughts on Rootless Docker ImagesBack to topWhy 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 filesystemsecrets stored on the filesystem or in the ENVyour firewalled networkThe 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 UserDocker 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:1000When 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 OverlaySystem 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.Back to topBuilding a Rootless Docker Image With ZendPHPZendPHP 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 ImagesFirst, 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:startSince 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 imagesThe 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 v3In 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 EntrypointThe 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 RootlessSo, 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 Initializations6-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 cachesGenerate static assetsSet 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:seedRegarding 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 topFinal Thoughts on Rootless Docker ImagesHow 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 FreeAdditional ResourcesBlog - Rootless Containers and Why They MatterBlog - PHP Orchestration With ZendPHP Docker ImagesBlog - Cloud Orchestration With ZendPHPBlog - PHP Docker Images: Tips and TricksBlog - The Impact of PHP ARM Architecture SupportBack to top
Matthew Weier O’Phinney Senior Product Manager, OpenLogic and 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.