How to run Electron on Linux on Docker on Mac
My co-Recurser Hazem tried running Programmable Matter on Linux, and it didn’t work very well (I had previously run it only on my Mac). So the past few days I have been getting it running on Linux on Docker in order to fix it. I had not used Docker before so this was an adventure! I learned a lot.
Docker lets you run virtual computers (I’ll call them guests, although this is not official Docker terminology) inside your computer (the host), and guests can run different operating systems from the one the host runs. You can use it to develop and test software on other OSes (or different versions of your host OS); or to bundle up a bunch of software packages so users can install and uninstall them all together; or to run programs in a restricted environment so they can’t damage the host.
I have a vague idea how this all works but I still find it extremely magical! Docker seems to be very well put together and everything worked really smoothly for me. Nonetheless I ran into a lot of puzzling stuff and did a whole lot of Googling to get it working. Mostly why this was complicated is that Programmable Matter is based on Electron, which makes some unusual demands of the operating system it runs on.
Here is what I needed to do at a high level:
- learn about Docker. I went through the getting started tutorial, it was great.
- build an image containing the base Linux installation and any extra libraries needed to run my app. (To run a guest computer you load an image into a container.)
- set things up so that the app running on the guest can display a window on my Mac.
- figure out some Docker security stuff.
- run the app!
Building a Docker image
To build an image, you write a Dockerfile, which is a script of actions that
modify the guest computer’s storage; when the script finishes, the storage is
snapshotted into a file on the host. Here’s the one I wrote (with explanations
interpolated):
FROM node:14
This line names a base image to start with. Docker has a
repository of images of various base operating
systems and add-ons. This one is a Debian image with Node.js version 14
installed (see https://hub.docker.com/_/node).
There are a bunch of choices here: for example, the sample app in the tutorial
uses an image with Node on top of Alpine Linux, which is a lot smaller than
Debian. (I picked node:14 because the smaller ones sounded from the docs like
they might not work for me, but I should go back and try it.)
From this point on in the Dockerfile you can run ordinary shell commands in
the context of the guest:
# stuff needed to get Electron to run
RUN apt-get update && apt-get install \
git libx11-xcb1 libxcb-dri3-0 libxtst6 libnss3 libatk-bridge2.0-0 libgtk-3-0 libxss1 libasound2 \
-yq --no-install-suggests --no-install-recommends \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
The base images are typically pretty stripped down compared to a normal OS
installation. In particular, node:14 is missing a bunch of libraries that
Electron depends on for rendering the display. I found these by trying to start
the app, getting an error like
libX11-xcb.so.1: cannot open shared object file: No such file or directory
searching for the missing library at
https://www.debian.org/distrib/packages#search_contents,
and adding the matching package. (Git is needed by
electron-forge, which I’m using to run my
app.)
# Electron doesn't like to run as root
RUN useradd -d /programmable-matter programmable-matter
USER programmable-matter
If you try to run Electron as root you get
Error: Running as root without --no-sandbox is not supported.so we need to add a user (more about --no-sandbox below). The USER command
changes to the given user for subsequent commands.
WORKDIR /programmable-matter
COPY . .
RUN npm install
RUN npx electron-rebuild
The WORKDIR command sets the working directory for subsequent commands, then
COPY copies a file tree containing the app code from the host filesystem to
the guest filesystem. This includes package.json, so we can run npm install.
We also run electron-rebuild which builds some native code modules to match
the installed Electron version. (Electron-rebuild is run by electron-forge
on launch if we don’t do it here, but that causes a Docker security issue, see
below; and also makes startup slower, so I moved it here.)
# Electron needs root for sandboxing
# see https://github.com/electron/electron/issues/17972
USER root
RUN chown root /programmable-matter/node_modules/electron/dist/chrome-sandbox
RUN chmod 4755 /programmable-matter/node_modules/electron/dist/chrome-sandbox
Without this the app errors out with
Error: The SUID sandbox helper binary was found, but is not configured correctly.As far as I understand things: Electron is based on Chromium, which has a fancy
multi-process architecture
in which some processes are run with restricted permissions (“sandboxed”).
Sandboxing can be done with a Linux kernel facility called “user namespaces”,
but this facility is not enabled on some Linux distributions, so there is a
workaround using a setuid helper program. However npm install can’t set the
setuid bit without root permission, and the Electron package
chooses not to ask for it,
so we need to do it explicitly. (I’m not sure whether user namespaces can be
enabled on the Debian base image I started with, but I think it may use an older
kernel version and this is a fairly recent feature.)
USER programmable-matter
CMD npm run start
Now we are ready to build the image with
docker build -t programmable-matter .
and run it with
docker run programmable-matter
But we get an error:
Error: Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted.XQuartz and DISPLAY
I like long specific error messages like this, they are easy to Google. I found
this, which suggests either
passing the --no-sandbox argument or setting up a custom seccomp
configuration. I was feeling tired and not very interested in setting up a
custom configuration for something I had never heard of, so I tried
--no-sandbox, which got me past the namespace error and on to a new one:
Not very helpful—I was expecting this to be hard to Google, but
this Electron issue was the
second hit. Turns out I needed to set the DISPLAY environment variable so
Electron knows where to render its windows, using
X, the standard windowing
system on Linux.
My Mac is running an implementation of the X display server, XQuartz, which can receive connections from X programs and render their windows. When you’re running an X client and server on the same computer they normally connect over a Unix socket for performance (and also for security). I tried to mount the X Unix socket from the host to the guest, following this post, but I got several variations of
Error: docker: Error response from daemon: invalid mode: /tmp/.X11-unixIt turns out that mounting arbitrary Unix sockets into a container is
not supported on Docker for Mac.
Instead (following these
instructions)
I set an XQuartz preference to allow connections from network clients, ran
xhost +localhost to allow connections from localhost, and ran docker like
so:
docker run -e DISPLAY=host.docker.internal:0 programmable-matter
It works! The guest OS can reach network ports on the host at
host.docker.internal, and -e DISPLAY= passes the environment variable to the
CMD in the Dockerfile. I also needed to run xhost +localhost so XQuartz
will accept the connection. This is not secure for general use (XQuartz trusts
the IP address of the incoming connection), but it seems OK for local use.
Docker and seccomp
After a rest on the fainting couch, I decided to try to set up seccomp in
order to get rid of the --no-sandbox argument. I am not sure how much
practical value this has, since the whole app is running in a container, but
maybe it prevents dangerous access between app processes in the container. In
any case --no-sandbox seems like an escape-hatch flag that should be avoided.
So following these instructions I
tried running the container like so:
docker run -e DISPLAY=host.docker.internal:0 --security-opt seccomp=chrome.json programmable-matter
where
chrome.json
is from Jessie Frazelle’s dotfiles (see
also
how it was made).
As far as I understand things,
seccomp configures a list
of Linux system calls that programs running on the guest are allowed to call,
and chrome.json includes the namespace calls Chromium uses for sandboxing.
But:
The file in question is part of a native library I’m using,
NSFW, and the error comes up while
electron-forge is running electron-rebuild. I thought that copyfile was
the name of a system call so I tried adding it to chrome.json, but that didn’t
work. I did find
copy_file_range,
however, and adding that one worked! I spent some time digging to find where the
string copyfile comes from (electron-forge calls electron-rebuild calls
node-gyp which I think generates and runs a Makefile) but gave up.
Finally I realized that electron-rebuild can be run at image build time
instead of container run time; now the original chrome.json works. So I guess
there is no seccomp restriction at image build time.
The end
Truly, friends, we live in a time of wonders! Please email me with comments, criticisms, corrections.