Using Docker with PHP (and more!)

The DocuSign PHP code example launcher is configured to use Docker. Docker is containerization software that can help you package and deploy your applications in a consistent developer environment. Containerized applications can vary in size from complex applications requiring dozens of servers down to microservices, powered by a few API calls and DB queries, that are designed to handle and delegate inbound server requests. Containerization is essentially an OS-portable version of virtualization: a means of splitting up hardware resources into smaller, discrete, virtual machines. While virtualization has greater configurability vs. containerization, it suffers from I/O limitations on graphics and computationally intensive programs, in addition to getting your app to run seamlessly on different platforms. Docker, on the other hand, is relatively easy to use and is platform-independent, meaning you can create a web application on your Apple Macbook, yet deploy it on a Windows Server (or whatever compatible server you have access to) if desired. 

The tradeoff for portability is that Docker is not as efficient as using a pure virtualization, such as Vagrant, that will provision instances to the hardware level (before the OS layer). Under the hood, Docker creates one or more read-only blob objects called images. Images function similarly to snapshots (snapshots like those used in Virtualbox, for instance) in other virtualization solutions. You can build a Docker image from published repositories. You’ll want to create an image for each different server box that your application or infrastructure needs. Once the images have been generated, Docker will then create a read/write section that functions on top of the image(s), named a container. Since images are read-only, for the rest of this post, I’ll be referring to “writing” data into Docker containers. Each time you modify data within a Docker container, you have the means to save the contents as a new image by using the command docker create PHPLauncher. These images are portable and you can use them to migrate or transfer your application or application infrastructure without needing to rebuild from the source Docker-hosted repo images. It’s a real timesaver to work this way, if you can!

Deploying with Docker

Setting up your application with containers is simple: If you only have a single server, you'll need a file named Dockerfile configured with build instructions for your application. Remember to include Docker-hosted repositories as well as the installation steps so that you can build, update, and configure your dependencies. See the following current PHP Dockerfile used in our code example launcher for reference:

FROM composer:2 as composer_stage
 
RUN rm -rf /var/www && mkdir -p /var/www/html
WORKDIR /var/www/html
 
FROM php:8.1.6RC1-fpm-alpine3.15
 
# Install dev dependencies
RUN apk add --no-cache --virtual .build-deps \
    $PHPIZE_DEPS \
    curl-dev \
    imagemagick-dev \
    libtool \
    libxml2-dev
 
# Install production dependencies
RUN apk add --no-cache \
    bash \
    curl \
    g++ \
    gcc \
    git \
    imagemagick \
    libc-dev \
    libpng-dev \
    make \
    yarn \
    openssh-client \
    rsync \
    zlib-dev \
    libzip-dev
 
# Install PECL and PEAR extensions
RUN pecl install \
    imagick \
    xdebug
 
# We currently can't natively pull iconv with PHP8, see: https://github.com/docker-library/php/issues/240#issuecomment-876464325
RUN apk add gnu-libiconv=1.15-r3 --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.13/community/ --allow-untrusted
ENV LD_PRELOAD /usr/lib/preloadable_libiconv.so
 
# Install and enable php extensions
RUN docker-php-ext-enable \
    imagick \
    xdebug
RUN docker-php-ext-configure zip
RUN docker-php-ext-install \
    curl \
    pdo \
    pdo_mysql \
    pcntl \
    xml \
    gd \
    zip \
    bcmath
 
WORKDIR /var/www/html
COPY src src/
COPY --from=composer_stage /usr/bin/composer /usr/bin/composer
COPY composer.json /var/www/html/
# These are production settings, I'm running with 'no-dev', adjust accordingly
# if you need it
RUN composer install
 
CMD ["php-fpm"]
 
EXPOSE 9000

This Dockerfile is downloading the latest php-fpm configured for Alpine Linux along with Bash, curl, a slew of compilers, and all the other dependencies needed by my application. I have yet to figure out how to get composer install to run specifically against the /var/www/html/ directory to install the vendor folder; feel free to drop a comment or suggestion for how to get that going if you know how, thanks! 

The DocuSign PHP Launcher through Docker is deployed using NGINX Alpine. We're using NGINX as a reverse proxy to expose port 8080 to port 80 and serve the PHP output back to the user on port 9000, from a linked folder that has our PHP source code. To define those requirements, we’ll use a second configuration file named docker-compose.yml. Docker Compose is a separate command-line tool that will orchestrate the building and linking of multiple containers together along with defining volumes. Review the following docker-compose YAML file:

version: '3'
services:
  php-fpm:
    container_name: docusign-php-fpm
    build:
      context: .
      dockerfile: ./Dockerfile
    volumes:
      - ./:/var/www/html
  nginx:
    image: nginx:alpine
    container_name: docusign-nginx
    volumes:
      - ./vhost.conf:/etc/nginx/conf.d/default.conf
      - ./:/var/www/html
    links:
      - php-fpm
    ports:
      - "8080:80"

YAML?

That’s right, YAML! YAML (.yml) files are similar to Python in that spacing and indentation is important and denote different objects. We’ve got the two different containers defined here along with their volumes (the virtual directories configured). Note that the two different volumes have a matching patch ./:/var/www/html that will be linked from the PHP container into the NGINX container. If you wish to provide a one way flow of data, you can use attach :ro to the end of the path, as in "./:/var/www/html:ro" to mount read-only to the server. 

Finally, we expose the standard 8080 http caching proxy to port 80.  The NGINX standard container needs to know our servername and our fast_cgi parameters (the fpm in php-fpm stands for fast CGI process manager). Here is the NGINX vhost config file we’re linking to in the docker-compose.yml file above:

server {
    listen 80 default_server;
    server_name docusign;
 
    root /var/www/html;
 
    location / {
        try_files $uri /public/index.php$is_args$args;
    }
 
    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        fastcgi_index index.php;
        send_timeout 1800;
        fastcgi_read_timeout 1800;
        fastcgi_pass php-fpm:9000;
    }
}

With most great things come caveats

Docker is pretty great, right? It powers through as a solution to bundle microservices for PHP, a Linux distribution, a web server, and our source code PHP application, all from within a single box, but not always pain-free. For instance, updating our PHP launcher codebase to PHP version 8 required also updating the correlated Docker configurations. I came to learn that simply rolling forward to the next version of the php 8.16-fpm-alpine image wasn’t enough. I encountered the strangest error regarding a missing “_libiconv_version” which implied that lib(rary) iconv did not compile. Iconv was, for all intents and purposes, unknown to me upon reading that error.

#0 7.747 In file included from /usr/src/php/ext/iconv/iconv.c:22:
#0 7.747 /usr/src/php/ext/iconv/config.h:59: note: this is the location of the previous definition
#0 7.747    59 | #define ICONV_BROKEN_IGNORE 1
#0 7.747       |
#0 7.877 /usr/src/php/ext/iconv/iconv.c: In function 'zm_startup_miconv':
#0 7.877 /usr/src/php/ext/iconv/iconv.c:284:4: error: '_libiconv_version' undeclared (first use in this function)
#0 7.877   284 |    _libiconv_version >> 8, _libiconv_version & 0xff);
#0 7.877       |    ^~~~~~~~~~~~~~~~~
#0 7.877 /usr/src/php/ext/iconv/iconv.c:284:4: note: each undeclared identifier is reported only once for each function it appears in
#0 7.880 /usr/src/php/ext/iconv/iconv.c: In function '_php_iconv_appendl':
#0 7.880 /usr/src/php/ext/iconv/iconv.c:181:15: warning: implicit declaration of function 'libiconv'; did you mean 'iconv'? [-Wimplicit-function-declaration]
#0 7.880   181 | #define iconv libiconv
#0 7.880       |               ^~~~~~~~
#0 7.880 /usr/src/php/ext/iconv/iconv.c:453:8: note: in expansion of macro 'iconv'
#0 7.880   453 |    if (iconv(cd, (char **)&in_p, &in_left, (char **) &out_p, &out_left) == (size_t)-1) {
#0 7.880       |        ^~~~~
#0 7.913 make: *** [Makefile:192: iconv.lo] Error 1

Luckily, the internet was gracious enough to provide an answer to this enigma. Iconv allows a means to automate the conversion of text and file encoding formats. If I have a string of characters that is stored or written in a legacy format (perhaps something from the 1970s), I  can use Iconv to convert the format to a modern format such as UTF-8. More digging online, and I learned that Iconv API has a PHP wrapper built into the language itself. I presume that this makes Iconv a hard requirement, that it will likely be required for other dependencies. I scrambled through GitHub to find some relevant guidance. This is a web-current PHP production-level library; SOMEONE else has encountered what I’ve encountered here.  

Sure enough, I uncovered a GitHub post by someone mentioning that the latest current iteration of the PHP8-alpine image does not include the libiconv library. @joePagan of GitHub provided a small workaround whereby he advises to load an older, precompiled version of libIconv directly from the Alpine Linux distribution repository. Upon downloading that precompiled version of libiconv, we use LD_PRELOAD on our environment to attach Iconv to our container as we were able to before.

# We currently can't natively pull iconv with PHP8, see: https://github.com/docker-library/php/issues/240#issuecomment-876464325
RUN apk add gnu-libiconv=1.15-r3 --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.13/community/ --allow-untrusted
ENV LD_PRELOAD /usr/lib/preloadable_libiconv.so

Another issue I ran into was with maintaining my Docker image sizes. I found that Docker started to become resource-heavy once I started messing around with settings or adding features and creating new image exports. My Docker image sizes were becoming between 60% and three times larger each time I exported my image. This happens due to the nature of the way Docker exports image changes as layered states onto existing images. Currently, the general guidance is to use official Docker images, particularly those that use the Alpine Linux architecture with the smaller footprint.  

Finally, the last issue I had was philosophical in nature. The official Docker Desktop software is the suggested route for having a Docker daemon service running in the background and requires a paid subscription to use within larger organizations. Software that was once free but costs money to maintain is often scrapped in favor of other solutions. At the time that the DocuSign PHP launcher was containerized, Docker Desktop was free to use. I have seen alternative workarounds involving using docker-cli on Ubuntu Linux via the WSL2 for Windows so that you can use Docker from directly within Visual Studio and VS Code, so there is that, if you wish to embark on such a journey. 

Dockerizing the other Code Launchers 

To containerize the other DocuSign code launchers would be relatively trivial. Modify the three (or possibly just the Dockerfile if you don’t plan to use NGINX) files above and copy them over into your given repo. Modify the Dockerfile, as it is tailored to the given language. It will grab a specific base image such as Python or Java when specified, which means you'll also need to walk through the file and ensure that you're downloading and updating the proper dependencies (and required prerequisite compilers!) for that given language. 

For the PHP launcher, we’re using NGINX, but that is totally up to you if you wish to use a different setup (without NGINX). In the example shown above, we’re using NGINX as a reverse proxy so that the PHP server cannot be directly communicated with; NGINX is acting on your behalf to pass requests to php-fpm to be executed. 

Should you choose a different language, such as Java, you could point directly to your Tomcat web server instance, instead of using a NGINX as a proxy, to simplify the setup greatly; you would then only need the Dockerfile to install Java, Maven, and Tomcat into a container. You then can run mvn install and mvn spring-boot:start to kick off the Java application in the container. If you wanted to Dockerize a Node.js application, the process is somewhat similar: simply copy the files into your Docker image, run npm install, then npm start. Here is an example Dockerfile, courtesy of Snyke:

FROM node
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Remember, you don’t need Docker Compose to orchestrate builds if you’re using a single combined server. To use the Dockerfile above, you need to build it and give it a tag name. Then you can use the docker run command with the tag and the flag -p to specify a port for the Node server to listen on. Here's how to do that using a command line or terminal:

$ docker build . -t code-examples-node
$ docker run -p 3000:3000 code-examples-node

Wrapping up

There are benefits to be had by using Docker to achieve containerization for your applications and microservices. I have described just how easy it is to combine and package your application into Docker images that can store OS-level dependencies and configuration settings to ease their deployment. I’ve taken care to not specify “Web" application, because often there are different proprietary or internally facing tools that do not work to serve end-users such as customers or clients. Perhaps you have an inventory system that is designed for employees only that is linked through a local intranet. Using Docker to bundle an application for deployment through containerization could help immensely, especially if you intend to migrate or scale up your operation. But remember, a wise old engineer once said, "There is no free lunch." Using Docker means maintaining two sets of infrastructure: your application and your containerization process pipeline. 

I hope you found this to be an interesting read. Here are some other interesting blog posts and resources you might like.

Additional resources

 

Aaron Wilde
Author
Aaron Jackson-Wilde
Programmer Writer
Published