Sunday, August 28, 2022

LXD Containers for Wayland GUI Apps

Having upgraded my home computers to Ubuntu 22.04, which features the latest version of LXD (5.5) via Snap, and using Wayland (via the Sway window manager), I spent some time working out how to run Wayland-native GUI apps in an LXD container. With the help of a few posts (Running X11 Software in LXD Containers, GUI Application via Wayland From Ubuntu LXD Container on Arch Linux Host, and Howto Use the Host's Wayland and XWayland Servers Inside Containers), I was able to get this working quite nicely.

Basic Profile

Most apps I tried, like LibreOffice or Eye of Gnome, worked with this basic LXD container profile (for Ubuntu 22.04 container images):

config: boot.autostart: false user.user-data: | #cloud-config write_files: - path: /usr/local/bin/mystartup.sh permissions: 0755 content: | #!/bin/sh uid=$(id -u) run_dir=/run/user/$uid mkdir -p $run_dir && chmod 700 $run_dir && chown $uid:$uid $run_dir ln -sf /mnt/wayland-socket $run_dir/wayland-0 - path: /usr/local/etc/mystartup.service content: | [Unit] After=local-fs.target [Service] Type=oneshot ExecStart=/usr/local/bin/mystartup.sh [Install] WantedBy=default.target runcmd: - mkdir -p /home/ubuntu/.config/systemd/user/default.target.wants - ln -s /usr/local/etc/mystartup.service /home/ubuntu/.config/systemd/user/default.target.wants/mystartup.service - ln -s /usr/local/etc/mystartup.service /home/ubuntu/.config/systemd/user/mystartup.service - chown -R ubuntu:ubuntu /home/ubuntu - echo 'export WAYLAND_DISPLAY=wayland-0' >> /home/ubuntu/.profile description: Basic Wayland Jammy devices: eth0: name: eth0 network: lxdbr0 type: nic root: path: / pool: default type: disk wayland-socket: bind: container connect: unix:/run/user/1000/wayland-1 listen: unix:/mnt/wayland-socket uid: 1000 gid: 1000 type: proxy

It binds the host's Wayland socket (/run/user/1000/wayland-1) to the container at /mnt/wayland-socket, via the wayland-socket device config. Via its cloud config user data, it sets up a systemd service in the container that will run when the ubuntu user logs in, and link the Wayland socket to its usual location in the container (/run/user/1000/wayland-0). This cloud config also adds the WAYLAND_DISPLAY variable to the ubuntu user's .profile, ensuring that Wayland-capable apps will try to access the Wayland socket at that location.

(Note that you may be using a different user ID or Wayland socket number on your own host; run ls /run/user/*/wayland-? to check. If so, change the connect: unix:/run/user/1000/wayland-1 line above to match the actual location of your Wayland socket.)

To set up a profile like this, save it as a file like wayland-basic.yml on the host. Create a new profile with the following command:

$ lxc profile create wayland-basic

And then update the profile with the file's content:

$ cat wayland-basic.yml | lxc profile edit wayland-basic

You can continue to edit the profile and update it with the same lxc profile edit command; LXD will apply your changes to existing containers which use the profile. You can view the latest version of the profile with the following command:

$ lxc profile show wayland-basic

With this profile set up, you can launch a new Ubuntu 22.04 container from it using the following command (the last argument, mycontainer, is the name to use for the new container):

$ lxc ubuntu:22.04 --profile wayland-basic mycontainer

Once launched, you can log into an interactive terminal session on the container as the ubuntu user with the following command:

$ lxc exec mycontainer -- sudo -u ubuntu -i

Once logged in, you can install apps into the container, like to install LibreOffice Writer (the LibreOffice alternative to Microsoft Word):

ubuntu@mycontainer:~$ sudo apt update ubuntu@mycontainer:~$ sudo apt install libreoffice-gtk3 libreoffice-writer

Then you can run the app, which should open up in a native Wayland window:

ubuntu@mycontainer:~$ libreoffice

Sharing Folders

This basic profile doesn't have access to the host's filesystem, however. To allow the container to access a specific directory on the host, run the following command on the host:

$ lxc config device add mycontainer mymount disk source=/home/me/Documents/myshare path=/home/ubuntu/mydir

This will mount the source directory from the host (/home/me/Documents/myshare) at the specified path in the container (/home/ubuntu/mydir). LXD's name for the device within the container will be mymount — you can use the device's name in combination with the container's own name to edit or remove the device; and you can mount additional directories if you give each mount device a unique name within the container.

Our basic profile allows only read access to the mounted directory within the container, however, as the directory will be mounted with the nobody user as its owner. To change the owner to the ubuntu user (so you can write to the directory from within the container), shut down the container, change the user ID mapping for its mounts, and then start the container back up again:

$ lxc stop mycontainer $ lxc config set mycontainer raw.idmap='both 1000 1000' $ lxc start mycontainer $ lxc exec mycontainer -- sudo -u ubuntu -i ubuntu@mycontainer:~$ ls -l mydir

The mounted mydir directory and its contents will now be owned by the ubuntu user, with full read and write access. (If you need to map a host user or group with an ID other than 1000 to the container's ubuntu user, you can do so with the uid and gid directives instead of the both directive; see the LXD idmap documentation for details.)

If you want to use these same settings for all containers that use the same profile, you can add these settings directly to the profile's config:

config: boot.autostart: false raw.idmap: both 1000 1000 user.user-data: | #cloud-config write_files: - path: /usr/local/bin/mystartup.sh permissions: 0755 content: | #!/bin/sh uid=$(id -u) run_dir=/run/user/$uid mkdir -p $run_dir && chmod 700 $run_dir && chown $uid:$uid $run_dir ln -sf /mnt/wayland-socket $run_dir/wayland-0 - path: /usr/local/etc/mystartup.service content: | [Unit] After=local-fs.target [Service] Type=oneshot ExecStart=/usr/local/bin/mystartup.sh [Install] WantedBy=default.target runcmd: - mkdir -p /home/ubuntu/.config/systemd/user/default.target.wants - ln -s /usr/local/etc/mystartup.service /home/ubuntu/.config/systemd/user/default.target.wants/mystartup.service - ln -s /usr/local/etc/mystartup.service /home/ubuntu/.config/systemd/user/mystartup.service - chown -R ubuntu:ubuntu /home/ubuntu - echo 'export WAYLAND_DISPLAY=wayland-0' >> /home/ubuntu/.profile description: Myshare Wayland Jammy devices: eth0: name: eth0 network: lxdbr0 type: nic mymount: source: /home/me/Documents/myshare path: /home/ubuntu/mydir type: disk root: path: / pool: default type: disk wayland-socket: bind: container connect: unix:/run/user/1000/wayland-1 listen: unix:/mnt/wayland-socket uid: 1000 gid: 1000 type: proxy

Launcher Script

When an LXD container is running, you don't have to log into it via a terminal session to launch an application in it — you can launch the application directly from the host. The following command will launch LibreOffice directly from the host:

$ lxc exec mycontainer -- sudo -u ubuntu -i libreoffice

So save the following as a shell script on the host (eg mycontainer-libreoffice.sh) and make it executable (eg chmod +x mycontainer-libreoffice.sh), and then you can simply run the script any time you want to launch libreoffice in mycontainer:

#/bin/sh lxc info mycontainer 2>/dev/null | grep RUNNING >/dev/null || (lxc start mycontainer; sleep 2) lxc exec mycontainer -- sudo -u ubuntu -i libreoffice

(Note that if you did not add the WAYLAND_DISPLAY variable to the user's .profile file, or if you added it to the user's .bashrc file instead of .profile, you'll need to include this variable in the launch command like this: lxc exec mycontainer -- sudo WAYLAND_DISPLAY=wayland-0 -u ubuntu -i libreoffice .)

AppArmor Issues

Some Wayland-capable GUI apps may fail to run inside an LXD container due to issues with the app's AppArmor profile; but you may be able to work-around it by adjusting the profile. One such app I've encountered is Evince.

A good way to check for AppArmor issues is by tailing the syslog, and filtering on its audit identifier, like with the following command:

$ journalctl -t audit -f

Access denied by AppArmor will look like this:

Aug 25 19:30:07 jp audit[99194]: AVC apparmor="DENIED" operation="connect" namespace="root//lxd-mycontainer_<var-snap-lxd-common-lxd>" profile="/usr/bin/evince" name="/mnt/wayland-socket" pid=99194 comm="evince" requested_mask="wr" denied_mask="wr" fsuid=1001000 ouid=1001000

In the case of Evince, I found I could work-around it by adjusting the container's own AppArmor profile for Evince. Run the following commands in the container to grant Evince read/write access to the Wayland socket:

ubuntu@mycontainer:~$ echo '/mnt/wayland-socket wr,' | sudo tee -a /etc/apparmor.d/local/usr.bin.evince ubuntu@mycontainer:~$ sudo apparmor_parser -r /etc/apparmor.d/usr.bin.evince

The first command adds a line to the user-managed additions of the Evince AppArmor policy (which is usually empty); the second command reloads the packaged version of the policy (a different file), which references the user-managed additions via an include statement.

Browser Quirks

Unfortunately, Firefox and Chromium don't work with the LXD-proxied Wayland socket (at least the Snap-packaged Ubuntu versions of Firefox and Chromium don't). But fortunately, they do work (mostly) when the Wayland socket is shared with them via disk mount.

If you create a new profile like the following, with a disk mount used to share the Wayland socket instead of a network proxy, you can keep the startup script the same as before:

config: boot.autostart: false raw.idmap: both 1000 1000 user.user-data: | #cloud-config write_files: - path: /usr/local/bin/mystartup.sh permissions: 0755 content: | #!/bin/sh uid=$(id -u) run_dir=/run/user/$uid mkdir -p $run_dir && chmod 700 $run_dir && chown $uid:$uid $run_dir ln -sf /mnt/wayland-socket $run_dir/wayland-0 - path: /usr/local/etc/mystartup.service content: | [Unit] After=local-fs.target [Service] Type=oneshot ExecStart=/usr/local/bin/mystartup.sh [Install] WantedBy=default.target runcmd: - mkdir -p /home/ubuntu/.config/systemd/user/default.target.wants - ln -s /usr/local/etc/mystartup.service /home/ubuntu/.config/systemd/user/default.target.wants/mystartup.service - ln -s /usr/local/etc/mystartup.service /home/ubuntu/.config/systemd/user/mystartup.service - chown -R ubuntu:ubuntu /home/ubuntu - echo 'export WAYLAND_DISPLAY=wayland-0' >> /home/ubuntu/.profile description: Browser Wayland Jammy devices: eth0: name: eth0 network: lxdbr0 type: nic root: path: / pool: default type: disk wayland-socket: source: /run/user/1000/wayland-1 path: /mnt/wayland-socket type: disk

Save this profile as a file like wayland-browser.yml. Create a new profile for it, and update the profile from the file's content:

$ lxc profile create wayland-browser $ cat wayland-browser.yml | lxc profile edit wayland-browser

Launch an Ubuntu 22.04 container with it, log into it, and install a browser:

$ lxc ubuntu:22.04 --profile wayland-basic myfirefox $ lxc exec myfirefox -- sudo -u ubuntu -i ubuntu@myfirefox:~$ sudo snap install firefox

Once installed, you should be able to start up the browser and have it open in a new Wayland window:

ubuntu@myfirefox:~$ firefox

Using a disk mount instead of a network proxy to share the Wayland socket seems much more flaky, however. I find that I'm not always able to start Firefox back up after quitting from it if I leave its LXD container running (especially if I put the computer to sleep in between quitting and starting again). Also, Firefox's "crash reporter" window, when it appears, seems to trigger a new crash, resulting in a continuous loop of crashes.

So now I always stop and restart the browser's LXD container before starting a new browser session (and I disable the crash reporter). This is what I use for my Firefox launcher script:

#/bin/sh lxc info myfirefox 2>/dev/null | grep STOPPED >/dev/null || lxc stop myfirefox lxc start myfirefox sleep 3 lxc exec myfirefox -- sudo MOZ_CRASHREPORTER_DISABLE=1 -u ubuntu -i firefox

And this for my Chromium launcher:

#/bin/sh lxc info mychromium 2>/dev/null | grep STOPPED >/dev/null || lxc stop mychromium lxc start mychromium sleep 3 lxc exec mychromium -- sudo -u ubuntu -i chromium --ozone-platform=wayland

Also, there are a few facets of the browsers that still don't work under this regime — in particular, open/save file dialogs don't appear when you try to download/upload files.

PulseAudio Output

To output audio from an LXD container, bind the host's PulseAudio socket (/run/user/1000/pulse/native) to the container at /mnt/pulse-socket, similar to the original Wayland socket:

config: boot.autostart: false raw.idmap: both 1000 1000 user.user-data: | #cloud-config write_files: - path: /usr/local/bin/mystartup.sh permissions: 0755 content: | #!/bin/sh uid=$(id -u) run_dir=/run/user/$uid mkdir -p $run_dir && chmod 700 $run_dir && chown $uid:$uid $run_dir ln -sf /mnt/wayland-socket $run_dir/wayland-0 mkdir -p $run_dir/pulse && chmod 700 $run_dir/pulse && chown $uid:$uid $run_dir/pulse ln -sf /mnt/pulse-socket $run_dir/pulse/native - path: /usr/local/etc/mystartup.service content: | [Unit] After=local-fs.target [Service] Type=oneshot ExecStart=/usr/local/bin/mystartup.sh [Install] WantedBy=default.target runcmd: - mkdir -p /home/ubuntu/.config/systemd/user/default.target.wants - ln -s /usr/local/etc/mystartup.service /home/ubuntu/.config/systemd/user/default.target.wants/mystartup.service - ln -s /usr/local/etc/mystartup.service /home/ubuntu/.config/systemd/user/mystartup.service - chown -R ubuntu:ubuntu /home/ubuntu - echo 'export WAYLAND_DISPLAY=wayland-0' >> /home/ubuntu/.profile description: Pulse Wayland Jammy devices: eth0: name: eth0 network: lxdbr0 type: nic root: path: / pool: default type: disk pulse-socket: bind: container connect: unix:/run/user/1000/pulse/native listen: unix:/mnt/pulse-socket uid: 1000 gid: 1000 type: proxy wayland-socket: source: /run/user/1000/wayland-1 path: /mnt/wayland-socket type: disk

Update the startup script to link the PulseAudio socket to its usual location in the container (/run/user/1000/pulse/native) when the ubuntu user logs in, just like we did for the Wayland socket. (Note that the mystartup.sh script's content from the cloud config of this profile is applied only when the container is first created, so you have to manually edit it in any containers that you've already created if you want to update them, too.)

Useful Commands

If you are just getting started with LXD containers, here are a few more useful commands that are good to know:

  • lxc ls: Lists all LXD containers.
  • lxc snapshot mycontainer mysnapshot: Creates a snapshot of mycontainer named mysnapshot.
  • lxc restore mycontainer mysnapshot: Restores mycontainer to the mysnapshot snapshot.
  • lxc delete mycontainer: Deletes mycontainer.
  • lxc storage info default: Shows the space used and available in the default storage pool.
  • lxc config show mycontainer: Shows the container-customized config settings for mycontainer.
  • lxc config show mycontainer -e: Shows all config settings for mycontainer (including those inherited from its profiles).