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