PHP was always the go-to language for me. I started exploring PHP in 2000, around the time of PHP 4 launch, in an effort to convert Legacy PHP and an increasingly popular website that was written as lots of individual, static HTML pages. I began work on writing a Content Management System from scratch, and the CMS was still distributing 30,000 articles some 17 years later.
This walk through is about another PHP application — it was written using the latest PHP 5 features at the time, and was left happily running in a single Amazon EC2 “classic” instance since 2015.
When I started this migration, the last commit was July 30th 2016.
Legacy PHP Existing platform
The frontend was left running a variety of third party libraries, notably Bootstrap 3.3 and jQuery 2.2.
The server-side was Symfony 3.1 and templates were Twig 1.x.
Data was obtained from an external API using guzzle and locally cached using doctrine cache 1.6. Logging was done with monolog.
Build toolkit included Node.js for npm (package manager), Grunt (javascript task runner), and Bower (web package manager).
CSS stylesheets were written in sass, so we included a parser to create css, concatenate, and minimize. Javascript was consolidated, obfuscated (“uglified”), and also minified.
To get a feel for the build steps, here’s the “build-all” grunt task:
grunt.registerTask('build-all', ['clean:all', 'concat', 'uglify', 'sass', 'cssmin', 'bowercopy', 'copy', 'string-replace:version', 'composer:dist:install:optimize-autoloader', 'composer:dist:update:optimize-autoloader']);
And finally, as you can see from the above, I was using Composer for PHP dependency management.
And finally the server itself was using Nginx with php-fpm.
Everything was stuck in the year 2015. My development server had long since been destroyed. I couldn’t do any changes; I didn’t even have PHP installed any more.
Aspiration
- Upgrade front end to the latest versions of Bootstrap and jQuery.
- Update server side runtime libraries to the latest version — apply necessary code changes required as a result, but no other functional changes.
- Upgrade the built-kit to the latest versions — but no other changes to the tasks or tools used.
- Deploy to an up-to-date operating system, with PHP 7.x.
My overarching constraint for the above was that I didn’t want to have to work through and recreate an entire development platform to do it. I wanted to find a way I could achieve the upgrade without needing to stack my Windows laptop with grunt, bower, nodejs, composer, PHP, just for this one task.
I turned to Docker to save the day.
Creating a build container
My build server required the following tools to be installed:
- PHP 7.x
- Composer
- Node.js
- Grunt
- Bower
Transitively, for SASS parsing, I subsequently found I needed:
- Ruby
PHP and Composer (build time)
Given this was used at build time, I wasn’t concerned about having a bloated image so I decided to go for a full PHP installation as the base image. I used the latest PHP image (on Alpine) for my build environment (which I gave the label “buildenv”).
FROM php:7.4.1-alpine AS buildenv
Unfortunately, Composer isn’t included by default and while I could follow the Composer installation instructions, we only need the final ‘composer’ binary file. Luckily, there’s an official Composer image to use. So we reference that image in our layers, and copy the composer binary over into our buildenv.
FROM composer:1.9.1 AS composer
FROM php:7.4.1-alpine AS buildenv
COPY --from=composer /usr/bin/composer /usr/bin/composer
At this stage, we have an Alpine 3.10 operating system with PHP 7.4.1 and Composer 1.9 available.
Node.js, Grunt, Bower (build time)
Building upon the previous layer, I then looked to the other build tools I required. These I grouped into a single layer, and installed the packages and ensured npm was up to date. Job done.
RUN apk add --update nodejs npm && \
npm update -g npm && \
npm install -g grunt-cli && \
npm install -g bower
Ruby (build time)
A bit of trial and error determined that the sass parser required Ruby. This is in its own layer to keep it separate while I worked out the packages I needed to install. More than I thought and remembered.
RUN apk add --update ruby ruby-bundler ruby-dev \
ruby-rdoc build-base gcc && \
gem install sass
Create a working directory
I always like to do everything inside a working directory, so I create /app and work inside here for all subsequent stages.
RUN mkdir /app
WORKDIR /app
Downloading the application dependencies
Remarkable looking back at all of the stages to get through before we can commence the application build. In summary, we have these steps for downloading the build time and runtime dependencies we require;
- npm install — download the javascript build libraries into node_modules
- composer install — PHP server side runtime dependencies, such as Twig and Symfony.
- bower update — download all the client side runtime libraries, such as jQuery.
# Copy npm dependencies
COPY package.json .
RUN npm install # Copy composer and download dependencies
COPY composer.json .
RUN composer install # Copy bower and download dependencies
RUN apk add --update git
RUN echo '{ "allow_root": true }' > /root/.bowerrc
COPY bower.json .
RUN bower update
To briefly explain the above techniques.
- I copy only the files needed for the job (e.g. package.json, composer.json, or bower.json) — that’s so that I can isolate changes and limit recreating layers unnecessarily. Some of these steps can take 5 minutes to complete so I don’t want to trigger a full rebuild because I changed an unrelated file.
- Bower — needs git, so this stage I added that. Secondly, bower throws a warning when it is run as ‘root’ user — so the second line suppresses that.
So now we have a build environment that has an Alpine 3.10 operating system with PHP 7.4.1 and Composer 1.9 available. It has the latest versions of nodejs, grunt, and bower, and we have downloaded all the build-time and runtime dependencies that we require.
I think now we’re ready to build the application.
Building the application
In Docker terms, this may feel like an anticlimax. Because I’m using grunt, the existing build-all task that I created back in 2015 will still do the job.
# Copy all the remaining source code
COPY src/ /app/src
COPY Gruntfile.js .
RUN grunt build-all
This is where all the work is done. You can skip this if you want, but I’ve shared so you can see the original build task. You might find this snippet useful to take some concepts into your own project.
In summary, what we are doing is:
- cleaning our build directory
- concatenating all javascript source code into a single file.
- obfuscating the javascript code
- using sass parser to create our css stylesheets
- minifying the css stylesheets
- copying all our javascript and css, third party css, and fonts over to our final distribution directory
- copy all server-side source code into the directory ready for Composer
- update version number placeholder
- run Composer to create an optimized production-ready runtime
It wasn’t all plain sailing when I upgraded my libraries to their latest version. Some changes were easy, sure, but a few required code updates — after all, I had to accommodate 5 years of deprecated features and changes. But all in all, it wasn’t as painful as I expected.
module.exports = function (grunt) { // Project configuration. grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), clean: { all: { src: ["dist", "tmp", ".sass-cache"], options: { force: true } }, web: { src: ["dist/web", "dist/templates", "dist/compilation_cache", "dist/doctrine_cache", "tmp", ".sass-cache"], options: { force: true } } }, jshint: { all: ['src/js/*.js'] }, concat: { all: { src: ['src/js/*.js'], dest: 'tmp/js/<%= pkg.name %>.js' } }, sass: { dist: { files: { 'tmp/css/<%= pkg.name %>.css': 'src/scss/main.scss' } } }, cssmin: { target: { options: { banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */' }, files: [{ expand: true, cwd: 'tmp/css/', src: ['*.css', '!*.min.css'], dest: 'dist/web/css/', ext: '.min.css' }] } }, uglify: { options: { banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n' }, build: { src: 'tmp/js/<%= pkg.name %>.js', dest: 'dist/web/js/<%= pkg.name %>.min.js' } }, bowercopy: { javascript: { options: { destPrefix: 'dist/web/js' }, files: { 'jquery.min.js': 'jquery/dist/jquery.min.js', 'bootstrap.min.js': 'bootstrap/dist/js/bootstrap.min.js', 'html5shiv.min.js': 'html5shiv/dist/html5shiv.min.js', 'respond.min.js': 'respond/dest/respond.min.js', 'underscore.min.js': 'underscore/underscore-min.js', 'jquery.dataTables.min.js': 'datatables.net/js/jquery.dataTables.min.js', 'dataTables.bootstrap.min.js': 'datatables.net-bs/js/dataTables.bootstrap.min.js' } }, css: { options: { destPrefix: 'dist/web/css' }, files: { 'bootstrap.min.css': 'bootstrap/dist/css/bootstrap.min.css', 'bootstrap-theme.min.css': 'bootstrap/dist/css/bootstrap-theme.min.css', 'font-awesome.min.css': 'components-font-awesome/css/font-awesome.min.css', 'dataTables.bootstrap.min.css': 'datatables.net-bs/css/dataTables.bootstrap.min.css' } }, bootstrap_fonts: { files: { 'dist/web/fonts': 'bootstrap/dist/fonts/*.*' } }, font_awesome_fonts: { files: { 'dist/web/fonts': 'components-font-awesome/fonts/*.*' } } }, copy: { main: { files: [ {expand: true, cwd: 'src/php/web/', src: ['**'], dest: 'dist/web/'}, {expand: true, cwd: 'src/php/lib/', src: ['**'], dest: 'dist/lib/'}, {expand: true, cwd: 'src/static/', src: ['**'], dest: 'dist/web/'}, {expand: true, cwd: 'src/ico/', src: ['**'], dest: 'dist/web/ico/'}, {expand: true, cwd: 'src/img/', src: ['**'], dest: 'dist/web/img/'}, {expand: true, cwd: 'src/css/', src: ['**'], dest: 'dist/web/css/'}, {expand: true, cwd: 'src/config/', src: ['**'], dest: 'dist/config/'}, {expand: true, cwd: 'src/templates/', src: ['**'], dest: 'dist/templates/'}, {src: ['composer.json'], dest: 'dist/'} ] } }, composer: { dist: { options: { cwd: 'dist' } } }, 'string-replace': { version: { files: { 'dist/config/settings.ini': 'dist/config/settings.ini' }, options: { replacements: [{ pattern: '%APPLICATION_VERSION%', replacement: '<%= pkg.version %>-<%= grunt.template.today("yyyymmdd") %>' }] } } } }); grunt.loadNpmTasks('grunt-contrib-clean'); grunt.loadNpmTasks('grunt-contrib-concat'); grunt.loadNpmTasks('grunt-contrib-sass'); grunt.loadNpmTasks('grunt-contrib-jshint'); grunt.loadNpmTasks('grunt-contrib-uglify'); grunt.loadNpmTasks('grunt-contrib-cssmin'); grunt.loadNpmTasks('grunt-composer'); grunt.loadNpmTasks('grunt-bowercopy'); grunt.loadNpmTasks('grunt-contrib-copy'); grunt.loadNpmTasks('grunt-string-replace'); grunt.registerTask('build-all', ['clean:all', 'concat', 'uglify', 'sass', 'cssmin', 'bowercopy', 'copy', 'string-replace:version', 'composer:dist:install:optimize-autoloader', 'composer:dist:update:optimize-autoloader']); grunt.registerTask('build-web', ['clean:web', 'concat', 'uglify', 'sass', 'cssmin', 'bowercopy', 'copy', 'string-replace:version']); grunt.registerTask('run-composer', ['composer:dist:install:optimize-autoloader', 'composer:dist:update:optimize-autoloader']); };
At this stage we have a buildenv containing a directory /app/dist/
with all our final build output ready to be run.
Building the runtime image
So far everything we have done is in our ‘buildenv’. But we don’t want all this bloatware in our final image. So we start again with a slim operating system, and I chose Alpine.
On top of that, we need PHP but this time we only need a small subset of the PHP packages to run this application (your mileage will vary). And for this particular runtime, we still have Nginx.
As you will see later, I am also using supervisor to manage the processes to ensure php-fpm and nginx are kept running.
# Create final image
FROM alpine:3.11.0 # Install packages
RUN apk upgrade && apk --no-cache add php7 php7-fpm \
php7-json php7-openssl \
nginx supervisor curl
Configuration
Our three processes each need some configuration, so we copy those into place in the image. These trivial configuration files have been shared in the example repository on GitHub.
In summary, the configuration is simply to allow Nginx to listen on port 8080 and redirect requests to php-fpm to handle.
# Configure nginx
COPY config/nginx.conf /etc/nginx/nginx.conf # Configure php-fpm
COPY config/fpm-pool.conf /etc/php7/php-fpm.d/www.conf
COPY config/php.ini /etc/php7/conf.d/zzz_custom.ini # Configure supervisord
COPY config/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
Use nobody user to own directories
We’re using the ‘nobody’ user, so we need to ensure directory permissions are aligned.
RUN chown -R nobody.nobody /run && \
chown -R nobody.nobody /var/lib/nginx && \
chown -R nobody.nobody /var/log/nginx
We now have our runtime image, with PHP and Nginx installed and configured. But we still need our application.
Copy the final distribution from the build environment
Let’s create the directory /var/www/html
and switch to the nobody user, then copy all the distribution contents over from the ‘buildenv’.
# Setup document root
RUN mkdir -p /var/www/html # Switch to use a non-root user from here on
USER nobody # Add application
WORKDIR /var/www/html
COPY --chown=nobody --from=buildenv /app/dist/ /var/www/html/
Expose the port
Nearly at the end now — we need to expose the port that Nginx will be reachable on.
# Expose the port nginx is reachable on
EXPOSE 8080
Ensure services are running
The final stage now is to use supervisord to ensure that Nginx and php-fpm are running and able to serve requests.
# Let supervisord start nginx & php-fpm
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
Conclusion
And that’s it — we can build the image and run it locally. Our runtime has a low footprint because we used a slim base image and only installed the absolute bare-minimum packages we required to run our application.
Your situation will obviously be different, the libraries you use may be more complex — but I hope the overview gives a sense of what can be accomplished.
For build automation, I then combined GitHub with DockerHub so that future commits automatically kick off a build and the creation of a new versioned image.
And I deployed that into Google Cloud Run, where the exact same image I ran on my laptop I could also happily run in Production.
I killed off my Amazon EC2 “classic” micro instance after almost 6 years, and reduced the cost of running to pennies a year.
And by choosing Docker, I had a maintainable platform to keep on top of.
The final Dockerfile
I ended up with a single Dockerfile, applying the Docker multi-stage build technique, and ultimately created a cut-down, slim runtime image. I could have consolidated a few layers, and break out, but once the initial set up was done, I could spin out new images in under a minute so I left it all as one.
FROM composer:1.9.1 AS composer
FROM php:7.4.1-alpine AS buildenv
COPY --from=composer /usr/bin/composer /usr/bin/composer
RUN apk add --update nodejs npm && \
npm update -g npm && \
npm install -g grunt-cli && \
npm install -g bower
RUN apk add --update ruby ruby-bundler ruby-dev ruby-rdoc build-base gcc && \
gem install sass
RUN mkdir /app
WORKDIR /app
# Copy npm dependencies
COPY package.json .
RUN npm install
# Copy composer and download dependencies
COPY composer.json .
RUN composer install
# Copy bower and download dependencies
RUN apk add --update git
RUN echo '{ "allow_root": true }' > /root/.bowerrc
COPY bower.json .
RUN bower update
# Copy all the remaining source code
COPY src/ /app/src
COPY Gruntfile.js .
RUN grunt build-all
# Create final image
FROM alpine:3.11.0
# Install packages
RUN apk upgrade && apk --no-cache add php7 php7-fpm php7-json php7-openssl \
nginx supervisor curl
# Configure nginx
COPY config/nginx.conf /etc/nginx/nginx.conf
# Configure PHP-FPM
COPY config/fpm-pool.conf /etc/php7/php-fpm.d/www.conf
COPY config/php.ini /etc/php7/conf.d/zzz_custom.ini
# Configure supervisord
COPY config/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Make sure files/folders needed by the processes are accessable when they run under the nobody user
RUN chown -R nobody.nobody /run && \
chown -R nobody.nobody /var/lib/nginx && \
chown -R nobody.nobody /var/log/nginx
# Setup document root
RUN mkdir -p /var/www/html
# Create cache directories
RUN mkdir /var/www/html/compilation_cache && chown nobody.nobody /var/www/html/compilation_cache && \
mkdir /var/www/html/doctrine_cache && chown nobody.nobody /var/www/html/doctrine_cache
# Switch to use a non-root user from here on
USER nobody
# Add application
WORKDIR /var/www/html
COPY --chown=nobody --from=buildenv /app/dist/ /var/www/html/
# Expose the port nginx is reachable on
EXPOSE 8080
# Let supervisord start nginx & php-fpm
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
# Configure a healthcheck to validate that everything is up & running
HEALTHCHECK --timeout=10s CMD curl --silent --fail http://127.0.0.1:8080/fpm-ping