All Articles

Raspberry Pi で L チカしてるプログラムを、k3s を使って無停止で更新する

最近 Raspberry Pi で遊び始め、Raspberry Pi 上で動いているプログラムを無停止で更新できたら面白いなと思い、k3s を使った無停止リリースに挑戦してみました。

最終的には、プログラムの更新によって、以下のように途中で L チカのスピードが速くなります。

環境

方針

さて、まずはどうやって無停止でプログラムを更新するかを考えてみます。

GPIO を複数のプログラムが同時にさわるとよくないことになりそうなので、GPIO を扱うプログラムを無停止で更新するのは少しハードルが高そうです。

そこで、「GPIO を扱うプログラム」と「GPIO に与える値を計算するプログラム」を別プロセスにする方針を考えてみました。

下図のようなイメージです。

raspberrypi-k3s-non-stop-release-architecture.jpg

この「GPIO を扱うプロセス」から「計算を担当するプロセス」に向けて定期的に HTTP リクエストを送り、その結果を GPIO に出力する流れです。

その上で、サーバ側のプログラムの更新は k3s を使えば簡単に無停止でできそうなので、k3s を使ってみようと思います。

k3s のセットアップ

もし k3s が動かないと方針から考え直しなので、最初に k3s をセットアップして動作確認します。

といってもめちゃくちゃ簡単で、Raspberry Pi 上で以下のコマンドを実行するだけです。

curl -sfL https://get.k3s.io | sh -

この際、私の環境では cgroup の設定の関係でエラーになりましたが、エラーメッセージの通り対処して reboot すれば解決しました。

参考: k3s on pi error - cgroupmemory=1 cgroupenable=memory #2067

ちなみに、もともとは MicroK8s を使おうと思っていたのですが、手こずりそうな雰囲気を感じたため k3s に変更しました。

参考: Microk8s on armhf architecture #719

インストールしたら kubectl runkubectl get pod などでコンテナを動かせることを確認しておきましょう。

通信のインタフェースを決める

実装に入る前に、クライアント側・サーバ側が HTTP で通信するときのインタフェースを決める必要があります。

今回は L チカで済ませるつもりなので何でも良いのですが、今後複数の PIN を扱えるよう、以下のようにしました。

{
  "gpios": [
    {
      "pin": 21,
      "value": 1
    }
  ]
}

value が 1 なら ON、0 なら OFF となります。

クライアント側のプログラム

では、GPIO を扱う、クライアント側に相当するプログラムを用意します。

Python ならライブラリを入れたりせずに GPIO が扱えたので、Python で書いてみました。

import RPi.GPIO as GPIO
import time
import datetime
import requests

URL = 'http://localhost:30000'

def request_gpios():
  response = requests.get(URL, timeout=(1, 1))
  body = response.json()
  now = datetime.datetime.now()
  print(str(now) + ' | body: ' + str(body))
  return body['gpios']

def setup_gpios(gpios):
  for gpio in gpios:
    pin = gpio['pin']
    GPIO.setup(pin, GPIO.OUT)

def output_gpios(gpios):
  for gpio in gpios:
    pin = gpio['pin']
    value = gpio['value']
    GPIO.output(pin, value)

GPIO.setmode(GPIO.BCM)

try:
  gpios = request_gpios()
  setup_gpios(gpios)

  while True:
    gpios = request_gpios()
    output_gpios(gpios)
    time.sleep(0.5)

except KeyboardInterrupt:
  GPIO.cleanup()

0.5 秒ごとにリクエストを送り、その結果を GPIO に出力しているだけです。

サーバ側もできたら、以下のように実行することになります。

$ python main.py
2021-05-08 19:57:36.481347 | body: {u'gpios': [{u'value': 1, u'pin': 21}]}
2021-05-08 19:57:36.491097 | body: {u'gpios': [{u'value': 1, u'pin': 21}]}
2021-05-08 19:57:36.998604 | body: {u'gpios': [{u'value': 0, u'pin': 21}]}
    :

サーバ側のプログラム

次に、サーバ側のプログラムを作成します。

const http = require("http");
const process = require("process");

const PORT = 3000;
const INTERVAL_MILLIS = process.env.INTERVAL_MILLIS || 1000;

const ON_RESPONSE = {
  gpios: [
    {
      pin: 21,
      value: 1,
    },
  ],
};

const OFF_RESPONSE = {
  gpios: [
    {
      pin: 21,
      value: 0,
    },
  ],
};

var response = ON_RESPONSE;

intervalObj = setInterval(() => {
  response = response === ON_RESPONSE ? OFF_RESPONSE : ON_RESPONSE;
}, INTERVAL_MILLIS);

const server = http
  .createServer((req, res) => {
    res.writeHead(200, { "Content-Type": "text/plain" });
    const body = JSON.stringify(response);

    res.end(body);
    const now = Date.now();
    console.log(now + " | body: " + body);
  })
  .listen(PORT, () => console.log("Server http://localhost:" + PORT));

process.on("SIGTERM", () => {
  // リクエストの処理中かもしれないので 5 秒待つ
  setTimeout(() => {
    console.log("Received SIGTERM at " + new Date());
    server.close(() => {
      console.log("Server closed");
    });

    clearInterval(intervalObj);
    console.log("intervalObj cleared");
  }, 5000);
});

クライアント側と比べると少し長いですが、ざっくり言えば、環境変数 INTERVAL_MILLIS で指定した時間ごとにレスポンスの ON/OFF の値が切り替わるだけです。

コードの最後の部分については、k3s でコンテナを更新する際に古いほうのコンテナに飛ぶ SIGTERM をうまくハンドリング (Graceful Shutdown) するための記述です。

試しに実行して curl localhost:3000 のようなリクエストを送ると、以下のようになります。

$ node server.js
Server http://localhost:3000
1620471867537 | body: {"gpios":[{"pin":21,"value":1}]}
1620471868278 | body: {"gpios":[{"pin":21,"value":1}]}
1620471868861 | body: {"gpios":[{"pin":21,"value":0}]}
    :

サーバ側のコンテナイメージを作成

クライアント側、サーバ側のプログラムができたので、サーバ側をコンテナにしようと思います。

コンテナ化したいので、Dockerfile を書きます

FROM node:14.16.1-alpine

COPY server.js /opt/app/

ENTRYPOINT ["node", "/opt/app/server.js"]

docker build して、Docker Hub に push します。

$ docker build -t <イメージ名> .
$ docker login
$ docker push <イメージ名>

k3s へのデプロイ

k3s にデプロイするため、マニフェストファイルを作成します。

apiVersion: v1
kind: Service
metadata:
  labels:
    app: app
  name: app
spec:
  type: NodePort
  selector:
    app: app
  ports:
  - port: 3000
    targetPort: 3000
    nodePort: 30000
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: app
  name: app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: app
  strategy:
    rollingUpdate:
      maxSurge: 50%
      maxUnavailable: 50%
  template:
    metadata:
      labels:
        app: app
    spec:
      containers:
      - name: app
        image: <イメージ名>
        ports:
        - containerPort: 3000
        livenessProbe:
          failureThreshold: 3
          httpGet:
            path: /
            port: 3000
          periodSeconds: 60
          timeoutSeconds: 3
        readinessProbe:
          failureThreshold: 3
          httpGet:
            path: /
            port: 3000
          periodSeconds: 5
          timeoutSeconds: 3
        resources:
          limits:
            cpu: 500m
            memory: 250Mi
          requests:
            cpu: 500m
            memory: 250Mi
        env:
        - name: INTERVAL_MILLIS
          value: "2000"
      restartPolicy: Always
      terminationGracePeriodSeconds: 60

これを適用してしばらく待てば、コンテナが起動しています。

$ kubectl apply -f app.yaml
service/app created
deployment.apps/app created
$ kubectl get po
NAME                   READY   STATUS    RESTARTS   AGE
app-7878c59946-4wfkk   1/1     Running   0          95m

※ 実際にはプライベートなレジストリを使ったので、その設定もしています。

参考: Pull an Image from a Private Registry

クライアント側を Raspberry Pi 上で実行すると、無事疎通します!このとき、LED もチカチカします!

$ python main.py
2021-05-08 20:19:55.393659 | body: {u'gpios': [{u'value': 1, u'pin': 21}]}
2021-05-08 20:19:55.400793 | body: {u'gpios': [{u'value': 1, u'pin': 21}]}
2021-05-08 20:19:55.908066 | body: {u'gpios': [{u'value': 0, u'pin': 21}]}
    :

無停止で更新してみる

では最後に、無停止で更新できるか試します。

本当ならプログラムを書き換えて反映したいのですが、少し手間がかかります。

Deployment の環境変数を変えるだけでも同じ挙動になるので、環境変数の変更で試してみます。

簡易的に kubectl edit deploy app で INTERVAL_MILLIS を 500 に変更すると…

無事、無停止で挙動が切り替わりました!

考察

一応やりたかったことは達成しましたが、この方針でよかったのか少し考えてみようと思います。

今回は「GPIO を扱うプログラム」と「GPIO に与える値を計算するプログラム」を別プロセスにする方針にすることと、k3s を使ったローリングアップデートで、無停止での動作の切り替えを実現しました。

Kubernetes の使い方さえ知っていれば簡単だったので、基本的には悪くない方針だったと思います。

今回の方針の欠点

ただ、大きな欠点として、「GPIO を扱うプログラム」側は無停止で更新できないことが挙げられます。

「GPIO を扱うプログラム」の更新がある際は無停止リリースしている場合じゃない気もするので、これでもいいかもしれませんが、改善する方法も考えておきたいです。

例えば、「GPIO を扱うプロセス」を「リクエストに応じて GPIO を処理するプロセス」と「定期的にリクエストを送るプロセス」に分離し、無停止で更新できないのは「定期的にリクエストを送る」という薄いプログラムだけにするといった方針が考えられます。

他には、「GPIO を扱うプログラム」の新版・旧版を並列稼働させ、特定時刻になったら切り替わるようにする処理を入れる、といった方法でも実現できるかもしれません。

後者の方法はうまく動作するのか検証するのがかなり大変な気がするので、本当にそこまでするのか要検討だと思います。

おわりに

ということで、「Raspberry Pi で L チカしてるプログラムを、k3s を使って無停止で更新する」という記事については以上になります。

説明はスラスラ進んだように書きましたが、実際にはサーバ側のプログラムを Graceful Shutdown させる箇所などで少し手間取ったりしました。

とはいえ、想像していたくらいの時間で一通りできたので、個人的には満足しています。

今後の改良としては、まずは「GPIO を扱うプログラム」の方もコンテナ化してみることが考えられます。

k3s 上のコンテナから GPIO を操作する例はいくつかあったので、たぶんできると思います。

参考

あとは、せっかく k3s を導入していろんなコンテナを連携させたりしやすくなったので、Tensorflow Serving や TorchServe のコンテナと連携させて、機械学習の結果を Raspberry Pi の挙動に反映したりしたら面白いかもしれません。