【ラズパイでもDocker!】Docker Alpineコンテナ上のNode.jsネイティブアプリからラズパイのGPIOを操作する方法


2020/05/08

過日に記事にした
ラズパイでLチカ 〜 実機からShellで行う方法 & Docker AlpineコンテナからShellで行う方法の比較の発展編です。

今回は
Nodejsのネイティブアプリケーションからラズパイ側のGPIOをDockerコンテナを通じてLEDをチカチカさせてみましょう。

まずは
NodejsでLチカするために利用できるパッケージで2つ例を挙げてご紹介します。


npmパッケージの利用① 〜 onoff編

まずは、一番簡単にGPIOを操作できるonoffから試してみます。

以降では前提として、Raspbian Busterのラズパイ3B+にDockerがインストールされていることとします。ただしDockerさえ導入できれば、どのOSでも同様の手順でLチカ出来ると思います。

今回利用するdockerイメージは既に
nodejsがインストールされているものを利用したほうが手間が省けるので、arm32v7/nodealpineベースのイメージをタグ付きでDocker Hubからプルして利用します。今回は現行の安定バージョン(nodejs13+alpine3.11相当)のarm32v7/node:alpineベースを利用します。

前回の記事で取り上げたラズパイで動作する
alpineイメージで最小クラスのイメージと比べると30倍強の容量がありますが、nodejsが動作させようとすると100MB以上の容量のコンテナになってしまうことは覚悟する必要があります。

            $ docker pull arm32v7/node:alpine
$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
arm32v7/node        alpine              30bb03f6ec2e        6 months ago        96.6MB
armhf/alpine        latest              15ed6d4bf10d        2 years ago         3.6MB
        
ちなみに、ラズパイ3B+に搭載されているCPUアーキテクチャはBroadcom BCM2837というSoCに採用されているCortex-A53ということなので、ARMv8...なので、正確にはARMv8を謳っているDockerイメージを利用するべきなのですが、ARMv7ベースのイメージでも今回はLチカするだけですので十分動作しています。

不足のapkパッケージ追加

ではまずarm32v7/node:alpineベースのイメージでインタラクティブモードに入ります。onoffを利用する場合には、以下で理由は後述しますが、-v /sys:/sysオプションを用いてラズパイ側のシステムをボリュームごと共有させて利用します。

            $ docker run -it \
    --rm \
    -v /sys:/sys \
    arm32v7/node:alpine /bin/sh
        
ここからonoffを正しく動作させるには、いくつか不足しているパッケージをインストールする必要があります。

            $ apk add --update python make g++
        
これが、内部でnode-gyp rebuildさせるための最小パッケージになります。

npmプロジェクトの作成

次にnpmの初期化と、エントリーポイントとなるindex.jsを作成します。まずコンテナ内にインタラクティブモードで入り、適当な作業フォルダを作成し、そのフォルダ上でリソースコードを作っていきます。

ここでは一例として
/usr/local/appでプロジェクトを作成していきます。

            $ mkdir /usr/local/app && cd /usr/local/app
#👇新規のpackage.jsonを発行
$ npm init -y
Wrote to /usr/local/app/package.json:
{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

#👇index.jsがアプリのエントリーポイント
$ touch index.js
$ ls
index.js package.json
        
それではonoffをローカルにインストールします。

            $ npm i onoff
> epoll@4.0.0 install /usr/local/app/node_modules/epoll
> node-gyp rebuild

make: Entering directory '/usr/local/app/node_modules/epoll/build'
  CXX(target) Release/obj.target/epoll/app/epoll.o
  SOLINK_MODULE(target) Release/obj.target/epoll.node
  COPY Release/epoll.node
make: Leaving directory '/usr/local/app/node_modules/epoll/build'
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN src@1.0.0 No description
npm WARN src@1.0.0 No repository field.

+ onoff@6.0.0
added 6 packages from 12 contributors and audited 6 packages in 26.364s
found 0 vulnerabilities
        
これでonoffがインストールされて、準備が整いました。

Lチカ

前回と同じ配線でLEDが点滅するかを試してみましょう。今回は3番ピン(GPIO2)で、0.5秒の間隔で点滅させてみます。

以下を
index.jsに編集保存します。

            const { Gpio } = require('onoff');

// GPIO2を出力設定で利用
const ledOut = new Gpio('2', 'out');

// LEDの状態(false=消灯)
let isLedOn = false;

// 一定時間間隔でループ処理
setInterval(() => {
    ledOut.writeSync( isLedOn ? 0 : 1 ); // 1の書き込みで点灯・0で消灯
    isLedOn = !isLedOn; // 状態を反転
}, 500); // 0.5秒
        
これで準備はOKです。では以下のコマンドでLチカさせてみましょう。

            $ node index.js
#...ずっとチカチカ
        
なお終了させるときはcntl + cでキーを押します。

余談 ~ --deviceオプションだけだとonoffは動かない

dockerの--device /dev/gpiomemオプションを付加しているのであれば、通常はラズパイのGPIOを操作する権限が与えられることが期待されるのですが、このonoffは曲者で、試しに--deviceオプションを付けてコンテナを以下のように起動してみます。

            $ docker run -it \
    --rm \
    --device /dev/gpiomem \
    arm32v7/node:alpine /bin/sh
        
それで、上記のindex.jsを叩くと、

            $ node index.js
internal/fs/utils.js:220
    throw err;
    ^

Error: EROFS: read-only file system, open '/sys/class/gpio/export'
    at Object.openSync (fs.js:440:3)
    at Object.writeFileSync (fs.js:1281:35)
    at exportGpio (/usr/local/app/node_modules/onoff/onoff.js:18:8)
    at new Gpio (/usr/local/app/node_modules/onoff/onoff.js:172:36)
    at Object.<anonymous> (/usr/local/app/index.js:4:16)
    at Module._compile (internal/modules/cjs/loader.js:1063:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1103:10)
    at Module.load (internal/modules/cjs/loader.js:914:32)
    at Function.Module._load (internal/modules/cjs/loader.js:822:14)
    at Function.Module.runMain (internal/modules/cjs/loader.js:1143:12) {
  errno: -30,
  syscall: 'open',
  code: 'EROFS',
  path: '/sys/class/gpio/export'
}
        
とエラーが発生して動きません。

そもそも
onoffはデバイスに直接アクセスするのではなく、前回の記事でも説明したようにラズパイのシステム内の/sys/class/gpioファイルに書き込みを行っているだけのライブラリのようです。なので当然--deviceを付けてコンテナを起動してもonoffがデバイスを直接操作している訳でなく、'sys'ファイル以下を覗きにいっているだけなので、書き込み権限エラーが発生したと考えられます。

onoffはnodejs側から簡単に操作したい初学者レベル相当のライブラリであることに注意してください。


npmパッケージの利用② 〜 pigpio編

ラズパイのGPIOをnodejsで操作するためのパッケージは非常に多くnpm等で公開されていますが、逐一取り上げていたらキリがないので、個人的にも気に入っているpigpioをここでは紹介しようと思います。pigpio自体はラズパイGPIOコードサンプルwikiにもあるように、c++で書かれたライブラリで実績もあるプログラムのようです。

とりあえず先のほどのようにベースイメージから、色々とパッケージを手動でインストールしていくのは骨折りな作業ですので、今回はインタラクティブモードで開発するバージョンのDockerfileを用意します。

            FROM arm32v7/node:alpine
RUN apk update && \
    apk upgrade && \
    apk add --no-cache python make g++ alpine-sdk unzip bash

RUN wget https://github.com/joan2937/pigpio/archive/master.zip && \
    unzip master.zip && \
    cd pigpio-master && \
    sed -i 's,ldconfig,,' Makefile && \
    make && \
    make install

WORKDIR /usr/local/app
COPY package.json /usr/local/app
COPY index.js /usr/local/app
RUN npm install
        
これをオリジナルDockerイメージとしてローカルビルドさせます。ここでは自分専用としてtaconocat/pigpioと名前を付けます。

            $ docker build . -t taconocat/pigpio
        
これをコンテナ起動させてインタラクティブモードで開発を進めていきます。

            $ docker run -it \
    --rm \
    --privileged \
    taconocat/pigpio /bin/bash
        
とりあえず苦肉の策として--privilegedを付けている理由は後述します。また後付になりましたが、Dockerfile内でコンテナ内にコピーで送り込んでいるindex.jspackage.jsonは以下のようになります。

index.js(エントリーポイント)

            const Gpio = require('pigpio').Gpio;

const led = new Gpio(2, {mode: Gpio.OUTPUT});

let isLedOn = false;

// 一定時間間隔でループ処理
setInterval(() => {
    if (isLedOn) {
        led.digitalWrite(1);
    } else {
        // led.off();
        led.digitalWrite(0);
    }
    isLedOn = !isLedOn; // 状態を反転
}, 1000); // 0.5秒
        

package.json

            {
  "name": "pigpio-master",
  "version": "1.0.0",
  "description": "pigpio is a C library for the Raspberry which allows control of the General Purpose Input Outputs (GPIO).",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "pigpio": "^3.2.1"
  }
}
        
コンテナ起動後に、インタラクティブモード越しにnode index.jsとコマンドを叩くと、Lチカすると動作確認OKです。

余談 〜 特権コンテナにしないと動かない?

今回は深く追求しませんが、--deviceオプションでデバイス権限をdocker側に付与させただけではLチカできないようです。試しに--deviceオプションからGPIOを利用できるようにしてみてからコンテナ起動します。

こちらのドキュメントに記載してありますが、Linuxのファイルシステムをマウントするには--cap-add--deviceの両方を使う必要があります。

            $ docker run -it \
    --rm \
    --cap-add SYS_ADMIN \
    --device /dev/gpiomem \
    taconocat/pigpio /bin/bash
        
インタラクティブモードに入りnodeを呼び出すと...

            $ node index.js
2020-05-07 16:13:07 initCheckPermitted:
+---------------------------------------------------------+
|Sorry, you don't have permission to run this program.    |
|Try running as root, e.g. precede the command with sudo. |
+---------------------------------------------------------+

/usr/local/app/pigpio-master/node_modules/pigpio/pigpio.js:29
    pigpio.gpioInitialise();
           ^

Error: pigpio error -1 in gpioInitialise
    at initializePigpio (/usr/local/app/pigpio-master/node_modules/pigpio/pigpio.js:29:12)
    at new Gpio (/usr/local/app/pigpio-master/node_modules/pigpio/pigpio.js:133:5)
    at Object.<anonymous> (/usr/local/app/pigpio-master/index.js:3:13)
    at Module._compile (internal/modules/cjs/loader.js:778:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:789:10)
    at Module.load (internal/modules/cjs/loader.js:653:32)
    at tryModuleLoad (internal/modules/cjs/loader.js:593:12)
    at Function.Module._load (internal/modules/cjs/loader.js:585:3)
    at Function.Module.runMain (internal/modules/cjs/loader.js:831:12)
    at startup (internal/bootstrap/node.js:283:19)
        
という感じで、node内部で実行エラーが発生してしまいます。どうやらalpineコンテナ内でのnode実行権限を細かくdocker側で事細かく設定しないといけないようですが...実行ファイルにアクセス権限が不足している場合があるので、エラーのあるファイルに個別に実行権限を確認してまわる必要もあるかも知れません。

今回はLチカさせたいだけなので、色々と細悩むよりは
--previligedを一発当てて所謂特権コンテナで起動させてしまえば十分だと判断します。

ただし本番用の製品などで
--previligedは使いたくない場合にDockerのセキュリティをしっかり見直す必要がありそうです。

本番用のプロセス実行コンテナ

それでは最後に、先程のインタラクティブモード用の開発コンテナから、本番用の常駐プロセスで走らせて使用する本番用のコンテナに仕立て直します。

開発用イメージ内のアプリケーションビルドに利用したパッケージが無駄に残っているので、ビルド後に用済みになっているパッケージは削ります。

            FROM arm32v7/node:alpine
RUN apk update && \
    apk upgrade && \
    apk add --no-cache --virtual build-deps python make g++ alpine-sdk unzip

RUN cd /tmp && \
    wget https://github.com/joan2937/pigpio/archive/master.zip && \
    unzip -qq master.zip && \
    cd pigpio-master && \
    sed -i 's,ldconfig,,' Makefile && \
    make && \
    make install && \
    rm -rf /tmp/*

WORKDIR /usr/local/app
COPY package.json /usr/local/app
COPY index.js /usr/local/app
RUN npm install && \
    apk del build-deps

CMD ["node", "index.js"]
        
この実行用のコンテナを開始+常駐化するため以下のコマンドで立ち上げます。

            #デタッチモードで起動
$ docker run -d --privileged taconocat/pigpio
#起動デーモンに登録し常駐化
$ docker update --restart=always taconocat/pigpio
        
これでLEDをチカチカする処理がバックグラウンドで永続化できると思います。


まとめ

今回はnodejsによってdockerコンテナ側からでもラズパイのホスト側のデバイスをc++ライブラリを介して直接レジスターアクセスで操作できるまでをダイジェストに解説していきました。これを活用することで、nodejsベースのアプリケーションでも、オーバーヘッド時間の短いデバイスへの高速処理が可能となりました。

今後はQEMUなどのエミュレータと組み合わせながら、実践的な開発の応用事例などをブログ記事で発信できればいいなと思っております。