【ラズパイでもDocker!】Dockerコンテナ越しにシリアル通信を叩くNode.jsアプリの作成方法


2021/06/12
蛸壺の中の工作室|Dockerコンテナ越しにシリアル通信を叩くNode.jsアプリの作成方法

著者的にラズパイでNodejsアプリを使う場合、Dockerコンテナ内部で起動させて利用することが多いですが、コンテナ外部のホストOSの権限を持ったデバイスを使うのにはコツがあります。

ちょっとしたことですが、今回はラズパイでDockerコンテナー内部から外部デバイスを操作する際に覚えておきたいポイントを解説してみます。


Dockerで動くNode.jsアプリの作成

まずはDockerコンテナ上でラズパイのシリアル通信を介して、信号を受け取るだけのNode.jsアプリを作成してみましょう。

以前のブログでは、ラズパイでシリアル通信するシンプルなやり方を解説していました。

今回これをNode.jsアプリに仕立て直して使います。

また、
ラズパイにDockerをインストールする方法およびDockerコンテナからLチカさせるNode.jsアプリを作成する手順は既にこちらのブログで解説していましたので、詳しい作り方に関してはそちらをご覧ください。

node-serialport v9でシリアル通信プログラム実装

npmパッケージでシリアル通信を行う場合に利用できるライブラリが豊富にありますが、今回は採用事例の多いnode-serialportを利用します。

nodeのビルド環境が整っていれば導入は簡単に、

            $ npm insall serialport
#OR
$ yarn add serialport
        
でインストール可能になります。

Dockerイメージのビルドの話は後述しますが、コンテナの実行エンドポイントのindex.jsは以下のようにします。

            const SerialPort = require('serialport');
const Readline = require('@serialport/parser-readline');

//👇ゲスト上のSerialデバイスに名前を合せる
const portName = '/dev/ttyUSB0';

//👇接続先(Arduino)のシリアルモデムを考慮
const sp = new SerialPort(portName, {
    baudRate: 9600,
    dataBits: 8,
    parity: 'none',
    stopBits: 1,
    flowControl: false,
});

//👇ポート開放時の初期化処理
sp.on('open', ()=> {
    console.log('Open Serialport');
});

//👇パーサクラスを指定
const parser = sp.pipe(new Readline());

//👇データ(Arduino > ラズパイ)の受信
parser.on('data', (inp_) => {
    try {
        console.log(`Received: ${inp_}`);
    } catch(e) {
        return;
    }
});
        
なお、現行のnode-serialportではデリミタ(区切り文字)の扱いが大きく変わり、従来のserialport.on('data',...)では正しく文字を区切れない仕様になりました。

そのため新しい仕組みとしてParserクラス使って区切り位置を操作することになります。今回は通常の改行文字区切りをつかうため、
Readlineパーサを使う必要があります。

従来であれば受信した信号は生バイナリの状態のため適切にエンコードする必要がありましたが、パーサクラスで処理した信号は適切にエンコードされて出力されます(デフォルトではutf8)。

Dockerイメージの作成

今回はどかっと、本番用のコンテナイメージを以下に示します。

ターゲットデバイスはRaspberryPi 3B+ですのでアーキテクチャを考慮し、ベースイメージは
arm32v7/nodeにしていますが、nodeのバージョンが進みすぎるのもバグが多い気もするので、v12くらいで安定しているバージョンを選択します。

なおラズパイ4以降で試す際は、
arm64v8/nodeをベースに読み替えてください。

            FROM arm32v7/node:12-alpine3.11

RUN apk update && apk upgrade && apk add --no-cache \
    --virtual sp-deps make gcc g++ python alpine-sdk linux-headers udev

ENV NODE_ENV development
WORKDIR /usr/src/app

COPY package.json /usr/src/app/

RUN cd /usr/src/app && yarn install && yarn cache clean

RUN apk del sp-deps

COPY index.js /usr/src/app/
CMD ["node", "index.js"]
        
先にnpmパッケージをグローバルで入れても良いのですが、今回はpackage.jsonでローカルインストールを作成します。

            {
    "name": "serial-dckr",
    "version": "0.0.1",
    "license": "MIT",
    "dependencies": {
        "serialport": "^9.0.0"
    }
}
        
ではDockerイメージのビルドを行います。

ここではイメージの名前をタグ付きで
serial-dckr:node12としています。

            $ docker build -t serial-dckr:node12 .
        
正常にビルドできたらひとまずOKです。

DockerコンテナのNodeアプリの起動

前回の話の延長で、ラズパイのシリアル通信の接続先にはArduinoを使います。手元の環境でこのシリアルデバイスはホストOS(ラズパイ)上で/dev/ttyACM0で認識しています。

ゲストOS(コンテナ)上でアクセス権限を付与するデバイス名を共通にしても良いのですが、今回は
/dev/ttyUSB0という別名を付けてコンテナ側にこのデバイスをマウントしてみます。(※--deviceオプションに関しては後述します。)

以下で
piserialという名前のコンテナが起動します。

            $ docker run -it \
    --rm \
    --device=/dev/ttyACM0:/dev/ttyUSB0 \
    --name piserial \
    serial-dckr:node12
#👇node index.jsのプロセスが走り出す
Open Serialport
Received: LED OFF
Received: LED ON
Received: LED OFF
Received: LED ON
#...省略
        
と確かにシリアル通信で外部のArduinoからの文字列信号を受信している状態であれば成功です!

起動しているコンテナの止め方

今回のコンテナは一度起動したら走りっぱなしですので、他のコンソールから強制的にdocker stopさせることになります。

起動を停止させる際はコンテナ名を使って、

            $ docker stop piserial
        
とすると止めることができます。

また
--rmオプションを付けているのでコンテナ停止後は自動でコンテナごと消滅します。停止後も残しておきたいコンテナは--rmオプションを付けないで起動させましょう。


コンテナに外部デバイスへのアクセス権限を追加する

上記では天下り的にDockerオプションからデバイスのアクセス権限の付与していましたが、改めてそのポイントをまとめてみましょう。

個別にデバイスを許可する

docker runのコマンドオプション--deviceで、

            $ docker run --device=<ホスト上のデバイス名>[:コンテナ上のデバイス名][:権限タイプ] ...
        
とすることでDockerコンテナ内で指定したホスト上のデバイスが利用できるようになります。

:コンテナ上のデバイス名の部分は省略した場合、ホスト上のデバイス名がそのままコンテナ上のデバイス名になります。

つまりは、

            $ docker run --device=/dev/ttyACM0 ...
#👇と同じ
$ docker run --device=/dev/ttyACM0:/dev/ttyACM0 ...
        
権限タイプはコンテナからデバイスへ対してREAD(読み出し)・WRITE(書き込み)・MKNOD(特殊ファイル作成)がそれぞれ:r / :w / :mを限定して指定もできます。デフォルトでは:rwmの3つのオプションはセットされており、これは省略化です。

特権を与える(最終奥義)

ネットワーク越しにIoT機器を扱うことがますます多くなる昨今、コンテナに特権を与えて運用することはかなり危険なことになりつつあります。

とはいえ、動作確認段階からセキュリティをシビアに考慮することもなさそうであれば、
--privilegedオプションは便利に扱えます。

            $ docker run --privileged -it <コンテナ名>
        
これでコンテナに特権が与えられ、/dev/以下の全てのディレクトリがホストOSと同じように利用出来るようになります。

GPIOとUSBデバイスの違い

実は以前の記事で、GPIOをDockerコンテナで取り扱ったときにも、同様の内容に触れたことがありました。

今回はUSBデバイスをDockerコンテナから使うために、
--device=/dev/tty...を使ってデバイス指定をすることでアクセスが可能となったのですが、GPIOだとこの方法が取れませんでした。

いくつかあるGPIO操作のやり方で、デバイスに直接アクセスを介さず
/sys/class/gpioファイルを使う方法では、Dockerコマンドで--device=/dev/gpiomemとアクセス権限を与えても、/sys/フォルダへのアクセスも同時に付与しなければ使えずにエラーが発生します。

なのでその場合、デバイスへのアクセス権ではなく、ライブラリを含むボリュームへのアクセス権を
--volume=/sys:/sysによってDockerコンテナに付与しなければならない、ということが結論でした。

不思議に思うかも知れませんが、そのときのDockerコンテナにはGPIOデバイスを利用する権限が付与されていなくても、マウント先の/sys/class/gpioファイルにはGPIOを実行する権限があるので、結局はDockerコンテナからGPIOデバイスが操作できるようになってしまいます。

今後Dockerのセキュリティ強化のため、このような権限跨ぎ的な利用方法が見直される場合があるかも知れませんが、Dockerへの実行権限の付与の仕組みを知っておけばその都度対処できると思います。


まとめ

以上、Dockerコンテナ上のNode.jsアプリからUSB接続されたArduinoからシリアル通信を行う方法を細かく検証していきましたが以下がだったしょうでしょうか。

node-serialportが動いたことで、ネットワーク上であれば何処からでも遠隔操作できるIoTシステムが構築できるなど、色々と応用の幅も広がってくるはずです。


参考サイト

About SerialPort - Quick Answers to Important Questions

ROS meets Docker

DockerコンテナからUSBデバイスへのアクセス