Docker on Mac for Development with Fast Disk Access and Webpack Dev Server
May 13, 2020 📬 Get My Weekly Newsletter ☞
I am somewhat obsessed with clean, repeatable, maintainable dev environments and Docker has all the pieces to
create one. For the past several months on several projects, I’ve been using Docker (and docker-compose
) to not
only run services like Postgres and Redis, but also as a virtual machine in which to run all my dev commands. My
Mac is only running Docker and vim.
The problem is that Docker on Mac has very slow file system access. Running bin/rails server
on a fresh Rails
app can take almost a minute to come up. I recently discovered that, by using NFS, file system access is way
faster. This creates a bit of a problem for webpack-dev-server
, but this, too, can be fixed. I want to write
out what I did in case it’s helpful.
My Setup
I have a single docker-compose.yml
file that runs services like Postgres, but also runs a custom-built image for
developing my Rails app. I start all the containers in the docker-compose.yml
file and then run bash inside the
app’s container to do development. Here’s what my docker-compose.yml
file looks like:
version: "3.7"
services:
db:
image: postgres:12
environment:
POSTGRES_PASSWORD: postgres
redis:
image: redis:5
myapp-development:
image: myapp-development
entrypoint: "tail -f /dev/null"
ports:
- "9001:3000"
- "8001:3035"
volumes:
- 'nfsmount:/root/apps'
- type: volume
target: /usr/dependencies-cache
volumes:
nfsmount:
driver: local
driver_opts:
type: nfs
o: addr=host.docker.internal,rw,nolock,hard,nointr,nfsvers=3
device: ":/Users/davec/Projects/NewCo"
A few notes:
- The reason
myapp-development
is runningtail
as theentrypoint:
is that the container needs to run something, but I ultimately intended to log into it, so tailing nothing works. I then usedocker exec -it «container_id» bash -l
to “log in”. From there I can run my various dev commands likebin/rails test
or whatever. webpack-dev-server
runs on port 3035, which I’m mapping to8001
. We’ll see the Webpack side of that config in a bit.- The
/usr/dependencies-cache
is part of a custom base image I use. That base image installs a default set of Ruby Gems and NPM modules into that location. It’s got nothing to do with NFS but wanted to point it out to avoid confusion. - The
nfsmount
volume is what this post is about. Will explain in a sec.
To make this work, you have to set up NFS on your Mac
Set up NFS on your Mac
First, edit /etc/exports
, which lists all the directories to export over NFS:
sudo vim /etc/exports
You can use any editor you like instead of vim
. Then add this line:
/Users/davec/Projects/NewCo -alldirs -mapall=501:20 localhost
Here’s what’s going on here (I will admit I cargo-culted this line and finding out what these options do is…not easy):
- The first bit is the path to the directory to export.
- The
-alldirs
, I believe allows you to mount subdirectories of the exported directory if you like. - The
-mapall=501:20
is specifying the user and group. Typeid
at a terminal prompt to see your user ID and groups. I believe the default on macs is that your user is 501 and in group 20, which is staff. - The
localhost
part allows the path to be mounted on the same host exporting it, which is what has to happen.
Next, edit /etc/nfs.confg
so it has this line:
nfs.server.mount.require_resv_port = 0
By default, NFS only allows mount requests from “privileged” ports, which are ports below 1024. Docker is not
going to use these ports, so we set the verbose-yet-still-abbreviated require_resv_port
option to 0
, which
means “allow any ports”. Bet you don’t think YAML is so bad now, do you?
Then, restart nfsd
(which is running NFS) so all this config is read in:
sudo nfsd restart
Now that that’s done, we use this in Docker.
Use NFS in Docker
Here’s the part of the docker-compose.yml
above that’s relevant:
services:
# «snip»
myapp-development:
# «snip»
volumes:
- 'nfsmount:/root/apps'
«snip»
volumes:
nfsmount:
driver: local
driver_opts:
type: nfs
o: addr=host.docker.internal,rw,nolock,hard,nointr,nfsvers=3
device: ":/Users/davec/Projects/NewCo"
The bit inside myapp-development
says that there is a volume named nfsmount
that will be available inside the
Docker container as /root/apps
. Anything in ~/Projects/NewCo
on my Mac will be in /root/apps
in the Docker
container.
The volumes:
key contains an nfsmount:
key that describes what the nfsmount
volume actually is. Here be
dragons.
First, we use the local
driver for reasons I don’t understand. Next we give driver_opts
, which is highly
dependent on the value for type:
. The entire line with o:
is arcane. I have no idea what it’s doing, why or if
it must be o:
or anything. I believe this are options to the NFS process running in the Docker container.
The device:
is what’s critical. It must have that leading colon and then the path you used in /etc/exports
(though it can be a subdir of it, too).
With that in place, you can docker-compose up
, then docker exec -it «container_id» bash -l
into your app’s
container. If you ls /root/apps
you’ll see your Mac’s disk. And it will be almost as fast as local!
This does break webpack-dev-server
, however.
Making webpack-dev-server
work
By default, webpack-dev-server
uses file system events to know when to rebuild. An NFS-mounted volume, like the
one we just created, does not supply such events. So we must modify our Webpack config to poll.
The options needed are documented in Webpack’s watchOptions
. Specifically, you must set poll
to either true
or a number of milliseconds. I set it to 300.
If you are using Webpacker in Rails, you have to modify this in config/webpacker.yml
(more YAML):
default: &default
# «snip»
development:
<<: *default
compile: true
check_yarn_integrity: true
dev_server:
# «snip»
host: 0.0.0.0
port: 3035
public: localhost:8000
watch_options:
ignored: '**/node_modules/**'
poll: 300
aggregateTimeout: 200
While it’s not part of making NFS work, note the host:
and public:
values above. host:
must be 0.0.0.0 to
work inside Docker. Since your browser on your computer will connect to webpack-dev-server
, it needs a localhost
URL. We mapped 3035 to 8000 in docker-compose.yml
, so for public:
we have to put localhost:8000
since
that’s how the browser will access webpack-dev-server
.
The watch_options
are what make this work on NFS. With poll:
and aggregateTimeout
set, webpack-dev-server
will check every 300ms for changed files, and wait 200ms to allow other changes to be found before rebuilding. The rebuild itself is pretty fast, but I find the polling to be quite flaky. Not sure why. Nevertheless, it’s way better than when using Docker’s macOS file system support.
Conclusion
Having a repeatable, reliable dev environment can be a boon to team productivity, and Docker is a good tool to make that happen. It is slower than local development, but using an NFS-mounted filesystem can make it much better.
Other than the newly-discovered NFS mount, I have been doing development in Docker for the past several months and
it’s really nice to not have to worry about macOS updates breaking everything. I can run bin/setup
and am good
to go.