↓ Skip to main content

From Virtual to Virtualized: Enabling GPU Hardware Acceleration in containerd/xrdp Containers

·1751 words·9 mins
Max Severance
Author
Max Severance
Architecting fabric convergence at hyperscale.
Table of Contents

From Virtual to Virtualized: Enabling GPU Hardware Acceleration in containerd/xrdp Containers
#

In this comprehensive guide, I’ll walk you through the complete process of adding GPU passthrough to a containerd container using nerdctl while preserving state, configuring xrdp for hardware-accelerated graphics, and baking everything into a reproducible Dockerfile.

TL;DR
#

  • Commit the container to save state to an image, then recreate with GPU devices
  • Pass both /dev/dri/card0 and /dev/dri/renderD128 to the container
  • Configure xorg.conf with DRMDevice, DRI3, and DRMAllowList options
  • Add user to video and render groups for device access
  • Hardware acceleration confirmed: ~12,000 FPS with glxgears (vs ~60 FPS with llvmpipe)

Understanding the Starting Point
#

The user had a running containerd container (ID: 6d5b3c01f38a) built from locals.tf/xrdp:latest. This container was running xrdp for remote desktop access and needed GPU passthrough for hardware acceleration.

Let’s first inspect the container to understand its current configuration:

sudo nerdctl inspect 6d5b3c01f38a

The inspection reveals important details about the container:

  • Image: locals.tf/xrdp:latest
  • Runtime: io.containerd.runc.v2
  • Driver: overlayfs
  • Port Binding: 3389/tcp (RDP port)
  • Mounts: /data bound to /data

The container was running with these key configurations:

  • Network: Bridge with IP 10.4.0.29
  • User: user
  • Cgroup namespace: private

Preserving Container State with nerdctl commit
#

When you need to modify a container while preserving its state, the recommended approach is to commit the container to a new image. This captures all filesystem changes, installed packages, and configurations.

Step 1: Commit the Running Container
#

First, commit the current container to a new image that will preserve all its state:

sudo nerdctl commit 6d5b3c01f38a xrdp-with-dri

This creates a new image xrdp-with-dri that contains all the current state of the container—including installed packages, configuration files, user data, and any modifications made inside the container.

Step 2: Run a New Container with GPU Passthrough
#

Now run a new container from the committed image with the GPU device passed through:

sudo nerdctl run -d \
  --name xrdp \
  --device /dev/dri/card0 \
  -p 3389:3389 \
  -v /data:/data \
  xrdp-with-dri

Explanation: The --device /dev/dri/card0 flag passes the GPU device from the host to the container, enabling direct hardware access. The -v /data:/data bind mount ensures any data in /data persists separately from the container lifecycle.


Handling Port Conflicts
#

The Problem: Port Already Allocated
#

When the user tried to run the new container while keeping the old one (as a failsafe), they encountered:

FATA[0000] failed to load networking flags: bind for 0.0.0.0:3389 failed: port is already allocated

This happened because the old container was still holding the port binding.

Checking Container Status
#

sudo nerdctl ps -a | grep 6d5b3c01f38a

Output:

6d5b3c01f38a    locals.tf/xrdp:latest    "/home/user/start-kd…"    6 days ago    Exited (137) 2 minutes ago    0.0.0.0:3389->3389/tcp    xrdp

The container shows “Exited” but the port was still allocated.

Finding the Zombie Process
#

sudo ss -tlnp | grep 3389

Output:

LISTEN 1      4096    0.0.0.0:3389    0.0.0.0:*    users:(("sleep",pid=4021562,fd=3))

Root Cause: Containerd uses a “pause” container (often a sleep infinity process) to hold the network namespace alive. When the container stops, sometimes these zombie processes aren’t properly cleaned up.

Solution: Kill the Zombie Process
#

sudo kill 4021562 && sudo ss -tlnp | grep 3389

After killing the zombie process, port 3389 was freed and the new container could start successfully.


Verifying Hardware Acceleration
#

Checking GPU Device Access
#

First, verify the GPU device is available inside the container:

sudo nerdctl exec xrdp-dri ls -la /dev/dri/

Output:

total 0
drwxr-xr-x 2 root root      60 Mar  3 00:25 .
drwxr-xr-x 6 root root     360 Mar  3 00:25 ..
crw-rw---- 1 root video 226, 0 Mar 3 00:25 card0

The device /dev/dri/card0 is present. To get hardware acceleration, we also need the render node.

Checking Host GPU
#

On the host, check what GPU is available:

lspci | grep -i vga

Output:

00:02.0 VGA compatible controller: Intel Corporation Raptor Lake-S GT1 [UHD Graphics 770] (rev 04)

Vendor ID 0x8086 confirms Intel UHD Graphics 770 (Raptor Lake).

Running glxinfo to Check Rendering
#

The X server runs on a specific display number. First, find the X server process:

sudo nerdctl exec xrdp-dri ps aux | grep X

Output:

user  48  0.0  0.0 301088 94288 ?  Sl   00:25   0:00 /usr/lib/xorg/Xorg :11 -auth .Xauthority -config xrdp/xorg.conf -noreset -nolisten tcp -logfile .xorgxrdp.%s.log

The X server is on display :11, not :0. Now run glxinfo:

DISPLAY=:11 glxinfo | grep "OpenGL renderer"

Initial output (without hardware acceleration):

OpenGL renderer string: llvmpipe (LLVM 21.1.8, 256 bits)

llvmpipe indicates software rendering—the GPU is accessible as a device, but the X server isn’t using it for rendering.


Configuring xrdp for Hardware Acceleration
#

Initial xorg.conf Issues
#

The log showed several problems:

rdpPreInit: /dev/dri/renderD128 open failed
AIGLX: Screen 0 is not DRI2 capable
GLX: Initialized DRISWRAST GL provider for screen 0
  1. The render node wasn’t available
  2. No DRI2 support
  3. Using software renderer (DRISWRAST)

Updated xorg.conf Configuration
#

The user configured /etc/X11/xrdp/xorg.conf with proper GPU settings:

Section "ServerLayout"
    Identifier "X11 Server"
    Screen "Screen (xrdpdoc)"
    InputDevice "xrdpMouse" "CorePointer"
    InputDevice "xrdpKeyboard" "CoreKeyboard"
EndSection

Section "ServerFlags"
    Option "DefaultServerLayout" "X11 Server"
    Option "DontVTSwitch" "on"
    Option "AutoAddDevices" "off"
    Option "AutoAddGPU" "off"
EndSection

Section "Module"
    Load "dbe"
    Load "ddc"
    Load "extmod"
    Load "glx"
    Load "int10"
    Load "record"
    Load "vbe"
    Load "glamoregl"
    Load "xorgxrdp"
    Load "fb"
EndSection

Section "InputDevice"
    Identifier "xrdpKeyboard"
    Driver "xrdpkeyb"
EndSection

Section "InputDevice"
    Identifier "xrdpMouse"
    Driver "xrdpmouse"
EndSection

Section "Monitor"
    Identifier "Monitor"
    Option "DPMS"
    HorizSync 30-80
    VertRefresh 60-75
    ModeLine "1920x1080" 138.500 1920 1968 2000 2080 1080 1083 1088 1111 +hsync -vsync
    ModeLine "1280x720" 74.25 1280 1720 1760 1980 720 725 730 750 +HSync +VSync
    Modeline "1368x768" 72.25 1368 1416 1448 1528 768 771 781 790 +hsync -vsync
    ModeLine "1600x900" 119.00 1600 1696 1864 2128 900 901 904 932 -hsync +vsync
EndSection

Section "Device"
    Identifier "Video Card (xrdpdoc)"
    Driver "xrdpdoc"
    Option "DRMDevice" "/dev/dri/renderD128"
    Option "DRI3" "1"
    Option "DRMAllowList" "i915 radeon"
EndSection

Section "Screen"
    Identifier "Screen (xrdpdoc)"
    Device "Video Card (xrdpdoc)"
    GPUDevice ""
    Monitor "Monitor"
    DefaultDepth 24
    SubSectionSub "Display"
        Depth 24
        Modes "640x480" "800x600" "1024x768" "1280x720" "1280x1024" "1600x900" "1920x1080"
    EndSubSection
EndSection

Key Configuration Options:

  • Driver "xrdpdoc": The xrdp device driver
  • Option "DRMDevice" "/dev/dri/renderD128": Points to the render node for GPU acceleration
  • Option "DRI3" "1": Enables DRI3 (Direct Rendering Infrastructure)
  • Option "DRMAllowList" "i915 radeon": Allows Intel and AMD GPUs

Fixing Permission Issues
#

Even with the render node passed to the container, the X server couldn’t open it due to permissions:

rdpPreInit: /dev/dri/renderD128 open failed

The user needed to be added to the video and render groups:

sudo nerdctl exec xrdp-dri sudo usermod -aG video,render user

Then restart the container.


Hardware Acceleration Confirmed
#

After the fixes, the Xorg log showed success:

rdpPreInit: /dev/dri/renderD128 open ok, fd 11
rdpPreInit: name [i915]
rdpPreInit: desc [Intel Graphics]
rdpPreInit: drm device looks ok, use glamor set
(II) XRDPDEV(0): glamor: Using OpenGL 4.6 context.
(II) XRDPDEV(0): glamor X acceleration enabled on Mesa Intel(R) Graphics (RPL-S)
(II) XRDPDEV(0): [DRI2]   DRI driver: iris

Verifying with glxinfo
#

DISPLAY=:13 glxinfo | grep renderer

Output:

OpenGL renderer string: Mesa Intel(R) Graphics (RPL-S)

This confirms hardware GPU rendering instead of “llvmpipe” (software).

Performance Benchmark: glxgears
#

With vsync enabled (default):

301 frames in 5.0 seconds = 60.118 FPS
301 frames in 5.0 seconds = 60.011 FPS
300 frames in 5.0 seconds = 59.996 FPS

With vsync disabled (to see true performance):

env vblank_mode=0 glxgears

Output:

59418 frames in 5.0 seconds = 11883.407 FPS
60206 frames in 5.0 seconds = 12041.156 FPS

That’s approximately 12,000 FPS—roughly 200x faster than software rendering!


Saving Everything to Dockerfile
#

Now that hardware acceleration is working, let’s save everything to a reproducible Dockerfile.

Step 1: Commit the Working Container
#

sudo nerdctl commit 2232570531a0 xrdp-dri:latest

Output:

sha256:60b87574554338cef378effd01d4da7d5cd3c17814c3f6506d4a2261c9feec22

Step 2: Export the xorg.conf
#

sudo nerdctl exec 2232570531a0 cat /etc/X11/xrdp/xorg.conf > /path/to/xorg.conf

Step 3: Updated Dockerfile
#

FROM debian:forky

RUN apt update && apt install -y --no-install-recommends \
	xrdp \
	wireplumber \
	xorg \
	xorgxrdp \
	lxqt \
	dbus-x11 \
	xterm \
	x11-xserver-utils \
	konsole \
	sudo \
	ca-certificates \
	fish \
	tmux \
	mesa-utils \
	&& apt clean \
	&& apt remove -y lxqt-powermanagement 2>/dev/null || true

RUN apt update && apt install -y --no-install-recommends \
	adwaita-icon-theme \
	hicolor-icon-theme \
	adwaita-qt \
	gtk-update-icon-cache \
	fonts-dejavu-core \
	xdg-utils \
	wget \
	&& apt clean \
	&& gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true \
	&& gtk-update-icon-cache -f -t /usr/share/icons/Adwaita 2>/dev/null || true \
	&& mkdir -p /usr/share/fonts/truetype/lucida \
	&& wget -q https://raw.githubusercontent.com/witt-bit/lucida-fonts/main/Lucida%20Sans%20Typewriter/Lucida%20Sans%20Typewriter-Regular.ttf -O /usr/share/fonts/truetype/lucida/LucidaSansTypewriter.ttf \
	&& wget -q https://raw-bit/lucida-fonts/main/L.githubusercontent.com/wittucida%20Sans%20Typewriter/Lucida%20Sans%20Typewriter-Bold.ttf -O /usr/share/fonts/truetype/lucida/LucidaSansTypewriterBold.ttf \
	&& wget -q https://raw.githubusercontent.com/witt-bit/lucida-fonts/main/Lucida%20Sans%20Unicode-Regular.ttf -O /usr/share/fonts/truetype/lucida/LucidaSansUnicode.ttf \
	&& fc-cache -f

ENV LANG=C.UTF-8

RUN groupadd -g 1004 data && useradd -g 1004 -G ssl-cert -m -s /usr/bin/fish user && echo "user ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers && echo 'user:hunter2' | chpasswd

RUN mkdir -p /home/user/Desktop /run/xrdp /run/xrdp/sockdir/1000
RUN printf '[Desktop Entry]\nName=konsole\nGenericName=\nComment=\nExec=/usr/bin/konsole\nType=Application\nIcon=text-x-script\nTerminal=false\n' > /home/user/Desktop/konsole.desktop
RUN chmod +x /home/user/Desktop/konsole.desktop
RUN chown -R user /etc/xrdp/rsakeys.ini /etc/xrdp /run/xrdp /home/user/Desktop
RUN for x in /etc/xrdp/*.pem ; do chown user $(readlink $x) ; done
RUN rm -f /var/log/xrdp.log && ln -s /proc/self/fd/1 /var/log/xrdp.log
RUN rm -f /var/log/xrdp-sesman.log && ln -s /proc/self/fd/1 /var/log/xrdp-sesman.log

RUN echo > /etc/default/locale
ENV XDG_RUNTIME_DIR=/tmp/
ENV XDG_DATA_DIRS=/usr/share/icons/Adwaita:/usr/share/icons:/usr/share/desktop-icons:/usr/share

COPY start-kde.sh /home/user/start-kde.sh
RUN chmod +x /home/user/start-kde.sh
RUN chown -R user:data /tmp /home/user && chmod 700 /tmp

RUN echo rdp > /etc/hostname

# Add user to video and render groups for GPU access
RUN usermod -aG video,render user

# Copy xorg.conf with GPU acceleration settings
COPY xorg.conf /etc/X11/xrdp/xorg.conf
RUN chown root:root /etc/X11/xrdp/xorg.conf

USER user

WORKDIR /home/user
CMD ["/home/user/start-kde.sh"]

Key Dockerfile Changes
#

  1. Added mesa-utils package: For glxinfo and glxgears benchmarking tools
  2. Added group membership: RUN usermod -aG video,render user for GPU device access
  3. Copied xorg.conf: COPY xorg.conf /etc/X11/xrdp/xorg.conf with GPU configuration

Building and Running
#

Build the image:

sudo nerdctl build -t xrdp-dri:latest .

Run with full GPU passthrough:

sudo nerdctl run -d \
  --name xrdp-dri \
  --device /dev/dri/card0 \
  --device /dev/dri/renderD128 \
  -p 3389:3389 \
  -v /data:/data \
  xrdp-dri:latest

Summary
#

This comprehensive guide covered:

  1. State Preservation: Using nerdctl commit to save container state to an image before making changes

  2. GPU Passthrough: Passing /dev/dri/card0 and /dev/dri/renderD128 to the container using the --device flag

  3. Port Conflict Resolution: Identifying and killing zombie sleep processes that held the port after container exit

  4. xrdp Configuration: Setting up proper xorg.conf with:

    • DRMDevice pointing to /dev/dri/renderD128
    • DRI3 enabled
    • DRMAllowList for allowed GPU drivers
  5. Permission Fixes: Adding the user to video and render groups

  6. Performance Verification: Achieving ~12,000 FPS with hardware acceleration vs ~60 FPS with software rendering

  7. Dockerfile Reproducibility: Baking all changes into a reproducible Dockerfile

The final result is a containerized xrdp desktop with full hardware-accelerated graphics, powered by Intel UHD Graphics 770, running at approximately 200x the performance of software rendering.