david alfonso

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:

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
The image is based on Debian buster, aka Debian 10. Specifically, a slimmed down version of it. But, hey, we're not going down this rabbit hole.

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:

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
This image compiles and installs its own PHP version and extensions directly without using the corresponding Debian packages. This is because:

  1. They strive to use the recommended PHP installation method.

  2. 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/*
This is a typical Dockerfile pattern for installing package dependencies:

  1. Update repositories.
  2. Install packages, assuming yes to all answers and not installing recommended packages.
  3. 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
Sets 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):

  1. Apache2 from Debian repositories is installed following the aforementioned technique (ie. apt-get install).

  2. 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 following sed 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).
  3. Using another builtin command (.), reads and executes the just modified Apache /etc/apache2/envars file in the current shell.

  4. Grant all permissions to all users and changes ownership of Apache's lock, log, and run 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
    
  5. Delete the index.html created by Apache.

    rm -rvf /var/www/html/*
    
  6. Redirect all Apache log contents to stdout / stderr by creating symbolic links to the corresponding device files

    ln -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).
  7. 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

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

Establish PHP compilation options and dependencies

ENV PHP_EXTRA_BUILD_DEPS apache2-dev
ENV PHP_EXTRA_CONFIGURE_ARGS --with-apxs2 --disable-cgi
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"

Establish PHP version and validation mechanisms

ENV GPG_KEYS 1729F83938DA44E27BA0F4D3DBDB397470D12172 B1B44D8F021E4E2D6021E995DC9FF8D3EE5AF27F
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=""

Download PHP source code and verify its integrity

This corresponds to a long RUN instruction composed of multiple steps:

RUN set -eux; \
  1. 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.

  2. Install the gnupg and dirmngr packages using apt as seen previously.

    gnupg
    GNU privacy guard - a free PGP replacement
    dirmngr
    GNU privacy guard - network certificate management service
  3. Create /usr/src directory and enter on it.

  4. 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 a 3xx response code).
    • -o php.tar.xz: write output to php.tar.xz instead of stdout.
  5. 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 from stdin 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*."

  6. 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; \
    
    1. Download .asc signature file.

    2. Create a temporary directory and use it as GNUPGHOME (default is ~/.gnupg otherwise).

      export GNUPGHOME="$(mktemp -d)"; \
      
      • mktemp prints to stdout 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 using export.
    3. For each provided keyID download the key itself from the ha.pool.sks-keyservers.net key server. This works because we have installed the dirmngr 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 given keyIDs 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.
    4. Verify the signature in the detached .asc signature file without user interaction:

      gpg --batch --verify php.tar.xz.asc php.tar.xz; \
      
    5. Kill all gpg components running as daemons and remove the GPG temporary key home.

      gpgconf --kill all; \
      rm -rf "$GNUPGHOME"; \
      
  7. 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; \
  1. First of all, save the list of manually installed packages to clean everything else later:

    savedAptMark="$(apt-mark showmanual)"; \
    
  2. Install compilation dependencies using Apt. Packages previously defined in PHP_EXTRA_BUILD_DEPS are also installed. The typical apt-get update, apt-get install, rm cycle is used.

  3. Export the previously defined PHP_* variables for the GCC compiler suite.

    export \
        CFLAGS="$PHP_CFLAGS" \
        CPPFLAGS="$PHP_CPPFLAGS" \
        LDFLAGS="$PHP_LDFLAGS" \
    ; \
    
  4. Extract PHP source tarball in /usr/src/php (default dir):

    docker-php-source extract; \
    
  5. Obtain package building architecture information using dpkg-architecture.

    gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"; \
    debMultiarch="$(dpkg-architecture --query DEB_BUILD_MULTIARCH)"; \
    

    From the Debian wiki:

    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.

    From the Debian wiki:

    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

  6. 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; \
    
  7. 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 and zlib.

    • 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:-} \
      
  8. Build PHP using all system cores in parallel:

    make -j "$(nproc)"; \
    
  9. Delete all generated static libraries:

    find -type f -name '*.a' -delete; \
    
  10. Install generated files in default system location:

    make install; \
    
  11. 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 to xargs 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 running strip because of binaries without symbols (not really an error).
  12. Clean compilation intermediate files:

    make clean; \
    
  13. Copy the sample php.ini to the default PHP configuration folder:

    cp -v php.ini-* "$PHP_INI_DIR/"; \
    
  14. Delete extracted PHP source folder (/usr/src/php):

    cd /; \
    docker-php-source delete; \
    
  15. 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 format libname.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 in xargs 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.
    • 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
      
  16. Update pecl definitions:

    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.
  17. 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 "$@"

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.