One of the most important things when starting to work on a project is to set up a local development environment. For me, this should have the following requirements:

  • Easy to install, preferably a one-click/command solution
  • Reproducible across multiple host machines, by everyone in the team

Very often, our go-to solution for this is a Docker-based environment powered by Docker Compose. Thankfully, these days this sort of setup is provided out-of-the-box when using Laravel through Laravel Sail.

However, if you’ve ever encountered this on a macOS environment (Docker Desktop), you’ve probably noticed this to be quite slow at times. The main reason is how file synchronisation is implemented in Docker for Mac. Combined with PHP projects which use a lot of I/O, this can cause quite a performance hit. This is one of the things that’s annoyed me often so I set out to look for a solution.

Before I delved into the depths of Docker, I started by benchmarking the current performance of a default Sail installation to have a frame of reference.

I started a new Laravel project and benchmarked two endpoints: the default welcome page and an API endpoint that returns a single user as a JSON response. The benchmarks themselves were done using k6 by running a GET request to the endpoints for 10 iterations.

// tests/Benchmarks/welcome.js

import http from 'k6/http';
import { check } from 'k6';

export default function () {
    const response = http.get('http://localhost');
    check(response, {
        'is status 200': (r) => r.status === 200,
    });
}

This was then ran via k6 run tests/Benchmarks /welcome.js -i 10 and gave the following results:

I’ll be the first to say that these results are not bad; an average of 50ms response time is definitely workable. But do keep in mind that this is a very simple endpoint and, in a real-world project, this can be a lot higher.

I ran the same benchmarks on two other infrastructures: using a native PHP 7.1 and MySQL 8 installation using Homebrew; and another using Sail on a Windows machine.

You can see that running Sail on Windows is much closer to the native PHP performance on macOS! Let’s figure out if we can get that same performance on our macOS machines with Docker.

Using NFS mounts

In short, an NFS mount is simply another way to mount a volume in your Docker container via the network.

To get started on macOS, you will first need to set up some things. Thankfully there is this gist which includes a bash script you can run to automate the process. Do make sure to update the code according to this comment if you are running on the most recent macOS version.

Another gotcha is that you cannot use NFS on directories that are in ~/Documents. So you will have to move your projects to any other directory (I use ~/Code).

Once you’ve run through that setup you can define the additional volume and provide some options, including which device to mount.

volumes:
    code:
        driver: local
        driver_opts:
            type: nfs
            o: addr=host.docker.internal,rw,nolock,hard,nointr,nfsvers=3
            device: ":${PWD}"

This will add a new code volume that contains the directory in which your docker-compose.yml file is in. You can then use that volume in a container like so:

volumes:
    - code:/var/www/html

There’s definitely some improvement here. On average I’ve noticed around 25% reduction in load-times so it’s definitely an option to consider. One downside of this approach, however, is that everyone on the team has to have support for NFS mounting so some personal setup is required.

If you do want to make use of it without interfering with other people’s setup you can always opt to use a docker-compose.override.yml file that is gitignored and overwrite the volume mounts:

version: "3"
services:
  laravel.test:
    volumes:
      - code:/var/www/html
volumes:
  code:
    driver: local
    driver_opts:
      type: nfs
      o: addr=host.docker.internal,rw,nolock,hard,nointr,nfsvers=3
      device: ":${PWD}"

Using an experimental Virtualization framework

Recently, Docker Desktop for mac has added the option to use a new Virtualization framework to replace Hypervisor. Out of curiosity I gave this a try but sadly the results were much worse.

Using Mutagen to increase performance of Docker

The final thing I tried was using Mutagen. A while back there was talk about having native support for this in Docker Desktop for mac but that hasn’t happened yet. Mutagen works by running everything in a remote, online environment. To be fair, it feels a bit magicy and I don’t quite understand how it works internally.

Getting Mutagen to work with Sail does require some manual setup. I gave it a try using Mutagen’s docker-compose integration which is still in beta. This is a wrapper around docker-compose that will also read the x-mutagen key in your docker-compose.yml file. This can be used to configure which volumes should be synchronized through Mutagen.

First off, I had to change the default container name from laravel.test to app since Mutagen doesn’t handle the former correctly. Then I added the following to the configuration to set up the synchronization.

app:
    volumes:
        - code:/var/www/html
volumes:
    code:
x-mutagen:
    sync:
        defaults:
            ignore:
                vcs: true
        code:
            alpha: "."
            beta: "volume://code"

Secondly, I needed to use the mutagen compose. If you’ve used Laravel Sail before then you know it comes with it’s own sail binary which also adds some helper commands. I wanted to make sure I could still use this binary. Recently, Laravel added a way to publish the Sail script to allow customizations by running the following command.

sail artisan vendor:publish --tag=sail-bin

With the script published we can now replace all the docker-compose usage with mutagen compose. Note that you will have to use ./sail now instead of sail (or configure an alias for it).

The final problem I encountered is that the web server would not start automatically because the Supervisor configuration could not find the artisan binary during the starting of the container, resulting in the following error:

app_1      | 2021-09-10 13:05:47,884 INFO spawned: 'php' with pid 18
app_1      | Could not open input file: /var/www/html/artisan
app_1      | 2021-09-10 13:05:47,930 INFO exited: php (exit status 1; not expected)
app_1      | 2021-09-10 13:05:48,934 INFO spawned: 'php' with pid 19
app_1      | Could not open input file: /var/www/html/artisan
app_1      | 2021-09-10 13:05:48,978 INFO exited: php (exit status 1; not expected)
app_1      | 2021-09-10 13:05:50,983 INFO spawned: 'php' with pid 20
app_1      | Could not open input file: /var/www/html/artisan
app_1      | 2021-09-10 13:05:51,027 INFO exited: php (exit status 1; not expected)
app_1      | 2021-09-10 13:05:54,038 INFO spawned: 'php' with pid 21
app_1      | Could not open input file: /var/www/html/artisan
app_1      | 2021-09-10 13:05:54,083 INFO exited: php (exit status 1; not expected)
app_1      | 2021-09-10 13:05:55,064 INFO gave up: php entered FATAL state, too many start retries too quickly

I assume this happens because the files are only synchronized after the container is started. I could see them when running ./sail exec app ls.

I was able to start the application by running the command manually and run the benchmarking scripts:

./sail exec app php artisan serve --host=0.0.0.0 --port=80

The results speak for themselves. This gives the best performance out of all the other options, getting very close to native performance. Of course, a major downside is that it comes with a lot of configuration and buggy behaviour with the Sail command.

Conclusion: how to improve Docker’s performance

There doesn’t seem to be a holy grail solution to improve Docker’s performance on macOS. There are a lot of ways to improve it but they all come with a trade-off.

It would be awesome if Mutagen became the default way to synchronise files on Docker for mac but sadly it doesn’t look like it will happen soon. Or perhaps Apple themselves make some changes on how files are synchronised that will improve these issues.

For now, I’ll stick with using NFS mounted volumes and use `docker-compose.override.yml` to tweak the configuration.

Resources