PHP 7.2 Docker image analysis
Base Docker images are full of sysadmin best practices and interesting tips to learn from. Besides that, knowing what's going on behind the curtains of a FROM is mandatory if you plan to trust a production system with it.
TL;DR image features:
- It uses Apache2 from Debian repositories.
- It installs PHP following the official installation recommendations.
- Apache can be run as an arbitrary user:
/var/www/html
has 777 permissions. - PHP configuration resides in:
/usr/local/etc/php/
.
Let's review all sections of the php:7.2-apache Docker base image mentioning some shell, Debian and Dockerfile best practices along the way.
Base image
FROM debian:buster-slim
RUN instructions
The RUN
instruction, in its shell form, will execute the given command in the /bin/sh
shell using the -c
option.
In Debian, /bin/sh
corresponds to the Debian Almquist Shell (dash) which strives to be a POSIX-compliant and slim shell. This means no "bashisms" in RUN instructions (unless you run /bin/bash -c
, of course).
As we will see, the first subcommand is always this:
set -eux
This will enable the following shell options:
errexit
: exit immediately if any untested command fails.nounset
: exit immediately if attempting to expand an unset variable.xtrace
: write each command to stderr preceded by a+
before being executed.
Block official PHP Debian packages
RUN set -eux; \
{ \
echo 'Package: php*'; \
echo 'Pin: release *'; \
echo 'Pin-Priority: -1'; \
} > /etc/apt/preferences.d/no-debian-php
-
They strive to use the recommended PHP installation method.
-
They want to be able to keep up with upstream releases since Debian will only update PHP in stable releases if there are security vulnerabilities.
This is implemented using an apt preferences file, by preventing installation (Pin-Priority: -1
) of all packages whose name starts with php
(using a glob expression: Package: php*
) belonging to all distribution releases (Pin: release *
).
Installing PHP compilation dependencies
ENV PHPIZE_DEPS \
# ...
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
$PHPIZE_DEPS \
ca-certificates \
curl \
xz-utils \
; \
rm -rf /var/lib/apt/lists/*
Dockerfile
pattern for installing package dependencies:
- Update repositories.
- Install packages, assuming yes to all answers and not installing recommended packages.
- Cleaning the storage area for state information for each package resource in the available repositories. This will save hundreds of MB in the resulting image.
Following Dockerfile best practices, it's important to execute them all in the same RUN
command (ie. in the same Docker layer).
Separating phpize
dependencies in an environment variable allows having them both organized and sorted alphabetically, at the same time that there is only one apt-get install
call (ie. one layer).
It's worth noting that the package cache in /var/cache/apt/archives/
is not being explicitly removed. This is because the official Debian base image is already executing apt-get clean
after every install (see code).
Setup PHP / Apache directories
ENV PHP_INI_DIR /usr/local/etc/php
RUN set -eux; \
mkdir -p "$PHP_INI_DIR/conf.d"; \
# allow running as an arbitrary user
[ ! -d /var/www/html ]; \
mkdir -p /var/www/html; \
chown www-data:www-data /var/www/html; \
chmod 777 /var/www/html
PHP_INI_DIR
environment variable, which is later used to specify the php configuration path /usr/local/etc/php
, and create Apache's html root directory with all permissions granted to all users.
Shell tip: Note that /var/www/html
shouldn't exist before this RUN instruction; otherwise, this will halt in [ ! -d /var/www/html ]
and stop the image building process.
Install Apache 2
Another RUN
instruction is executed with all the following steps inside (ie. again, all in one layer to reduce size):
-
Apache2 from Debian repositories is installed following the aforementioned technique (ie.
apt-get install
). -
Allow Apache environment variables to be overriden at runtime from the Docker cli (e.g.
-e APACHE_RUN_USER=...
). This is accomplished by modifying/etc/apache2/envars
using the followingsed
invocation:sed -ri 's/^export ([^=]+)=(.*)$/: ${\1:=\2}\nexport \1/' "$APACHE_ENVVARS";
- Command options:
-r
enables extended regexps and-i
edits files in place. - Matches lines like:
export SOME_VAR=any value
-
Transforms these lines to the form:
: ${SOME_VAR:=any value} export SOME_VAR
-
The form
${parameter:=word}
assigns word to parameter if it was not set. - The colon character (
:
) before the string assignment is the builtin null command that returns a 0 (true) exit value. This allows executing the string assignment without running the resulting parameter value (and getting an error when doing it).
- Command options:
-
Using another builtin command (
.
), reads and executes the just modified Apache/etc/apache2/envars
file in the current shell. -
Grant all permissions to all users and changes ownership of Apache's
lock
,log
, andrun
folders, creating them from scratch.for dir in "$APACHE_LOCK_DIR" "$APACHE_RUN_DIR" "$APACHE_LOG_DIR"; do rm -rvf "$dir"; mkdir -p "$dir"; chown "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$dir"; chmod 777 "$dir"; done
-
Delete the
index.html
created by Apache.rm -rvf /var/www/html/*
-
Redirect all Apache log contents to
stdout
/stderr
by creating symbolic links to the corresponding device filesln -sfT /dev/stderr "$APACHE_LOG_DIR/error.log" ln -sfT /dev/stdout "$APACHE_LOG_DIR/access.log" ln -sfT /dev/stdout "$APACHE_LOG_DIR/other_vhosts_access.log"
-s
: Create symbolic link (not hard link).-f
: Remove existing destination if exists.-T
: Treat the destination name (the log file) as a normal file (not a directory).
-
Finally, change the owner of all log files recursively (
-R
):chown -R --no-dereference "$APACHE_RUN_USER:$APACHE_RUN_GROUP" "$APACHE_LOG_DIR"
--no-dereference
changes the symbolic link owner (vs the link target).
Enable preforking MPM
RUN a2dismod mpm_event && a2enmod mpm_prefork
- Multi Processing Modules (MPMs) are responsible for binding to network ports, accepting requests, and dispatching children to serve them. Only one
mpm
module can be enabled at the same time. - According to the PHP manual, a threaded MPM is not recommended with Apache2; that's why the
prefork
module is enabled.
Make PHP handle PHP file requests
RUN { \
echo '<FilesMatch \.php$>'; \
echo '\tSetHandler application/x-httpd-php'; \
echo '</FilesMatch>'; \
echo; \
echo 'DirectoryIndex disabled'; \
echo 'DirectoryIndex index.php index.html'; \
echo; \
echo '<Directory /var/www/>'; \
echo '\tOptions -Indexes'; \
echo '\tAllowOverride All'; \
echo '</Directory>'; \
} | tee "$APACHE_CONFDIR/conf-available/docker-php.conf" \
&& a2enconf docker-php
- A specific configuration file is created, which is later enabled with the
a2enconf
command. -
It's interesting how it writes the contents of a file by using a grouping command piped into the
tee
utility, which outputs all it reads from stdin to stdout and also to any file given as a parameter.From the
dash
man page: "Note that “}” must follow a control operator (here, “;”) so that it is recognized as a reserved word and not as another command argument."
Establish PHP compilation options and dependencies
ENV PHP_EXTRA_BUILD_DEPS apache2-dev
ENV PHP_EXTRA_CONFIGURE_ARGS --with-apxs2 --disable-cgi
- We'll require the
apache2-dev
Debian package which contains development headers and theapxs2
binary, apart from somedebhelper
scripts. apxs2
is the APache eXtenSion tool which is used for building and installing extension modules: Dynamic Shared Objects (DSOs). These extensions are loaded into Apache using themod_so
module.--disable-cgi
avoids building the CGI version of PHP and implicitly enables FastCGI.
ENV PHP_CFLAGS="-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64"
ENV PHP_CPPFLAGS="$PHP_CFLAGS"
ENV PHP_LDFLAGS="-Wl,-O1 -Wl,--hash-style=both -pie"
-
GCC compiler flags related to code generation conventions:
-fpic
: generate position-independent code (PIC) for use in shared libraries.-fpie
: similar to-fpic
but the PIC generated can only be linked into executables. It requires to link using-pie
.
-
GCC compiler flags related to program instrumentation for error/attack detection
-fstack-protector-strong
: check for buffer overflows on functions with vulnerable objects (ie. functions that callalloca
, have buffers larger than 8 bytes, have local array definitions or have references to local frame addresses).
-
GCC compiler flags related to control optimization
-O2
: Optimize for reduced code and execution time, without involving space-speed tradeoff optimizations, but taking more time to compile than-O1
.
-
Large File Support (LFS) is enabled as per the PHP documentation.
-
GNU linker (ld) flags:
-
-Wl,-O1
: optimize shared library linkage.From Dockerfile: "this sorts the hash buckets to improve cache locality, and is non-default"
-
-Wl,--hash-style=both
: set the linker's hash table type for both the classicELF ".hash"
and the new styleGNU ".gnu.hash"
.From Dockerfile: "GNU hash is used if present, and is much faster than sysv hash; in this configuration, sysv hash is also generated".
-
-pie
: required to use PIC generated objects.
-
Establish PHP version and validation mechanisms
ENV GPG_KEYS 1729F83938DA44E27BA0F4D3DBDB397470D12172 B1B44D8F021E4E2D6021E995DC9FF8D3EE5AF27F
- Set an environment variable with a couple GPG key IDs required to verify PHP source packages.
- Key IDs are V4 fingerprints (160-bits).
ENV PHP_VERSION 7.2.25
ENV PHP_URL="https://www.php.net/get/php-7.2.25.tar.xz/from/this/mirror" \
PHP_ASC_URL="https://www.php.net/get/php-7.2.25.tar.xz.asc/from/this/mirror"
ENV PHP_SHA256="746efeedc38e6ff7b1ec1432440f5fa801537adf6cd21e4afb3f040e5b0760a9" \
PHP_MD5=""
- Set the PHP version, source URL, signature URL and SHA256 hash.
- The
.asc
file extension implies the signature is in plain-text.
Download PHP source code and verify its integrity
This corresponds to a long RUN
instruction composed of multiple steps:
RUN set -eux; \
-
Save the list of manually installed packages using
apt-mark showmanual
.From the man pages: When you request that a package is installed, and as a result other packages are installed to satisfy its dependencies, the dependencies are marked as being automatically installed, while the package you installed explicitly is marked as manually installed.
-
Install the
gnupg
anddirmngr
packages usingapt
as seen previously. -
Create
/usr/src
directory and enter on it. -
Download the PHP sources:
curl -fsSL -o php.tar.xz "$PHP_URL"; \
-f
: fail silently on server errors (no output at all).-s
: be silent, don't show any progress or messages.-S
: show error messages even if-s
is provided.-L
: follow redirects (Location:
header and a3xx
response code).-o php.tar.xz
: write output tophp.tar.xz
instead ofstdout
.
-
Check source code integrity using SHA256 and/or MD5. In this case, only the more secure SHA256 hash was previously defined.
if [ -n "$PHP_SHA256" ]; then \ echo "$PHP_SHA256 *php.tar.xz" | sha256sum -c -; \ fi; \
-
: read from standard input instead of a file.-c
: read sums fromstdin
and check them.
From the sha256sum man: "When checking, the input should be a former output of this program. The default mode is to print a line with checksum, a space, a character indicating input mode ('' for binary, ' ' for text or where binary is insignificant), and name for each FILE*."
-
Download and verify signature:
if [ -n "$PHP_ASC_URL" ]; then \ curl -fsSL -o php.tar.xz.asc "$PHP_ASC_URL"; \ export GNUPGHOME="$(mktemp -d)"; \ for key in $GPG_KEYS; do \ gpg --batch --keyserver ha.pool.sks-keyservers.net --recv-keys "$key"; \ done; \ gpg --batch --verify php.tar.xz.asc php.tar.xz; \ gpgconf --kill all; \ rm -rf "$GNUPGHOME"; \ fi; \
-
Download
.asc
signature file. -
Create a temporary directory and use it as
GNUPGHOME
(default is~/.gnupg
otherwise).export GNUPGHOME="$(mktemp -d)"; \
mktemp
prints tostdout
the file or directory created and we use$()
to substitute its output in place of the command name itself, ie. the temp folder name. See the Dash Command Substituion man section.- Mark for automatic export to the environment the
GNUPGHOME
variable to subsequently executed programs usingexport
.
-
For each provided
keyID
download the key itself from theha.pool.sks-keyservers.net
key server. This works because we have installed thedirmngr
package.gpg --batch --keyserver ha.pool.sks-keyservers.net --recv-keys "$key"; \
--batch
: use batch mode, ie. don't allow interactive messages.--keyserver
: set keyserver to use (this option is deprecated).--recv-keys "$key"
: import the keys with the givenkeyIDs
from a keyserver.
ha.pool.sks-keyservers.net
- This is a high-availability subset of the pool that require all servers to be identified as a clustered setup (marked with a blue indicator for reverse proxy in the status pages)
- What is an SKS keyserver?
- SKS is an OpenPGP keyserver whose goal is to provide easy to deploy, decentralized, and highly reliable synchronization. That means that a key submitted to one SKS server will quickly be distributed to all key servers, and even wildly out-of-date servers, or servers that experience spotty connectivity, can fully synchronize with the rest of the system.
-
Verify the signature in the detached
.asc
signature file without user interaction:gpg --batch --verify php.tar.xz.asc php.tar.xz; \
-
Kill all gpg components running as daemons and remove the GPG temporary key home.
gpgconf --kill all; \ rm -rf "$GNUPGHOME"; \
-
-
Purge all packages installed in this step (ie. remove and delete configuration files) along with all its dependencies.
-
Mark all installed packages as automatically installed discarding any output:
apt-mark auto '.*' > /dev/null; \
-
Mark as manually installed the same packages that were marked as manual at the beginning of this step (ie. not including
gnupg
,dirmngr
and its installed dependencies) discarding any output:apt-mark manual $savedAptMark > /dev/null; \
-
Remove automatically installed packges that are no longer needed.
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false
-y
: automatic yes to prompts.--auto-remove
: removes unused dependency packages.-o APT::AutoRemove::RecommendsImportant=false
: sets this configuration option to false, effectively forcing the removal of recommended packages whose dependent packages are also being removed. See explanation.
-
Copy docker-php-source
script
COPY docker-php-source /usr/local/bin/
Make available the docker-php-source
script built for managing the php source tarball lifecycle inside the container.
Install compilation dependencies, configure build process and compile PHP
This is the last big RUN
command and is responsible for building the PHP sources following the recommended procedure. These long RUN
commands are common in Docker to minimize the number of layers. Let's review all the steps involved:
RUN set -eux; \
-
First of all, save the list of manually installed packages to clean everything else later:
savedAptMark="$(apt-mark showmanual)"; \
-
Install compilation dependencies using Apt. Packages previously defined in
PHP_EXTRA_BUILD_DEPS
are also installed. The typicalapt-get update
,apt-get install
,rm
cycle is used. -
Export the previously defined
PHP_*
variables for the GCC compiler suite.export \ CFLAGS="$PHP_CFLAGS" \ CPPFLAGS="$PHP_CPPFLAGS" \ LDFLAGS="$PHP_LDFLAGS" \ ; \
-
Extract PHP source tarball in
/usr/src/php
(default dir):docker-php-source extract; \
-
Obtain package building architecture information using
dpkg-architecture
.gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"; \ debMultiarch="$(dpkg-architecture --query DEB_BUILD_MULTIARCH)"; \
Multiarch is the term being used to refer to the capability of a system to install and run applications of multiple different binary targets on the same system.
Multiarch also simplifies cross-building, where foreign-architecture libraries and headers are needed on a system during building.
BUILD is the machine we are building on
This is use to avoid the disparity between GNU_TYPE and MULTIARCH
DEB_BUILD_GNU_TYPE=i586-linux-gnu DEB_BUILD_MULTIARCH=i386-linux-gnu
-
Avoid a PHP compilation bug where curl development headers are not found.
if [ ! -d /usr/include/curl ]; then \ ln -sT "/usr/include/$debMultiarch/curl" /usr/local/include/curl; \ fi; \
-
Execute
./configure
to setup compilation using Autotools.-
define the architecture we want to build PHP for (ie. this system).
--build="$gnuArch"
-
define PHP configuration paths:
--with-config-file-path="$PHP_INI_DIR" \ --with-config-file-scan-dir="$PHP_INI_DIR/conf.d" \
-
enable some extensions which can't be easily (or at all) compiled after having built PHP:
mhash
,ftp
,mbstring
,mysqlnd
,password-argon2
,sodium
,curl
,libedit
,openssl
andzlib
. -
use system SQLite:
--with-pdo-sqlite=/usr \ --with-sqlite3=/usr \
-
don't use pcre-jit for s390x architecture:
$(test "$gnuArch" = 's390x-linux-gnu' && echo '--without-pcre-jit') \
-
define lib directory:
--with-libdir="lib/$debMultiarch" \
-
add previously defined configure args (apxs2 and disable cgi):
${PHP_EXTRA_CONFIGURE_ARGS:-} \
-
-
Build PHP using all system cores in parallel:
make -j "$(nproc)"; \
-
Delete all generated static libraries:
find -type f -name '*.a' -delete; \
-
Install generated files in default system location:
make install; \
-
Strip symbols from all generated binaries:
find /usr/local/bin /usr/local/sbin -type f -executable -exec strip --strip-all '{}' + || true; \
- The
-exec command {} +
variant builds the command line to run by appending the selected file names at the end. This is similar toxargs
and reduces considerably the number of executions. Quoting{}
is recommended when running inside a shell to avoid interpretation. - Adding
|| true
at the end stops the command from failing. This effectively shadows any error runningstrip
because of binaries without symbols (not really an error).
- The
-
Clean compilation intermediate files:
make clean; \
-
Copy the sample
php.ini
to the default PHP configuration folder:cp -v php.ini-* "$PHP_INI_DIR/"; \
-
Delete extracted PHP source folder (
/usr/src/php
):cd /; \ docker-php-source delete; \
-
Purge all build dependencies:
-
Mark all installed packages as automatically installed discarding any output:
apt-mark auto '.*' > /dev/null; \
-
Mark as manually installed the same packages that were marked as manual at the beginning of this step (ie. not including all compilation dependencies) discarding any output:
[ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; \
-
Mark as manually installed the binary dependencies required for PHP runtime execution (ie. not for compilation). This will stop them from being uninstalled.
find /usr/local -type f -executable -exec ldd '{}' ';' \ | awk '/=>/ { print $(NF-1) }' \ | sort -u \ | xargs -r dpkg-query --search \ | cut -d: -f1 \ | sort -u \ | xargs -r apt-mark manual \ ; \
- Run
ldd
against all executable files in/usr/local
to obtain their entire shared library dependency tree. Dependencies are printed in the formatlibname.so.X => /full/path/libname.so.X (hex number)
. - Obtain the full path of every library dependency using
awk
matching all lines containing=>
with/=>/
and printing the second last field (awk
automatically splits each record in fields separated by whitespace). - For each library dependency path, obtain the package that installed it searching the dpkg database:
xargs -r dpkg-query --search
- Using
-r
inxargs
avoids running the command if the input does not contain any nonblanks. - As previously seen, mark each package as manually installed to avoid purging it later:
xargs -r apt-mark manual
.
- Run
-
Remove automatically installed packages that are no longer needed (see command explanation previously in this article):
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false
-
-
pecl update-channels; \ rm -rf /tmp/pear ~/.pearrc; \
- PECL is "a repository for PHP Extensions, providing a directory of all known extensions and hosting facilities for downloading and development of PHP extensions."
- Remove any PEAR (PHP Extension and Application Repository) local configuration.
-
Test that php runs OK or fail image building otherwise:
php --version
Copy all other custom shell scripts inside the image
COPY docker-php-ext-* docker-php-entrypoint /usr/local/bin/
These scripts are located next to the Dockerfile
in the git repository. Some of them are meant to be used by the image users, while others are internal.
Enable sodium PHP extension
RUN docker-php-ext-enable sodium
This is an example usage of the docker-php-ext-enable
which is a very interesting shell script that handles enabling one or more PHP extensions.
freetype-config bug workaround
RUN { echo '#!/bin/sh'; echo 'exec pkg-config "$@" freetype2'; } > /usr/local/bin/freetype-config && chmod +x /usr/local/bin/freetype-config
As we have already seen, using {}
to group several echo commands to write in a file is a common Dockerfile / sysadmin pattern.
Set image entry point
ENTRYPOINT ["docker-php-entrypoint"]
According to the documentation: "An ENTRYPOINT allows you to configure a container that will run as an executable".
The exec form is being used, which is the preferred one (versus the shell
form: ENTRYPOINT command param1 param2
), which makes the run process PID 1, effectively receiving Unix signals (e.g. a SIGTERM sent by a docker stop
command).
The very simple script allows the user to run the apache2-foreground
script (see next steps) with user-defined parameters or to just run any command passed to it (like php or any other executable in the image).
Define stop signal
STOPSIGNAL SIGWINCH
SIGWINCH is the recommended signal to use for gracefully stopping Apache.
Copy apache2-foreground script
COPY apache2-foreground /usr/local/bin/
This script handles the setup required to launch Apache in the foreground. All params received will be passed to the main apache2 process in the last line of the script:
exec apache2 -DFOREGROUND "$@"
- Using
exec
avoids undesirable resident shell processes, as would be the case if using Apache'sapache2ctl
script.
Set the working directory
WORKDIR /var/www/html
This affects only Dockerfile instructions following this line, in this case, it will affect the CMD
final instruction.
Expose port 80
EXPOSE 80
Inform Docker that this container will listen to port 80 at runtime (by default, a TCP port). It's important to note that this instruction does not publish the port, it's seen as a kind of documentation between the image creators and the person running the image.
Port publishing is done on docker run
using the -p
or -P
flags.
Specify the command to use in an executing container
CMD ["apache2-foreground"]
Because both the CMD
and ENTRYPOINT
instructions are specified with the exec
form, this instruction is defining default parameters to ENTRYPOINT
, which fits nicely with the docker-php-entrypoint
script.