LXC for running problematic apps

A friend recommended Dwarf Fortress as a quality real-time strategy game with a good community and active development. Sadly, I found it hard to run the game since it's distributed as 32-bit binaries and like most games has many library dependencies. Gentoo distributes pre-baked 32-bit libs for these cases but they didn't work for me. I then launched a 32-bit Ubuntu VM. The game worked this way, but it's unsatisfying to play through an extra window, with emulation latency, and the audio broke up.

Since brushing up on LXC 1.0, I realized that this could be the perfect solution for Dwarf Fortress. I'm all set up for unprivileged containers, so I pulled up Graber's article on how to run X apps in a container. I was successful after encountering several problems, so here's how I did it.

The Plan

Graber's technique for trasferring host-unprivileged actions (e.g. normal user can run X on host) to a container-unprivileged user can be summarized:

  • Bind-mount any required special files/devices into the container.
  • Thoughtfully craft your user ID map so that the container-unprivileged uid/gid map directly to the host-unprivileged uid/gid

So, these went into my container config, taken directly from Graber's article (except /dev/video0 which I don't have and apparently don't need):

lxc.mount.entry = /dev/dri dev/dri none bind,optional,create=dir
lxc.mount.entry = /dev/snd dev/snd none bind,optional,create=dir
lxc.mount.entry = /tmp/.X11-unix tmp/.X11-unix none bind,optional,create=dir

And also this user mapping (my host-unprivileged user is 1000):

lxc.id_map = u 0 100000 1000
lxc.id_map = g 0 100000 1000
lxc.id_map = u 1000 1000 1
lxc.id_map = g 1000 1000 1
lxc.id_map = u 1001 101001 64535
lxc.id_map = g 1001 101001 64535

The effect of this is that uid/gid 1000 on the guest has all the same privileges as uid/gid 1000 on the host in terms of bind-mounted resources. There is an unexpected consequence of this mapping: user 'erik' on the guest will be able to interact with the bind-mounted resources but not user 'root' on the guest. This strange inversion of privileges applies only to those bind mounts (X socket, sound device); guest-root still trumps guest-'erik' when dealing with all other permissions in the container.

Building the system

I can now create the container. It is configured to bridge to br0.

lxc-create -f dwarf.conf.init -t download -n dwarf -- -d gentoo -r current -a i386

I start the container. It's necessary to fix up the networking manually at least the first time since the Gentoo download image doesn't DHCP.

lxc-start -n dwarf -d
lxc-attach -n dwarf ip addr add 172.21.117.80/24 dev eth0
# also add default route and fix /etc/resolv.conf
lxc-attach -n dwarf wget google.com # Prove networking success

I now enter the container through SSH instead of lxc-attach, since some things don't quite work correctly under lxc-attach (no hostname, ~ isn't expanded correctly).

Prove X

I installed xclock, so I could isolate the "use X in a container" problem from the "satisfy binary linker dependencies" problem. I got an error about "no protocol specified", and the search engines told me that I needed the xauth program. I installed xauth and imported the cookie from the host environment. On the host:

msi erik # xauth list $DISPLAY 
msi/unix:0  MIT-MAGIC-COOKIE-1  029beac9a3cecde93ffbf0007ea7a4b6

And in the guest:

erik@dwarf ~ $ export DISPLAY=:0
# Note I omitted the hostname before :0
erik@dwarf ~ $ xauth add :0  MIT-MAGIC-COOKIE-1  029beac9a3cecde93ffbf0007ea7a4b6
erik@dwarf ~ $ xclock 

And behold, a big clock appears!

Summoning dwarves

In the guest, I download the binary tarball for the game and extract it. It won't run, of course, until dependencies are satisfied.

dwarf portage # ldd /usr/src/df_linux/libs/Dwarf_Fortress 
        linux-gate.so.1 (0xf7732000)
        libSDL-1.2.so.0 => not found
        libgraphics.so => /usr/src/df_linux/libs/libgraphics.so (0xf7310000)
        libstdc++.so.6 => /usr/src/df_linux/libs/libstdc++.so.6 (0xf7233000)
        libm.so.6 => /lib/libm.so.6 (0xf71ee000)
        libgcc_s.so.1 => /usr/src/df_linux/libs/libgcc_s.so.1 (0xf71d2000)
        libc.so.6 => /lib/libc.so.6 (0xf702c000)
        libpthread.so.0 => /lib/libpthread.so.0 (0xf7011000)
        libgtk-x11-2.0.so.0 => not found
        libgobject-2.0.so.0 => /usr/lib/libgobject-2.0.so.0 (0xf6fbf000)
        /lib/ld-linux.so.2 (0xf7733000)
        libSDL-1.2.so.0 => not found
        libSDL_image-1.2.so.0 => not found
        libGLU.so.1 => not found
        libSDL_ttf-2.0.so.0 => not found
        libglib-2.0.so.0 => /usr/lib/libglib-2.0.so.0 (0xf6e83000)
        libffi.so.6 => /usr/lib/libffi.so.6 (0xf6e7c000)

It should be possible to iteratively identify and install missing packages until "not found" is "not found" in the ldd output. However, I took a shortcut by consulting an ebuild in the sunrise overlay.

less /var/lib/layman/sunrise/games-roguelike/dwarf-fortress/dwarf-fortress-0.34.05.ebuild
...

media-libs/fmod:1
media-libs/freetype
media-libs/libsdl[opengl,video,X]
media-libs/libsndfile[alsa]
media-libs/openal
media-libs/sdl-image[png,tiff,jpeg]
media-libs/sdl-ttf
sys-libs/zlib
x11-libs/cairo[xcb,X]
x11-libs/gtk+:2[xinerama]
x11-libs/libXcomposite
x11-libs/libXcursor
x11-libs/pango[X]

I tweaked some package.use settings until they installed. There was also a dependency failure that required me to manually install dev-perl/XML-Parser. At this point dynamic linking looks clean.

The chicken exit

At some point in here I learned that Dwarf Fortress can run under curses

emacs data/init/init.txt
...
Linux/OS X users may also use PRINT_MODE:TEXT for primitive ncurses output.

[PRINT_MODE:TEXT]

... but the output goes to a very small fixed area, and that's not what we're after here.

Let's try running the game:

su erik
export DISPLAY=:0
./df

...it fails because there a couple of things left to debug. One error was about being unable to load a png file, and a search engine turned up a solution which involved symlinking *.png to *.bmp. It seems that the game needs an older libpng, but it still didn't work when I tried that solution so I just went with the symlink hack.

And it worked!

Extra credit: re-using portage

So, all this took a few attempts, especially since tweaking the user map requires re-creating the container. I ran out of space on my notebook, and realized that it would be nice if the "download" template could re-use the host's portage and distfiles just like the privileged "gentoo" template does. This saves about 850M per container.

I started by deleting the contents of /usr/portage in the container. Then I restarted the container with a new bind-mount:

lxc.mount.entry = /usr/portage usr/portage none bind,optional,create=dir

A little extra user-map magic was required, since the container boosted privileges for it's 'erik' user but now I wanted the guest's 'root' user to also have privileges to write into the new bind-mount. There are probably multiple ways to achieve this, but my little brain only came up with this:

  • root on the guest is mapped to 100000.
  • This uid should have rw access to /usr/portage on the host.
  • The existing 'portage' group (gid 250) has rw access /to /usr/portage on the host
  • The guest 'portage' group should have the same gid as the host 'portage' group.

So, I created a new user on the host named 'dwarfroot' with the desired uid and added it to the 'portage' group. Also, a hole was punched in the subgid map for the guest 'portage' group, just like we did earlier with the 'erik' user in the subuid map.

/etc/subuid and /etc/subgid:

erik:1000:65536
erik:100000:65536
dwarfroot:165536:65536

And in the container config file, the map has two special-case one-id ranges for the 'portage' and 'erik' uid/gid's:

lxc.id_map = u 0 100000 250
lxc.id_map = g 0 100000 250
lxc.id_map = u 250 250 1
lxc.id_map = g 250 250 1
lxc.id_map = u 251 100251 749
lxc.id_map = g 251 100251 749
lxc.id_map = u 1000 1000 1
lxc.id_map = g 1000 1000 1
lxc.id_map = u 1001 101001 64535
lxc.id_map = g 1001 101001 64535

After restarting the container, the guest 'root' user can write files into the bind-mounted /usr/portage/distfiles as needed.

Audio

Audio isn't working, and I believe that's again a permissions issue. The files in /dev/snd/* are writable by the host's group 'audio' (gid 18) which isn't mapped. By now, this seemed like it should be simple. I punched a new hole in the gid map so that host gid 18 remained guest gid 18:

lxc.id_map = u 0 100000 250
lxc.id_map = g 0 100000 18
lxc.id_map = g 18 18 1
lxc.id_map = g 19 100019 231
lxc.id_map = u 250 250 1
lxc.id_map = g 250 250 1
lxc.id_map = u 251 100251 749
lxc.id_map = g 251 100251 749
lxc.id_map = u 1000 1000 1
lxc.id_map = g 1000 1000 1
lxc.id_map = u 1001 101001 64535
lxc.id_map = g 1001 101001 64535

I repeatedly got "newgidmap: write to gid_map failed: Invalid argument" when starting the container. Debugging, I noticed that commenting out the final lines of the map allowed the container to start. Finally I found this gem in "man user_namespaces":

There is an (arbitrary) limit on the number of lines in the
file.  As at Linux 3.18, the limit is five lines.

Gah! Too many 'g' entries! Let me collapse the system entries, at the risk of giving the guest slightly too many permissions on the files in /dev. I must also adjust /etc/subgid accordingly.

lxc.id_map = u 0 100000 250
lxc.id_map = g 0 100000 18
lxc.id_map = g 18 18 232
lxc.id_map = u 250 250 1
lxc.id_map = u 251 100251 749
lxc.id_map = g 251 100251 749
lxc.id_map = u 1000 1000 1
lxc.id_map = g 1000 1000 1
lxc.id_map = u 1001 101001 64535
lxc.id_map = g 1001 101001 64535

While this succeeded in rendering the files in /dev/snd/* as owned by 'audio' (and presumably writeable), the game still threw the same audio errors:

Picking OpenAL Soft. If your desired device was missing, make sure you have the appropred a different device, configure ~/.openalrc appropriately.
ALSA lib /var/tmp/portage/media-libs/alsa-lib-1.0.28/work/alsa-lib-1.0.28/src/confmisc.
ALSA lib /var/tmp/portage/media-libs/alsa-lib-1.0.28/work/alsa-lib-1.0.28/src/conf.c:42card_driver returned error: No such file or directory
ALSA lib /var/tmp/portage/media-libs/alsa-lib-1.0.28/work/alsa-lib-1.0.28/src/confmisc.ings
ALSA lib /var/tmp/portage/media-libs/alsa-lib-1.0.28/work/alsa-lib-1.0.28/src/conf.c:42concat returned error: No such file or directory
ALSA lib /var/tmp/portage/media-libs/alsa-lib-1.0.28/work/alsa-lib-1.0.28/src/confmisc.e
ALSA lib /var/tmp/portage/media-libs/alsa-lib-1.0.28/work/alsa-lib-1.0.28/src/conf.c:42refer returned error: No such file or directory
ALSA lib /var/tmp/portage/media-libs/alsa-lib-1.0.28/work/alsa-lib-1.0.28/src/conf.c:47ch file or directory
ALSA lib /var/tmp/portage/media-libs/alsa-lib-1.0.28/work/alsa-lib-1.0.28/src/pcm/pcm.cfault
AL lib: (EE) alsa_open_playback: Could not open playback device 'default': No such file
Initializing OpenAL failed, no sound will be played

Graber's example used PulseAudio not ALSA, so there's a good chance that something's missing in my mounted audio devices. I'm no ALSA ace, but let me try bind-mounting all of the 'audio'-owned devices.

# All audio-owned devices
lxc.mount.entry = /dev/snd dev/snd none bind,optional,create=dir
lxc.mount.entry = /dev/adsp dev/adsp none bind,optional,create=file
lxc.mount.entry = /dev/dsp dev/dsp none bind,optional,create=file
lxc.mount.entry = /dev/audio dev/audio none bind,optional,create=file
lxc.mount.entry = /dev/mixer dev/mixer none bind,optional,create=file
lxc.mount.entry = /dev/sequencer dev/sequencer none bind,optional,create=file
lxc.mount.entry = /dev/sequencer2 dev/sequencer2 none bind,optional,create=file

It didn't help. To isolate the issue, like with xclock, I found a .wav file I could test with aplay. Indeed, the error is reproducible with aplay. The venerable strace utility showed a failure to write to /dev/snd/controlC0. I hadn't added 'erik' to 'audio' in the guest also. Problem solved, and audio works.

Now, down to the mines...