Skip to content

Instantly share code, notes, and snippets.

@mu88
Created February 2, 2024 10:50
Show Gist options
  • Save mu88/55e13e59e81a4d164beec7c1a4a9e1fa to your computer and use it in GitHub Desktop.
Save mu88/55e13e59e81a4d164beec7c1a4a9e1fa to your computer and use it in GitHub Desktop.
Raspi IoT with GPIO in Docker

Control GPIO pins within rootless Docker container on Raspberry Pi

When it comes to controlling the GPIO pins of a Raspberry Pi, the existing tutorials will get you started in a couple of minutes. But most of the code assumes that it runs on bare metal and has sufficient privileges, e. g. to access the GPIO pins.
But what if you want to run this code in a Docker container, or even rootless? This page will explain how to set up all the necessary pieces.

Containerizing the app

Let's use the following sample code to blink an LED (taken from the official documentation) and save it under BlinkLedSample\Program.cs:

using System;
using System.Device.Gpio;
using System.Threading;

Console.WriteLine("Blinking LED. Press Ctrl+C to end.");
int pin = 18;
using var controller = new GpioController();
controller.OpenPin(pin, PinMode.Output);
bool ledOn = true;
while (true)
{
    controller.Write(pin, ((ledOn) ? PinValue.High : PinValue.Low));
    Thread.Sleep(1000);
    ledOn = !ledOn;
}

The project file BlinkLedSample\BlinkLedSample.csproj looks like this:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="System.Device.Gpio" Version="3.1.0" />
  </ItemGroup>
</Project>

As you can see, it is a .NET 8 console app which references the NuGet package System.Device.Gpio.

First of all, let's make sure that this compiles nicely by running dotnet build:

MSBuild version 17.8.3+195e7f5a3 for .NET
  Determining projects to restore...
  Restored C:\work\temp\BlinkLedSample\BlinkLedSample.csproj (in 125 ms).
  BlinkLedSample -> C:\work\temp\BlinkLedSample\bin\Debug\net8.0\BlinkLedSample.dll

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:00.88

The .NET SDK has built-in support for containerizing a .NET app (see here). All we have to do is to add a NuGet package reference to Microsoft.NET.Build.Containers so that the necessary build components are available (note: this step won't be necessary from .NET 8.0.200 on or when working with ASP.NET Core):

dotnet add package Microsoft.NET.Build.Containers

Now we can create a Docker image of our app by calling dotnet publish --os linux --arch arm64 /t:PublishContainer -c Release:

MSBuild version 17.8.3+195e7f5a3 for .NET
  Determining projects to restore...
  Restored C:\work\temp\BlinkLedSample\BlinkLedSample.csproj (in 115 ms).
  BlinkLedSample -> C:\work\temp\BlinkLedSample\bin\Release\net8.0\linux-arm64\BlinkLedSample.dll
  BlinkLedSample -> C:\work\temp\BlinkLedSample\bin\Release\net8.0\linux-arm64\publish\
  Building image 'blinkledsample' with tags 'latest' on top of base image 'mcr.microsoft.com/dotnet/runtime:8.0'.
  Pushed image 'blinkledsample:latest' to local registry via 'docker'.

This does the following:

  • dotnet publish is instructed to publish a Docker image which targets Linux and Raspi's arm64 architecture.
  • The project is restored and built using the Release configuration.
  • The Docker base image mcr.microsoft.com/dotnet/runtime:8.0 is resolved automatically by taking the type of app (console, ASP.NET Core, worker, etc.) and target framework (7.0, 8.0, etc.) into account.
  • The Docker image of our app is built, using the latest tag by default, i. e. the local Docker registry now contains the image blinkledsample:latest.

This image can now be transferred onto the Raspberry Pi, e. g. via using a Docker registry like Docker Hub or docker save and docker load.

Running the app in Docker

Assuming we're on a Raspberry Pi, we could now create a container from the previously created image like this:

docker run --rm --name blinkled blinkledsample:latest

However, this will fail as the app has no access to the GPIO pins inside the container. As with every other resource necessary for running a container, it must be mounted from the host into the container. In case of GPIO, the device /dev/gpiomem is needed.
The complete command to run the container therefore is:

docker run --rm --device=/dev/gpiomem --name blinkled blinkledsample:latest

But executing the command leads to the following error:

Unhandled exception. System.IO.IOException: Error 13 initializing the Gpio driver.
   at System.Device.Gpio.Drivers.RaspberryPi3LinuxDriver.Initialize()
   at System.Device.Gpio.Drivers.RaspberryPi3LinuxDriver.OpenPin(Int32 pinNumber)
   at System.Device.Gpio.Drivers.RaspberryPi3Driver.OpenPin(Int32 pinNumber)
   at System.Device.Gpio.GpioController.OpenPinCore(Int32 pinNumber)
   at System.Device.Gpio.GpioController.OpenPin(Int32 pinNumber)
   at System.Device.Gpio.GpioController.OpenPin(Int32 pinNumber, PinMode mode)

This piece is a bit more tricky: first of all, let's check the permissions inside the container for the device:

myUser@myRaspi:~ $ docker run -it --rm --entrypoint /bin/bash blinkledsample:latest
app@18c678710307:/app$ ls -lh /dev/gpiomem
crw-rw---- 1 root 997 245, 0 Jan 28 18:45 /dev/gpiomem

Now we know the following about /dev/gpiomem:

  • It is user-owned by root with read and write permissions.
  • It is group-owned by the group with ID 997 with read and write permissions.
  • All others have no access.

So let's test if our container can access the device:

app@18c678710307:/app$ test -r /dev/gpiomem; echo "$?"
1
app@18c678710307:/app$ test -w /dev/gpiomem; echo "$?"
1
app@18c678710307:/app$ id
uid=1654(app) gid=1654(app) groups=1654(app)

We see that the current user app can neither read nor write /dev/gpiomem, and that the current user has the group ID gid=1654.
That perfectly explains the error we're seeing in our app: our app runs as user app which belongs to the group 1654, but only members of the group 997 can access the GPIO device.

Luckily, the desired group ID can be set when running a Docker container:

docker run --rm -u "1654:997" --device=/dev/gpiomem --name blinkled blinkledsample:latest

Now you should see the console output Blinking LED. Press Ctrl+C to end. and a blinking LED - congratulations! 🎉

root vs. app

Starting with .NET 8, the Docker images for Linux come with a non-root user. There is this excellent blog article about the topic, but it's worth spending a word on the rootless nature of the described containerization.

Without configuring anything manually, the created Docker image uses the new rootless app user. That happened automatically since we used the .NET SDK Container Building Tools.

In case of using a dedicated Dockerfile and building the image via docker build, the following line would have to be added:

USER $APP_UID

This way, the user ID can be controlled via the environment variable APP_UID and uses 1654 by default.
Not adding the line and omit using -u "1654:997" when starting the Docker container will result in using the root user, i. e. there will still be a blinking LED, but the app will have more privileges due to the root permissions.

Additional information

The following issues and articles provide more in-depth details:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment