Performant tooling with Docker and macOS

May 3, 2022

Performant tooling with Docker and macOS

Developers love performant tools, I mean, developers NEED performant tools!

I'm not going to talk about whether you should use macOS to develop.
Personally, I do, not gonna argument here.

Problem is, I use Docker, and it's sad that this sentence starts with "problem".
Docker has helped the dev community and I like this tools, I mean, I like the idea and the possibilities it offers.

But on macOS it's slow as hell.

To be honest, it's not slow in every situation but only some, unfortunately the one that are mostly the main usage of Docker in a development environment.
This is not new as you can see on this issue reported in 2016.

Issues are related to volume performance, specifically the way volumes are mounted on macOS.
And for applications which perform many I/O disk operations, it can become almost not usable.

Unfortunately, I'm in that case, being a web/symfony developper I handle projects with many dependencies/cache and thus many I/O.

I heard that Docker decided to have a look at this issue.
It took them far too much time to do so, but that's another topic.
Their effort seems to go somewhere for some people, not so much for me.

Of course, I tried every option before, and trust me on this one.
Being docker-sync (which was the best tradeoff), mutagen, VM with docker inside, and other fancy other attempts.

And today, I'm thrilled to tell you that I compiled the best of all those errands into a solution that's quite acceptable.
It is the solution I use for all my projects, and so far, it has been a pleasure again to use Docker on macOS.

This solution is heavily inspired by docker-sync and I basically removed a layer, and thus it gives more control on the whole process.
It can be resumed like this: don't use -v ever, instead make specific docker volumes and synchronize them with unison <3

Let's dive in, code is coming.

Part one: building

Installing unison local/docker

We want to install unison on our local machine, it will then connect to our container that has the mounted volume.

$ brew install unison

Then get the version, as it is important to have the same version on both the client (your local machine) and the server (the docker container with the volume).

$ unison -version
unison version 2.52.0 (ocaml 4.12.0)

Now we build a docker image with unison at the same version (both unison and ocaml version should match)

Dockerfile

FROM ubuntu:20.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        curl wget build-essential coreutils openssh-server git bash \
        inotify-tools monit supervisor rsync ruby tzdata \
    && rm -rf /var/lib/apt/lists/* /var/cache/apt/*

# Install OCAML: adapt version number from your own local setup
RUN wget http://caml.inria.fr/pub/distrib/ocaml-4.12/ocaml-4.12.0.tar.gz \
    && tar xvf ocaml-4.12.0.tar.gz -C /tmp \
    && cd /tmp/ocaml-4.12.0 \
    && ./configure \
    && make world \
    && make opt \
    && umask 022 \
    && make install \
    && make clean \
    && rm -rf /tmp/ocaml-4.12.0 \
    && rm /ocaml-4.12.0.tar.gz

# Install Unison: adapt version number from your own local setup
RUN curl -L https://github.com/bcpierce00/unison/archive/v2.52.0.tar.gz | tar zxv -C /tmp \
    && cd /tmp/unison-2.52.0 \
    # needed for < 2.51.4 with OCALM 4.12 - see https://github.com/bcpierce00/unison/pull/480
    # and https://github.com/Homebrew/homebrew-core/blob/HEAD/Formula/unison.rb#L13
    # && curl https://github.com/bcpierce00/unison/commit/14b885316e0a4b41cb80fe3daef7950f88be5c8f.patch?full_index=1 -o patch.diff \
    # && ([[ "2.52.0" == "2.51.3" ]] && git apply patch.diff); \
    # rm patch.diff \
    && sed -i -e 's/GLIBC_SUPPORT_INOTIFY 0/GLIBC_SUPPORT_INOTIFY 1/' src/fsmonitor/linux/inotify_stubs.c \
    && make UISTYLE=text NATIVE=true STATIC=true \
    && cp src/unison src/unison-fsmonitor /usr/local/bin \
    && rm -rf /tmp/unison-2.52.0

# These can be overridden later
ENV TZ="Europe/Paris" \
    LANG="C.UTF-8" \
    UNISON_DIR="/data" \
    HOME="/root"

# Install SSH packages
RUN apt-get update && \
    apt-get install openssh-server -y --no-install-recommends && \
    rm -rf /var/lib/apt/lists/* /var/cache/apt/*

RUN mkdir -p /run/sshd
RUN mkdir -p /root/.ssh

# You should insert your public key in this file
COPY authorized_keys /root/.ssh/authorized_keys

# Editing the SSHD config file
RUN echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config

# Make port 22 available to the world outside this container
EXPOSE 22

# Run SSHD when the container starts
CMD ["/usr/sbin/sshd", "-D"]

You may need to adapt a bit, see comments

Create the Docker volume and container

docker volume create --name your-project-name-volume

docker create -h your-project-name --name your-project-name-sync -it -p PORT:22 -v your-project-name-volume:/data your/image /bin/bash Replace PORT with some available port on your machine.

Part two: usage

Configuration in your docker-compose

Now, everywhere you need those project files, instead of linking the local filesystem with -v or volumes you reference your-project-name-volume instead!

docker-compose.yml

version: "3.3"
services:

    apache:
        build: docker/apache
        volumes:
            - "your-project-name-volume:/var/www"
# Instead of
#        volumes:
#            - "./:/var/www"

volumes:
    your-project-name-volume:
        external: true

Start the container and sync

docker start your-project-name-sync

cd PROJECT unison . ssh://[email protected]:PORT//data -repeat=watch -auto -batch -ignore 'Path .git' -ignore 'Path var' Replace PORT with some available port on your machine.

Of course, you can put that into a bash script:

#!/bin/bash

docker start your-project-name-sync && \
unison ./ ssh://[email protected]:PORT//data -repeat=watch -auto -batch -ignore 'Path .git' -ignore 'Path var'

Tradeoffs

This solution comes with some tradeoffs, mainly the fact that your code base is duplicated on a Docker volume, thus duplicating the disk space required. The first start will take some time too as it needs to synchronize the whole projet.

I reckon, it's a bit of configuration to do, but to me that works so well that I consider those a detail.