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/card0and/dev/dri/renderD128to the container - Configure xorg.conf with
DRMDevice,DRI3, andDRMAllowListoptions - 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 6d5b3c01f38aThe 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:
/databound 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-driThis 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-driExplanation: 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 allocatedThis happened because the old container was still holding the port binding.
Checking Container Status #
sudo nerdctl ps -a | grep 6d5b3c01f38aOutput:
6d5b3c01f38a locals.tf/xrdp:latest "/home/user/start-kd…" 6 days ago Exited (137) 2 minutes ago 0.0.0.0:3389->3389/tcp xrdpThe container shows “Exited” but the port was still allocated.
Finding the Zombie Process #
sudo ss -tlnp | grep 3389Output:
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 3389After 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 card0The 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 vgaOutput:
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 XOutput:
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.logThe 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- The render node wasn’t available
- No DRI2 support
- 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
EndSectionKey Configuration Options:
Driver "xrdpdoc": The xrdp device driverOption "DRMDevice" "/dev/dri/renderD128": Points to the render node for GPU accelerationOption "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 failedThe user needed to be added to the video and render groups:
sudo nerdctl exec xrdp-dri sudo usermod -aG video,render userThen 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: irisVerifying with glxinfo #
DISPLAY=:13 glxinfo | grep rendererOutput:
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 FPSWith vsync disabled (to see true performance):
env vblank_mode=0 glxgearsOutput:
59418 frames in 5.0 seconds = 11883.407 FPS
60206 frames in 5.0 seconds = 12041.156 FPSThat’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:latestOutput:
sha256:60b87574554338cef378effd01d4da7d5cd3c17814c3f6506d4a2261c9feec22Step 2: Export the xorg.conf #
sudo nerdctl exec 2232570531a0 cat /etc/X11/xrdp/xorg.conf > /path/to/xorg.confStep 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 #
- Added
mesa-utilspackage: Forglxinfoandglxgearsbenchmarking tools - Added group membership:
RUN usermod -aG video,render userfor GPU device access - Copied xorg.conf:
COPY xorg.conf /etc/X11/xrdp/xorg.confwith 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:latestSummary #
This comprehensive guide covered:
-
State Preservation: Using
nerdctl committo save container state to an image before making changes -
GPU Passthrough: Passing
/dev/dri/card0and/dev/dri/renderD128to the container using the--deviceflag -
Port Conflict Resolution: Identifying and killing zombie sleep processes that held the port after container exit
-
xrdp Configuration: Setting up proper xorg.conf with:
DRMDevicepointing to/dev/dri/renderD128DRI3enabledDRMAllowListfor allowed GPU drivers
-
Permission Fixes: Adding the user to
videoandrendergroups -
Performance Verification: Achieving ~12,000 FPS with hardware acceleration vs ~60 FPS with software rendering
-
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.