Jake Donham > Technical Difficulties > How to run Electron on Linux on Docker on Mac

How to run Electron on Linux on Docker on Mac

2021-02-18

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:

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

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

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:

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:

The futex facility returned an unexpected error code.

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

docker: Error response from daemon: invalid mode: /tmp/.X11-unix

It 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:

EPERM: operation not permitted, copyfile '/programmable-matter/node_modules/nsfw/build/Release/nsfw.node' -> '/programmable-matter/node_modules/nsfw/bin/linux-x64-80/nsfw.node'

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.