3 ways to get Docker for Mac faster on your Symfony app.

Docker is an awesome tool for development but it turns out to be very slow on Mac by default on Symfony apps. We'll eexplore ways to make your Symfony project run much faster on Docker.

UPDATE (2017-05-05): article has been updated for docker-sync 0.4.

Docker is an amazing tool to set up your whole development platform locally (and elsewhere too). As you may know, it has many benefits, including:

  • Same, versioned and deterministic configuration accross environments and developers.
  • Ease of use (adding any technical stack to the project is very easy, with just a few lines of configuration).
  • Much better performance than a VM, at least on Linux (that's the point of this article).
  • Easy deployment.

However, you probably have noticed that running a Symfony app with Docker for Mac is very slow, almost unusable however fast is your Mac. This article will explain several ways to make your Symfony runnable on Docker for Mac.

We are first going to set up a simple Symfony project to evaluate how the exposed solutions perform. If you already have a Symfony project, you can skip directly to the solutions.

The performance tests have been measured on a 2016 MacBook Pro and are intended to show the variations depending on the solutions.

Demo project setup.

The example project will set up a very simple Symfony app with a typical Docker setup with the following containers:

  • Nginx webserver.
  • PHP 7.1.
  • A simple container for the Composer binary.

The Symfony app will be stored in an app folder and the project file structure will look like this:

πŸ“‚ symfony-docker-test
    πŸ“‚ app
    πŸ“‚ docker
        πŸ“‚ php
            πŸ“„ Dockerfile
            πŸ“„ php.ini
        πŸ“‚ nginx
            πŸ“„ app.conf
    πŸ“„ docker-compose.yml

Setup the Symfony project

In the symfony-docker-test folder, run:

symfony new app

This needs the symfony command to be installed on your host, as described on the Symfony installation doc.

Define containers

Edit the docker-compose.yml file at the root of the project:

version: '2'
services:
  php:
    build: ./docker/php/
    environment:
      TIMEZONE: Europe/Paris
    volumes:
      - ./docker/php/php.ini:/usr/local/etc/php/php.ini:ro
      - ./app:/var/www/app
    working_dir: /var/www/app

  webserver:
    image: nginx:1.11
    depends_on:
      - php
    volumes_from:
      - php
    volumes:
      - ./docker/nginx/app.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - 8080:80

  composer:
    image: composer:1.4
    volumes_from:
      - php
    working_dir: /var/www/app

Setup the PHP container

Add the following Dockerfile file to the docker/php folder:

FROM php:7.1-fpm

# Install recommended extensions for Symfony
RUN apt-get update && apt-get install -y \
        libicu-dev \
    && docker-php-ext-install \
        intl \
        opcache \
    && docker-php-ext-enable \
        intl \
        opcache

# Permission fix
RUN usermod -u 1000 www-data

Add PHP configuration to the docker/php/php.ini file:

date.timezone = ${TIMEZONE}
short_open_tag = Off
log_errors = On
error_reporting = E_ALL
display_errors = Off
error_log = /proc/self/fd/2
memory_limit = 256M

; Optimizations for Symfony, as documented on http://symfony.com/doc/current/performance.html
opcache.max_accelerated_files = 20000
realpath_cache_size = 4096K
realpath_cache_ttl = 600

Setup the Vhost for Nginx

Edit the docker/nginx/app.conf file:

upstream php-upstream {
    server php:9000;
}

server {
    root /var/www/app/web;
    listen 80;
    server_tokens off;

    location / {
        try_files $uri @rewriteapp;
    }

    location @rewriteapp {
        rewrite ^(.*)$ /app.php/$1 last;
    }

    location ~ ^/(app|app_dev|app_test|config)\.php(/|$) {
        fastcgi_pass php-upstream;
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param HTTPS off;
    }
}

Run the project

Now that the project has been set up, simply run:

docker-compose up -d

The app should now be available at http://127.0.0.1:8080 and you should see this kind of page:

So... everything is fine, expect one thing: the app is very slow for now 😩

Let's run a simple benchmark for the prod and dev environments (you will mostly use the later):

ab -n 100 -r http://127.0.0.1:8080/
ab -n 100 -r http://127.0.0.1:8080/app_dev.php

Benchmark results:

  • Prod: 17 seconds
  • Dev: 129 seconds (ouch!)

Solution 1: Move vendors out of the shared directory

After several tests (as performed here: https://github.com/michaelperrin/docker-symfony-test), it turned out that the bottleneck that slows down the application is the sharing of the vendor dir, that has a lot of files in it.

One efficient solution is to separate the vendor dir and make it only available in the container and not on the host. Surprisingly, not sharing the cache directory doesn't result in a big win, but it can be a second step of performance tuning.

To do so, edit your composer.json file in the app folder and add the config-dir parameter in the config entry:

{
    ...
    "config": {
        ...
        "vendor-dir": "/app-vendor"
    }
}

Edit the app/autoload.php file in the app folder and change this line:

$loader = require __DIR__.'/../vendor/autoload.php';

to

$loader = require '/app-vendor/autoload.php';

Add the /app-vendor folder as a volume for the php container in docker-compose.yml:

services:
  php:
    # ...
    volumes:
      # ...
      - /app-vendor

Make sure you have cleared your Symfony cache folder and install composer dependencies again by running this command:

docker-compose run --rm composer install

Benchmark results:

  • Prod: 2.8 seconds
  • Dev: 16 seconds

Let's see if we can do even better without cache sharing. Edit Symfony's AppKernel.php file to change the getCacheDir method this way:

class AppKernel
{
    // ...
    public function getCacheDir()
    {
        return '/dev/shm/symfony_docker_test/cache/'.$this->environment;
    }
}

Benchmark results:

  • Prod: 1.2 seconds
  • Dev: 5 seconds

Not bad. But beware! The vendor files are now concealed in your container and won't show anymore on the host. You won't be able to debug the vendors, and autocomplete won't be available in your IDE. My advice? Install first the dependencies in the standard directory, and then change again the composer.json file to make them installed in the container. This workaround is not as bad as it sounds if your dependencies don't change often.

Solution 2: Docker sync

Docker-sync (http://docker-sync.io/) is a tool that makes use of rsync to synchronize volumes files between the host and your containers instead of using Docker's osxfs system.

Install docker-sync:

sudo gem install docker-sync

Add a docker-sync.yml file at the root of the project:

version: '2'
syncs:
  app-sync:
    src: './app'

Copy the docker-compose.yml file to docker-compose-dev.yml file and add these lines to the end:

volumes:
  app-sync:
    external: true

Use a named volume for your app's code by changing this:

services:
  #...

  php:
    #...
    volumes:
      # ...
      - ./app:/var/www/app

to:

services:
  #...

  php:
    #...
    volumes:
      # ...
      - app-sync:/var/www/app

Add now a Makefile file at the root directory, that will allow easy start / stop commands whatever the system the project is run on:

OS := $(shell uname)

start_dev:
ifeq ($(OS),Darwin)
	docker volume create --name=app-sync
	docker-compose -f docker-compose-dev.yml up -d
	docker-sync start
else
	docker-compose up -d
endif

stop_dev:           ## Stop the Docker containers
ifeq ($(OS),Darwin)
	docker-compose stop
	docker-sync stop
else
	docker-compose stop
endif

You can now start your project by running:

make start_dev

This will start your containers and the docker-sync daemon (answer yes to all questions the first time).

Benchmark results:

  • Prod: 0.6 seconds
  • Dev: 1.2 seconds

That's a big win! Unfortunately, I experience several sync problems at times, with files not being synced from the host to the container, and with some user right issues on some files as well (chmod 777 to the rescue).

Solution 3: Docker's cache system

The Docker team is aware of the slowness of Docker for Mac (see here and here)

The latest beta (aka. "Edge") versions of Docker have introduced some new ways to mount volumes.

If you have downloaded the edge version, simply change the docker-compose.yml file at the root of the project to add the :cached option to the volume share:

services:
  php:
    # ...
    volumes:
      - # ...
      - ./app:/var/www/app:cached
  • Prod: 5.1 seconds
  • Dev: 15.7 seconds

Not bad, but not as efficient as the other solutions. That can be enough for some projects though. I can't tell for now if there are downsides for this solution, but I will experiment it from now.

Conclusion

Good news! Docker on Mac can be usable for your Symfony project. There is no perfect solution though. Have a look to a summary of all the possibilities below and take your pick!

Dev benchmark Prod benchmark Pros Cons
Default 129 seconds 17 seconds Easy Unusable
Solution 1: Non-shared vendors and cache 5 seconds 1.2 seconds Fast Vendors are not visible to host
Solution 2: Docker-sync 1.2 seconds 0.6 seconds Very fast Use of 3rd-party tool
Solution 3: Docker's volume cache 15.7 seconds 5.1 seconds Easy solution Not as fast.
Experimental for now.