Testing if using Mutagen improves performance in comparison to NFS volume mounts. Comparing MacOS Docker performance with Linux.
TL;DR; Performance improvement by using Mutagen compared to NFS volume mounts: ~25%. Mutagen causes high CPU usage when using multiple syncs. Development experience not ideal due to delays until files are synced. Issues with intentional mass file changes being prevented by Mutagen safety mechanisms.
TL;DR; when using PHP / Drupal xDebug increased Drupal response times by about 5–6x. I’ve added an environment variable that allows disabling it on Docker run if I don’t need to debug.
Motivation
I’ve been using Docker for local development for some time now. The performance has been lacking, to say the least. Compared to other setups like using a VM provided by VirtualBox or running bare-metal directly on the machine. I investigated a bit and the culprit was volume mount performance (as expected). Running on Docker without volume mounts is multitudes faster.
However, Docker provides advantages as well. Especially if using Docker in production. Having the same - or a very similar - setup as in production can be very valuable.
Thus I looked for possible optimizations.
After reading some articles, I decided give Mutagen a try:
Mutagen setup
First off. What is Mutagen? What they say:
Mutagen provides real-time file synchronization and flexible network forwarding for developers, extending the reach of local development tools to cloud-based containers and infrastructure.
It allows to sync files from the host machine to Docker containers. An option to replace volume mounts.
Sounds great, no need for slow volume mounts. And still the same functionality and development workflow.
Using docker-compose
I first tried to configure it using Mutagen’s docker-compose feature.
Didn’t work though, I figured out afterward that I need to create all mount point directories in the Dockerfiles. And make the user that is running inside the container the owner of these directories(For example in the DB container the user with ID 1111). Otherwise, Mutagen cannot write to the mount points. Make sure the user’s home directory is also writable, see this issue for more info. Otherwise, it will fail to use the mutagen agent.
Here’s a simplified version, didn’t fix it though so it doesn’t work:
# Configuration for local development
version: "3.7"
services:
# MySQL
# -----
db:
image: bitnami/mysql:latest
ports:
- '127.0.0.1:3307:3306'
env_file:
- ./.env
volumes:
- db:/bitnami/mysql
# PHP 7.4
# -------
drupal:
image: php:7.4-fpm
env_file:
- ./.env
depends_on:
- db
links:
- db
volumes:
- appdata:/app/code
- public-files:/app/public_files
- private-files:/app/private_files
# Nginx
# -----
nginx:
image: nginx:latest
env_file:
- ./.env
ports:
- 80:8080
- 443:8443
depends_on:
- drupal
links:
- drupal
volumes:
- appdata:/app/code
- public-files:/app/public_files
- private-files:/app/private_files
volumes:
appdata:
public-files:
private-files:
db:
x-mutagen:
sync:
defaults:
mode: "two-way-resolved"
symlink:
mode: "posix-raw"
configurationBeta:
permissions:
defaultOwner: "id:1111"
defaultGroup: "id:1111"
defaultFileMode: "0666"
defaultDirectoryMode: "0666"
ignore:
vcs: true
appdata:
alpha: "."
beta: "volume://appdata"
ignore:
paths:
- "docker"
- "frontend-build"
- "Jenkins"
public-files:
alpha: "../public_files"
beta: "volume://public-files"
private-files:
alpha: "../private_files"
beta: "volume://private-files"
db:
alpha: "docker/db/mysql"
beta: "volume://db"
configurationBeta:
permissions:
defaultOwner: "id:1001"
defaultGroup: "id:0"
Using Mutagen commands
I also ran into the issue of Mutagen preventing mass file changes because of its safety-mechanisms. Drupal has commands to export configuration, which writes a lot of files at once. So this is a problem for my case.
This made me scrap the docker-compose approach. So I used Mutagen’s shell commands to gain more flexibility:
#!/usr/bin/env bash
# Usage: See help
# $ bash mutagen.sh --help
COMPOSE_PROJECT_NAME=drupal
SYNC_APPDATA_SOURCE=.
SYNC_APPDATA=${COMPOSE_PROJECT_NAME}-appdata
SYNC_APPDATA_DRUPAL=${SYNC_APPDATA}-drupal
SYNC_APPDATA_NGINX=${SYNC_APPDATA}-nginx
SYNC_APPDATA_CONFIG_SOURCE=./config
SYNC_APPDATA_CONFIG_DRUPAL=${SYNC_APPDATA}-config-drupal
SYNC_PUBLIC_FILES_SOURCE=../public_files
SYNC_PUBLIC_FILES=${COMPOSE_PROJECT_NAME}-public-files
SYNC_PUBLIC_FILES_DRUPAL=${SYNC_PUBLIC_FILES}-drupal
SYNC_PUBLIC_FILES_NGINX=${SYNC_PUBLIC_FILES}-nginx
SYNC_PRIVATE_FLIES_SOURCE=../private_files
SYNC_PRIVATE_FLIES=${COMPOSE_PROJECT_NAME}-private-files
SYNC_PRIVATE_FILES_DRUPAL=${SYNC_PRIVATE_FLIES}-drupal
SYNC_PRIVATE_FILES_NGINX=${SYNC_PRIVATE_FLIES}-nginx
function terminate_syncs() {
mutagen sync terminate --label-selector project="${COMPOSE_PROJECT_NAME}"
}
function pause_syncs() {
mutagen sync pause --label-selector project="${COMPOSE_PROJECT_NAME}"
}
function resume_syncs() {
mutagen sync resume --label-selector project="${COMPOSE_PROJECT_NAME}"
}
function flush_syncs() {
mutagen sync flush --label-selector project="${COMPOSE_PROJECT_NAME}"
}
function list_syncs() {
mutagen sync list --label-selector project="${COMPOSE_PROJECT_NAME}"
}
function syncs_exist() {
if [[ -z $(list_syncs | grep 'No synchronization sessions found') ]]; then
echo 1
else
echo 0
fi
}
function monitor_syncs() {
mutagen sync monitor --label-selector project="${COMPOSE_PROJECT_NAME}"
}
function do_containers_exist() {
if [[ -z "$(docker-compose ps | grep "${COMPOSE_PROJECT_NAME}")" ]]; then
echo 0
else
echo 1
fi
}
function get_container_name() {
local serviceName=${1}
local existingContainerName=$(docker ps | grep ${COMPOSE_PROJECT_NAME}_${serviceName} | awk '{print $NF}')
if [[ -n "${existingContainerName}" ]]; then
echo "${existingContainerName}"
else
# Use default name schema assigned when a compose project is first run.
echo ${COMPOSE_PROJECT_NAME}_${serviceName}_1
fi
}
create_syncs_drupal() {
local shouldOverwriteContainerData=${1}
local containerToHostSyncMode
if [[ "${shouldOverwriteContainerData}" == 1 ]]; then
containerToHostSyncMode=one-way-safe
else
containerToHostSyncMode=two-way-resolved
fi
local containerName=drupal
mutagen sync create \
--name=${SYNC_APPDATA_DRUPAL} \
--label project="${COMPOSE_PROJECT_NAME}" \
--watch-polling-interval-alpha=10 \
--watch-polling-interval-beta=120 \
--default-owner-beta=drupal \
--default-group-beta=drupal \
--sync-mode=one-way-safe \
--probe-mode=assume \
--symlink-mode=posix-raw \
--ignore-vcs \
--ignore ./config,./docker,./frontend-build,./Jenkins,*.sql,*.tar.gz \
${SYNC_APPDATA_SOURCE} docker://drupal@$(get_container_name ${containerName})/app/code
mutagen sync create \
--name=${SYNC_APPDATA_CONFIG_DRUPAL} \
--label project="${COMPOSE_PROJECT_NAME}" \
--watch-polling-interval-alpha=30 \
--watch-polling-interval-beta=30 \
--default-owner-beta=drupal \
--default-group-beta=drupal \
--sync-mode="${containerToHostSyncMode}" \
--probe-mode=assume \
--symlink-mode=posix-raw \
--ignore-vcs \
${SYNC_APPDATA_CONFIG_SOURCE} docker://drupal@$(get_container_name ${containerName})/app/code/config
mutagen sync create \
--name=${SYNC_PUBLIC_FILES_DRUPAL} \
--label project="${COMPOSE_PROJECT_NAME}" \
--watch-polling-interval=10 \
--default-owner-beta=drupal \
--default-group-beta=drupal \
--sync-mode="${containerToHostSyncMode}" \
--probe-mode=assume \
--ignore-vcs \
${SYNC_PUBLIC_FILES_SOURCE} docker://drupal@$(get_container_name ${containerName})/app/public_files
mutagen sync create \
--name=${SYNC_PRIVATE_FILES_DRUPAL} \
--label project="${COMPOSE_PROJECT_NAME}" \
--watch-polling-interval=10 \
--default-owner-beta=drupal \
--default-group-beta=drupal \
--sync-mode="${containerToHostSyncMode}" \
--probe-mode=assume \
--ignore-vcs \
${SYNC_PRIVATE_FLIES_SOURCE} docker://drupal@$(get_container_name ${containerName})/app/private_files
}
function create_syncs_nginx() {
local containerName=nginx
mutagen sync create \
--name=${SYNC_APPDATA_NGINX} \
--label project="${COMPOSE_PROJECT_NAME}" \
--watch-polling-interval-alpha=30 \
--watch-polling-interval-beta=120 \
--default-owner-beta=drupal \
--default-group-beta=drupal \
--sync-mode=one-way-safe \
--probe-mode=assume \
--symlink-mode=posix-raw \
--ignore-vcs \
--ignore ./docker,./frontend-build,./Jenkins,./vendor,*.sql,*.tar.gz \
${SYNC_APPDATA_SOURCE} docker://drupal@$(get_container_name ${containerName})/app/code
mutagen sync create \
--name=${SYNC_PUBLIC_FILES_NGINX} \
--label project="${COMPOSE_PROJECT_NAME}" \
--watch-polling-interval=10 \
--default-owner-beta=drupal \
--default-group-beta=drupal \
--sync-mode=one-way-safe \
--probe-mode=assume \
--ignore-vcs \
${SYNC_PUBLIC_FILES_SOURCE} docker://drupal@$(get_container_name ${containerName})/app/public_files
mutagen sync create \
--name=${SYNC_PRIVATE_FILES_NGINX} \
--label project="${COMPOSE_PROJECT_NAME}" \
--watch-polling-interval=10 \
--default-owner-beta=drupal \
--default-group-beta=drupal \
--sync-mode=one-way-safe \
--probe-mode=assume \
--ignore-vcs \
${SYNC_PRIVATE_FLIES_SOURCE} docker://drupal@$(get_container_name ${containerName})/app/private_files
}
function create_syncs() {
local shouldOverwriteContainerData=0
if [[ $# -gt 0 ]]; then
if [[ "$1" = 'overwite' ]]; then
shouldOverwriteContainerData=1
fi
fi
create_syncs_drupal ${shouldOverwriteContainerData}
create_syncs_nginx
}
function create_overwrite_syncs() {
create_syncs overwite
}
function overwrite_container_data() {
create_overwrite_syncs
}
function stop() {
printf '=> Pausing syncs\n'
pause_syncs
}
function check_requirements() {
if ! command -v mutagen &> /dev/null
then
printf 'Mutagen is not installed.\n'
printf 'You can install it using Homebrew:\n'
printf '$ brew install mutagen-io/mutagen/mutagen\n'
printf 'Or using an alternative method, see: https://mutagen.io/documentation/introduction/installation\n'
exit
fi
}
check_requirements
if [[ $# -gt 0 ]]; then
if [[ "$1" == 'up' ]]; then
shift 1
if [[ "${1}" == '--build' ]]; then
printf '=> Building required Docker base images\n'
build_required_docker_images
fi
if [[ $(do_containers_exist) == 0 ]]; then
printf '=> No preexisting containers were found\n'
printf '=> Starting Docker containers\n'
docker-compose up -d "$@"
printf '=> Overwriting container volumes with host data\n'
overwrite_container_data
flush_syncs
terminate_syncs
else
printf '=> Starting Docker containers\n'
docker-compose up -d "$@"
fi
if [[ $(syncs_exist) == 0 ]]; then
printf '=> Creating volume syncs\n'
create_syncs
else
printf '=> Resuming volume syncs\n'
resume_syncs
fi
docker-compose up
elif [[ "$1" == 'stop' ]]; then
stop
elif [[ "$1" == 'terminate-syncs' ]]; then
terminate_syncs
elif [[ "$1" == 'list-syncs' ]]; then
list_syncs
elif [[ "$1" == 'monitor-syncs' ]]; then
monitor_syncs
elif [[ "$1" == '--help' ]]; then
printf 'Available commands:\n'
printf ' up [options]\n'
printf ' - Creates syncs and starts Docker containers\n'
printf ' Checks if any preexisting containers exist.\n'
printf ' If not it does a one-time sync from Host to containers that overwrites any data on the containers.\n'
printf ' - Options:\n'
printf ' All options that are available for the docker-compose command.\n'
printf ' --build Additionally builds all required base Docker images\n'
printf ' stop\n'
printf ' - Pauses syncs and stops the currently cunning Docker containers\n'
printf ' terminate-syncs\n'
printf ' - Terminates the syncs created for this project.\n'
printf ' list-syncs\n'
printf ' - Displays a list of the syncs created for this project.\n'
printf ' monitor-syncs\n'
printf ' - Monitors the syncs created for this project.\n'
else
printf 'Unknown command\n'
printf 'Run "bash mutagen.sh --help" for usage\n'
fi
fi
So now I have a working Mutagen setup.
Next step. See if it actually is any good.
Comparing MacOS + Mutagen vs Ubuntu + volume mounts
I thought volume mounts are fast on Linux systems. So let’s set one up to have a comparison. Using Ubuntu. And testing with a Drupal 9 project.
Response times are averages of 50 requests.
MacOS + NFS volume mounts
Average response time: 13.0558s
This is the setup I was using for development & hoping to improve.
As you can see, response times are not great 🙁.
Our reference system: Ubuntu + volume mounts
Average response time: 9.2684s
I thought Docker on Linux was fast? Better than MacOS. But still, slow as a 🐢.
What’s going on here? 🤯🤔
MacOS + Mutagen
Average response time: 9.7394s
Better than the initial setup. Similar performance to Linux Docker. Still slow.
MacOS + NFS volume mounts, PHP xDebug extension disabled
Average response time: 1.6025s
Aha! That’s the culprit.
Conclusion
Disable xDebug when you don’t need it. I added a flag via an environment variable in my case. Disabling it when I don’t need the debugger for the time being.
Mutagen's performance compared to using volume mounts was better in my case, using MacOS. I still decided to not use Mutagen. It had drawbacks that were not worth the marginal performance improvement to me:
The CPU usage was very high when using it. Often on 100%. (Using a MacBook Pro 2019, i7). Maybe this is because I used quite a few volumes.
The delay until the files are synced is a pain when developing. When I change some code, I want to be able to instantly see the result in my web browser. The polling intervals can be configured. It was not really an option for me to make them shorter. Since my CPU was already at its limit.
FYI: Docker for Mac is also adopting Mutagen. They first added it in version 2.3.10.
What are your experiences with Mutagen? Do you use other performance optimizations for your Docker development environment?