Docker for Mac Performance using NFS (Updated for macOS Catalina)

Author: Kiel
Monday, October 14 2019

We heavily use Docker for Mac for the internal development of our products. It allows us to closely replicate the internal, automated testing, user acceptance testing and production platforms.

There is just one problem, that I'm sure you've also found... The performance of the file system when using volume mounts.

This article is outdated, recent development from Docker indicates performance improvements without using NFS/hacks: https://www.docker.com/blog/speed-boost-achievement-unlocked-on-docker-desktop-4-6-for-mac/

Before Docker, came Vagrant, before Vagrant, came MAMP stacks. As developers we have been through a few different development environments in our time. Moving from Vagrant to Docker was a blessing, although one thing that hit us hard was the performance of Docker on Mac, specifically the file system performance.

When developing on macOS you would typically mount your local project folder volume to the /app directory within your container. If you made a change in your IDE, it would be replicated into the container (somehow), and you could then serve the updated content through your application.

This is fairly simple to set-up, i.e. you could start your docker container like this:

$ docker run -it --volume ${PWD}:/app php:7-fpm sh

or if you are using docker-compose (tip: you should), then you might have an block of config in your docker-compose.yml file looking like this:

    app:
        build:
            context: .
            dockerfile: ./docker/app/Dockerfile
        working_dir: /app
        user: "www-data"
        volumes:
            - ./:/app

By default this will create a consistent link between the host and docker file system, this is extremely slow.

i.e. if you perform anything with extensive file I/O in either the docker container or the host file system, such as $ bin/console cache:clear, you can expect a long wait.

In Docker for Mac 17.04 CE, the option to use a delegated link became available, this is pretty much the same speed as consistent in terms of transferring information between host and container, but with one important difference - it did not block the container when performing I/O inside the container. This meant that the host file system was the last to know about the changes, but this was a good thing (generally) as it meant your application would run fairly smoothly inside the container.

There is much more about the subject here: https://docs.docker.com/docker-for-mac/osxfs-caching/

However the long and short of it is you can add a :delegated flag to the volume mount to enable this, such as:

$ docker run -it --volume ${PWD}:/app:delegated php:7-fpm sh

or in your docker-compose.yml file:

    app:
        build:
            context: .
            dockerfile: ./docker/app/Dockerfile
        working_dir: /app
        user: "www-data"
        volumes:
            - ./:/app:delegated

Look why this is such a problem

I should point out these were tested very unscientifically, on a Mac Book Pro 2018 with a typical Symfony project and your mileage may vary.

Running on host file system

$ time php app/console c:c
real    0m7.192s
user    0m3.322s
sys     0m3.121s

Running in the docker container in consistent (default) mode

$ time php app/console c:c
real    1m41.386s <---------------------- 14 times as slow
user    0m3.410s
sys     0m8.670s

Running in the docker container in delegated mode

$ time php app/console c:c
real    0m56.587s <---------------------- 8 times as slow
user    0m3.600s
sys     0m5.170s

Enter NFS

NFS is a popular mechanism that volumes were mounted when using Vagrant and it's performance has been pretty consistent in the past, it's been a stretch to bring it to docker as there have been a number of challenges to overcome, however if you create a volume in your docker-compose.yml config file to point to NFS such as:

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

... then modify your volume mount in docker-compose.yml as such:

    app:
        build:
            context: .
            dockerfile: ./docker/app/Dockerfile
        working_dir: /app
        user: "www-data"
        volumes:
            - "nfsmount:/app"

... then run a nice little script I found from https://gist.github.com/seanhandley GitHub Link

#!/usr/bin/env bash

OS=`uname -s`

if [ $OS != "Darwin" ]; then
  echo "This script is OSX-only. Please do not run it on any other Unix."
  exit 1
fi

if [[ $EUID -eq 0 ]]; then
  echo "This script must NOT be run with sudo/root. Please re-run without sudo." 1>&2
  exit 1
fi

echo ""
echo " +-----------------------------+"
echo " | Setup native NFS for Docker |"
echo " +-----------------------------+"
echo ""

echo "WARNING: This script will shut down running containers."
echo ""
echo -n "Do you wish to proceed? [y]: "
read decision

if [ "$decision" != "y" ]; then
  echo "Exiting. No changes made."
  exit 1
fi

echo ""

if ! docker ps > /dev/null 2>&1 ; then
  echo "== Waiting for docker to start..."
fi

open -a Docker

while ! docker ps > /dev/null 2>&1 ; do sleep 2; done

echo "== Stopping running docker containers..."
docker-compose down > /dev/null 2>&1
docker volume prune -f > /dev/null

osascript -e 'quit app "Docker"'

echo "== Resetting folder permissions..."
U=`id -u`
G=`id -g`
sudo chown -R "$U":"$G" .

echo "== Setting up nfs..."
LINE="/Users -alldirs -mapall=$U:$G localhost"
FILE=/etc/exports
sudo cp /dev/null $FILE
grep -qF -- "$LINE" "$FILE" || sudo echo "$LINE" | sudo tee -a $FILE > /dev/null

LINE="nfs.server.mount.require_resv_port = 0"
FILE=/etc/nfs.conf
grep -qF -- "$LINE" "$FILE" || sudo echo "$LINE" | sudo tee -a $FILE > /dev/null

echo "== Restarting nfsd..."
sudo nfsd restart

echo "== Restarting docker..."
open -a Docker

while ! docker ps > /dev/null 2>&1 ; do sleep 2; done

echo ""
echo "SUCCESS! Now go run your containers"

It will set up a NFS link between your $PWD (current working directory) and the containers /app directory, and finally when you run a cache:clear again:

Running in the docker container in NFS mode

$ time php app/console c:c
real    0m18.641s <---------------------- 2.5 times as slow as native
user    0m2.920s
sys     0m2.750s

Verdict

While I hope sincerely you have already been using delegated mode before finding this labs post, NFS is much nicer as it removes a lot of the CPU overhead between host and container. Now, I/O peformance is not the only element of the overall app performance, but I'll take this performance improvement over delegated anyday.

Go forth and NFS things!

Update for OSX Catalina

OSX Catalina had changed the volume for various paths, including your user folder, this is a simple fix to move the NFS path to the new location. The above scripts/config must be changed so that

LINE="/Users -alldirs -mapall=$U:$G localhost"

changes to

LINE="/System/Volumes/Data -alldirs -mapall=$U:$G localhost"

and also

device: ":$PWD"

changes to

device: ":/System/Volumes/Data/${PWD}"

Be sure to re-run the script and also remove/recreate any Docker volumes, you can list your Docker volumes with:

docker volume ls

and then delete the relevant volumes created in a past Docker session with:

docker volume rm <YOUR_VOLUME_NAME>

Credit goes to: https://www.firehydrant.io/blog/nfs-with-docker-on-macos-catalina/

Running PHPUnit with a specific data provider set How to work with Money in PHP