AWSでRaspberry Piの自動更新システムを構築してみた

その昔学生時代にRaspberry Piを使ったプロトタイピングをクラウドソーシングで引き受けていました。

あまり細かく言えませんがざっくりいうとクラウド連携をする車載システムの開発です。

とりあえずひとしきり必要なプログラムを開発し終わったときに「じゃあそのプログラムになにか修正が入ったらクラウドにアップロードして稼働中のRaspberry Piのプログラムを更新できるようにしてほしい」

というんです。そんなのやったことないよ…

「そもそも当時はRaspberry Piのプログラムを自動実行したことないのでそこから調べないといけないし無理だよな…」って考えていたらいつの間にか契約が切れました。

まぁ依頼主の態度も気に入らなかったので結果オーライといったところでしょうw。

あれから数年経った今ならある程度知識が身についたしそれぐらい簡単にできるよなとふと思い立ちました。

というわけで今回はAWSを使ってRaspberry Piで動かしてるプログラムを自動更新するシステムを構築してみたのでその記録です。

なお、今回はどんな感じで作ったのか流れをざっくりまとめるだけなので細かい手順は自分でググってもらえると🙏(AWSは日々UIが変わるので細かい手順書くのやめた)

用意するもの

システム構成

大まかなシステム構成はこんな感じです。

まず、手元でプログラムを修正したら修正したソースコードをS3にアップロードします。ユーザーが行う作業はこれだけです。

あとはコードがアップロードされたことをトリガーにLambdaを立ち上げてAWS IoT経由で通知を行います。

その後Rapsberry PiでS3からコードをダウンロードしてプログラムの更新を行うという流れです。

f:id:KMiura:20210712231541p:plain

AWS IoTでの作業

モノ(AWS IoT上でのインスタンスみたいなやつ)を作成し、ポリシーも作成します。

モノの名前はraspi_update_callとします。

ポリシーは以下の設定でOKです。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iot:*",
      "Resource": "*"
    }
  ]
}

ポリシーと紐付けた証明書を作成し、証明書をダウンロードします。

Raspberry Piと通信をするために必要なファイルはAmazonRootCA1.pem【証明書ID】-certificate.pem.crt【証明書ID】-private.pem.keyの3つです。

またAWS IoTのトップ画面の設定からデバイスデータエンドポイントをメモしておきます。

細かい作業はこの記事がわかりやすいです。

dev.classmethod.jp

S3の用意

S3バケットを用意します。バケット名はraspi-codesとします。

IAMの作成

ついでにIAMも用意します。以下の設定をしたポリシーを用意します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListAllMyBuckets"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::raspi-codes",
                "arn:aws:s3:::raspi-codes/*"
            ]
        }
    ]
}

SDKの認証で使うため、IAMユーザーを作成するときはプログラムによるアクセスを選択します。

後の設定はデフォルトのままでOKです。IAMユーザーを作成したらアクセスキーIDとシークレットアクセスキーをメモしておきます。

Lambdaの作成

S3を連携するためのLambda関数を作成します。

設計図タブをクリックしてs3-get-object-pythonを選択します。

f:id:KMiura:20210712235631p:plain

ポリシーのアタッチ

Lambda関数の設定→アクセス権限から実行ロールで表示されているロールの編集ボタンをクリックします。

そこから以下の内容のポリシーを新たに追加してLambdaの実行ロールにアタッチします。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "iot:Publish",
            "Resource": "*"
        }
    ]
}

コードの作成

Lambdaは以下のソースコードに書き換えます。

import json
import urllib.parse
import boto3

print('Loading function')

s3 = boto3.client('s3')
iot = boto3.client('iot-data', region_name="ap-northeast-1")


def lambda_handler(event, context):
    # print("Received event: " + json.dumps(event, indent=2))

    # Get the object from the event and show its content type
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'], encoding='utf-8')
    try:
        print(key)
        iot.publish(
            topic="raspberry_pi/msg",
            qos=0,
            payload=key
        )
        return 'Success'
    except Exception as e:
        print(e)
        print('publish Failed')
        raise e

Raspberry Piでの作業

ここからRaspberry Piでの作業です。

SDKCLIをインストール

SDKのインストール

S3の接続に使うboto3とAWS IoTのSDKをインストールします。

sudo pip3 install boto3 AWSIoTPythonSDK

CLIのインストール

AWSCLIツールをインストールします。

sudo pip3 install awscli

Cofigureを設定

aws configure

access key IDとsecret access key、リージョンを設定します。

リージョンは使用するS3のリージョンを設定します。

プログラムを更新するためのコード

以下のレポジトリのsample/basicPubSub/basicPubSub.pyを参考にS3から受け取った通知に合わせてプログラムを更新するコードを作成します。

github.com

以下のソースコードと同じディレクトリ上にcertディレクトリを用意して、certディレクトリにAWS IoTの作業のときにダウンロードした3つの認証ファイルを保存します。

hostの値は先程メモしたデバイスデータエンドポイントに書き換えます。

ファイル名は、subscriber.pyとします。

'''
/*
 * Copyright 2010-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file is distributed
 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */
 '''

from AWSIoTPythonSDK.MQTTLib import AWSIoTMQTTClient
import boto3
import subprocess
import logging
import time


s3 = boto3.resource('s3')
bucket = s3.Bucket('raspi-codes')

# Custom MQTT message callback
def customCallback(client, userdata, message):
    msg = message.payload.decode()
    print("Received a new message: ")
    print(msg)
    print("from topic: ")
    print(message.topic)
    print("--------------\n\n")
    
    if msg == 'disp_7seg.py':
        updateCode()

def updateCode():
    file_name = '/home/pi/disp_7seg.py'
    try:
        subprocess.call(['rm', file_name])
        bucket.download_file('disp_7seg.py', file_name)
        subprocess.call(['sudo', 'chmod', '755', file_name])
        subprocess.call(['sudo', 'systemctl', 'restart', 'disp_7seg.service'])
        print('Success')
    except Exception as e:
        print('Failed')
        print(e)


host = 'yourhost.iot.ap-northeast-1.amazonaws.com'
rootCAPath = 'cert/AmazonRootCA1.pem'
certificatePath = 'cert/certificate.pem.crt'
privateKeyPath = 'cert/private.pem.key'


clientId = 'raspi_update_call'
topic = 'raspberry_pi/msg'
port = 8883

# Configure logging
logger = logging.getLogger("AWSIoTPythonSDK.core")
logger.setLevel(logging.DEBUG)
streamHandler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
streamHandler.setFormatter(formatter)
logger.addHandler(streamHandler)

# Init AWSIoTMQTTClient
myAWSIoTMQTTClient = AWSIoTMQTTClient(clientId)
myAWSIoTMQTTClient.configureEndpoint(host, port)
myAWSIoTMQTTClient.configureCredentials(rootCAPath, privateKeyPath, certificatePath)

# AWSIoTMQTTClient connection configuration
myAWSIoTMQTTClient.configureAutoReconnectBackoffTime(1, 32, 20)
myAWSIoTMQTTClient.configureOfflinePublishQueueing(-1)  # Infinite offline Publish queueing
myAWSIoTMQTTClient.configureDrainingFrequency(2)  # Draining: 2 Hz
myAWSIoTMQTTClient.configureConnectDisconnectTimeout(10)  # 10 sec
myAWSIoTMQTTClient.configureMQTTOperationTimeout(5)  # 5 sec

# Connect and subscribe to AWS IoT
myAWSIoTMQTTClient.connect()
myAWSIoTMQTTClient.subscribe(topic, 1, customCallback)
time.sleep(2)

# Publish to the same topic in a loop forever
while True:
    time.sleep(1)

7セグLEDを表示するプログラム

Pythonコード

以下の記事を参考にコードを作成します。

denkisekkeijin.com

使用する7セグLEDはアノードコモンなのでそれを考慮して以下のコードを保存します。

ファイル名はdisp_7seg.pyです。保存先は/home/piです。

#!/usr/bin/env python3
import RPi.GPIO as GPIO
GPIO.cleanup()

GPIO.setmode(GPIO.BCM)
GPIO.setup(23, GPIO.OUT)
GPIO.setup(24, GPIO.OUT)
GPIO.setup(25, GPIO.OUT)
GPIO.setup(12, GPIO.OUT)
GPIO.setup(16, GPIO.OUT)
GPIO.setup(20, GPIO.OUT)
GPIO.setup(21, GPIO.OUT)

z_0 = [1, 1, 1, 0, 1, 1, 1]
z_1 = [1, 0, 0, 0, 1, 0, 0]
z_2 = [1, 1, 0, 1, 0, 1, 1]
z_3 = [1, 1, 0, 1, 1, 1, 0]
z_4 = [1, 0, 1, 1, 1, 0, 0]
z_5 = [0, 1, 1, 1, 1, 1, 0]
z_6 = [0, 1, 1, 1, 1, 1, 1]
z_7 = [1, 1, 0, 0, 1, 0, 0]
z_8 = [1, 1, 1, 1, 1, 1, 1]
z_9 = [1, 1, 1, 1, 1, 0, 0]

numbers = [z_0, z_1, z_2, z_3, z_4, z_5, z_6, z_7, z_8, z_9]


def set_segment(s_or, s_om, s_ol, s_mm, s_ur, s_um, s_ul):
    GPIO.output(23, GPIO.HIGH if s_or == 0 else GPIO.LOW)
    GPIO.output(24, GPIO.HIGH if s_om == 0 else GPIO.LOW)
    GPIO.output(25, GPIO.HIGH if s_ol == 0 else GPIO.LOW)
    GPIO.output(12, GPIO.HIGH if s_mm == 0 else GPIO.LOW)
    GPIO.output(16, GPIO.HIGH if s_ur == 0 else GPIO.LOW)
    GPIO.output(20, GPIO.HIGH if s_um == 0 else GPIO.LOW)
    GPIO.output(21, GPIO.HIGH if s_ul == 0 else GPIO.LOW)


while True:
    try:
        set_segment(*z_0)
    except KeyboardInterrupt:
        break

GPIO.cleanup()

配線

Raspberry Piと7セグLEDの配線は以下の図のとおりです。

f:id:KMiura:20210713034645p:plain

Serviceファイル

プログラムを実行するときにはsystemctlを使います。

そこで、以下のserviceファイルをdisp_7seg.serviceで保存します。

保存先は/etc/systemd/systemです。

[Unit]
Description=Display 7seg LED

[Service]
ExecStart=/home/pi/disp_7seg.py
Environment=PYTHONUNBUFFERED=1
Restart=on-failure

[Install]
WantedBy=default.target

動作確認

いよいよ動作確認です。

7セグLEDを表示するプログラムを起動

はじめに以下のコマンドでdisp_7seg.pyに対して実行権限を変更します。

sudo chmod 755 ~/disp_7seg.py

次に以下のコマンドでデーモンを再読み込みして7セグLEDを起動します。

sudo systemctl daemon-reload
sudo systemctl start disp_7seg.service

実行後に以下のように7セグLEDで0を表示したらプログラムは正常に動作しています。

f:id:KMiura:20210713031742p:plain

プログラムを更新するコードを起動

subscriber.pyの保存先ディレクトリ上で以下のコマンドでプログラムを実行します。

python3 subscriber.py

今回はプログラムが更新する様子をデモで見せるためにserviceファイルを用意しませんでしたが、disp_7seg.pyと同様にsystemctlを用意して自動起動してもいいと思います。

S3にソースコードをアップロードする

disp_7seg.pyになにか修正を加えてS3バケットにアップロードしてみます。今回は、以下の数字の1を表示するプログラムに書き換えます。

#!/usr/bin/env python3
import RPi.GPIO as GPIO
GPIO.cleanup()

GPIO.setmode(GPIO.BCM)
GPIO.setup(23, GPIO.OUT)
GPIO.setup(24, GPIO.OUT)
GPIO.setup(25, GPIO.OUT)
GPIO.setup(12, GPIO.OUT)
GPIO.setup(16, GPIO.OUT)
GPIO.setup(20, GPIO.OUT)
GPIO.setup(21, GPIO.OUT)

z_0 = [1, 1, 1, 0, 1, 1, 1]
z_1 = [1, 0, 0, 0, 1, 0, 0]
z_2 = [1, 1, 0, 1, 0, 1, 1]
z_3 = [1, 1, 0, 1, 1, 1, 0]
z_4 = [1, 0, 1, 1, 1, 0, 0]
z_5 = [0, 1, 1, 1, 1, 1, 0]
z_6 = [0, 1, 1, 1, 1, 1, 1]
z_7 = [1, 1, 0, 0, 1, 0, 0]
z_8 = [1, 1, 1, 1, 1, 1, 1]
z_9 = [1, 1, 1, 1, 1, 0, 0]

numbers = [z_0, z_1, z_2, z_3, z_4, z_5, z_6, z_7, z_8, z_9]


def set_segment(s_or, s_om, s_ol, s_mm, s_ur, s_um, s_ul):
    GPIO.output(23, GPIO.HIGH if s_or == 0 else GPIO.LOW)
    GPIO.output(24, GPIO.HIGH if s_om == 0 else GPIO.LOW)
    GPIO.output(25, GPIO.HIGH if s_ol == 0 else GPIO.LOW)
    GPIO.output(12, GPIO.HIGH if s_mm == 0 else GPIO.LOW)
    GPIO.output(16, GPIO.HIGH if s_ur == 0 else GPIO.LOW)
    GPIO.output(20, GPIO.HIGH if s_um == 0 else GPIO.LOW)
    GPIO.output(21, GPIO.HIGH if s_ul == 0 else GPIO.LOW)


while True:
    try:
        set_segment(*z_1)
    except KeyboardInterrupt:
        break

GPIO.cleanup()

起動すると以下の動画のように0と表示していた7セグLEDが1に変わることが確認できたら成功です。


www.youtube.com

まとめ

今回はRaspberry Pi上で動かしているプログラムを自動更新するシステムをAWSで作ってみました。

今までの知識を駆使すれば意外と簡単でした。ただ、AWS IoTのUIが変化していたので過去の記憶を頼りにカンで作業を進めたら思いの外時間がかかりました。

これをあの頃思いついていたら良かったなーw