Penpen7のブログ

Penpen7のエンジニアブログ

🌡️ 室内環境をRaspberry Pi+Docker+grafanaで可視化する

はじめに

以下でRaspberry Pi+BME680+MH-Z19C+Python+Ambientを使って室内環境を可視化したわけですが、

より良い可視化ツールを使いたかったのと、コンテナで快適に開発を進めたかったので、様々挑戦してみました。
実装はこちらにおいています。

https://github.com/Penpen7/my-room-sensor

構成図

構成図は以下のようになっており、ラズパイ上にdockerコンテナを立ち上げコンテナ内で大まかな処理は完結するようになっています。
コンテナではセンサーの情報取得、データの永続を行い、室内の情報を居住者が手軽に確認できるようにシステムを構成してあります。

コンテナはdocker composeを使用して以下3つのコンテナを立ち上げています。

| grafana | センサーの情報をダッシュボード上に可視化するためのコンテナ。
内部ではgrafanaを動かしています。 |
| ------- | ------------------------------------------------------ |
| db | センサーの情報を永続化するデータベース。
時系列データの処理が得意なinfluxDBを載せています。 |
| sensor | センサーの情報を取得して解析・dbへ永続化してくれるコンテナ。
Rustで作られたバイナリが動作しています。 |

Grafana

Grafana(グラファナ)は、分析およびインタラクティブな視覚化を可能にする、マルチプラットフォームで動作するオープンソースのWebアプリケーションである。

Grafanaを使うとブラウザ上で各種メトリクスをグラフ化したり、アラート機能を利用することができます。様々なデータソースに対応しており、InfluxDBやDynamoDBなどさまざまなデータベースを利用することができます。
今回は温度や湿度などセンサーから取れるメトリクスや不快指数などをグラフ化しています。以下に自分が作成したダッシュボードのスクリーンショットを貼っておきます。

公式からGrafana EnterpriseバージョンのイメージがDockerHubにて公開されていますので、それを使用することとします。

compose.yamlでは以下のように設定を記述しています。

grafana:
  image: grafana/grafana-enterprise:9.5.19-ubuntu
  container_name: grafana
  environment:
    - GF_AUTH_ANONYMOUS_ENABLED=true
  ports:
    - "80:3000"
  volumes:
    - "grafana_storage:/var/lib/grafana"
    - "./grafana/datasources:/etc/grafana/provisioning/datasources"
    - "./grafana/dashboard-settings:/etc/grafana/provisioning/dashboards"
    - "./grafana/dashboards:/var/lib/grafana/dashboards"
  depends_on:
    - "db"

environmentで環境変数としてGF_AUTH_ANONYMOUS_ENABLED=true と設定すると、Grafanaにログインせずともダッシュボードを閲覧できます。このような設定をしたのは、家族がいつでも簡単にダッシュボードを見れるようにしておきたかったからです。
ポートフォワーディングではホストの80番ポートをコンテナ内の3000番ポートに転送しています。

ports:
    - "80:3000"

なんの変哲もない設定なのですが、ここで注意しないといけないのはcompose.yamlで以上の記述を行った場合、80番ポートが外部に公開され、ホスト外部からコンテナ内のアプリケーションにアクセスできてしまうというところです。
家のWi-Fiであればこのような設定でも問題ない可能性が高いのですが、不特定多数の人間が接続するような公共Wi-Fiだとセキュリティ的に問題があるので注意しましょう。
同じ家のネットワークのクライアントからraspberrypi.localに繋げにいくとGrafanaのページを手軽に見られるようにしたかったので、今回はこのまま進めてしまうことにします。
またvolumesで以下のようにバインドマウントの設定を行なっています。

- "./grafana/datasources:/etc/grafana/provisioning/datasources"
- "./grafana/dashboard-settings:/etc/grafana/provisioning/dashboards"
- "./grafana/dashboards:/var/lib/grafana/dashboards"

Grafanaでは/etc/grafana/provisioning/{datasources,dashboards}にファイルを配置すると初期化時に読んでくれて、ダッシュボードやデータソースの設定を自動的に投入してくれます。
設定をちまちまGUI上で作るのは面倒だったのでIaC的に設定を投入できるのは非常に便利です。
詳細は以下のドキュメントにあります。

InfluxDB

InfluxDB is the most popular open source database for developers managing time series data. Unlock real-time insights from time series data at any scale in any environment – in the cloud, on-prem, or at the edge.

InfluxDBはInfluxData社が開発する時系列データに特化したオープンソースデータベースです。
今回は気温・温度などのメトリクスを定期的に取得しているため時系列データの処理を得意としたデータベースに値を永続化しています。
ちなみにInfluxDBはRustで作られています!

https://github.com/influxdata/influxdb


compose.yamlによる設定は以下のように記述しています。

db:
  image: influxdb:2.7.6-alpine
  container_name: influxdb
  ports:
    - "8086:8086"
  volumes:
    - "influxdb_storage:/var/lib/influxdb"
  environment:
    - DOCKER_INFLUXDB_INIT_MODE=setup
    - DOCKER_INFLUXDB_INIT_USERNAME=db
    - DOCKER_INFLUXDB_INIT_PASSWORD=12345678
    - DOCKER_INFLUXDB_INIT_ORG=prg
    - DOCKER_INFLUXDB_INIT_BUCKET=prg
    - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=token
  healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost:8086/ping"]
    interval: 30s
    timeout: 20s
    retries: 3

environmentでは環境変数で以下のように設定を加えることができます。

- DOCKER_INFLUXDB_INIT_MODE=setup
- DOCKER_INFLUXDB_INIT_USERNAME=db
- DOCKER_INFLUXDB_INIT_PASSWORD=12345678
- DOCKER_INFLUXDB_INIT_ORG=prg
- DOCKER_INFLUXDB_INIT_BUCKET=prg
- DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=token
環境変数名 概要
DOCKER_INFLUXDB_INIT_MODE setupかupgrade。upgradeはv1のデータをv2にアップグレードする際に使用。それ以外はsetup。
DOCKER_INFLUXDB_INIT_USERNAME admin用のユーザ名
DOCKER_INFLUXDB_INIT_PASSWORD admin用のパスワード
DOCKER_INFLUXDB_INIT_ORG 初期organization名
DOCKER_INFLUXDB_INIT_BUCKET 初期バケット名
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN トークン(指定しなければ自動生成)
DOCKER_INFLUXDB_INIT_RETENTION データの保持期間(指定しなければ削除されない)

詳しくはhttps://hub.docker.com/_/influxdb のStart InfluxDB v2 with automated setupに書いてあります。

Sensor

センサーの情報を定期的に取得し、InfluxDBに送信するためのコードです。Rustで書いています。

sensor:
  build:
    context: ./sensor
    dockerfile: Dockerfile
    target: runner_arm_musl
  environment:
    - ENV=prod
    - INFLUXDB_URL=http://db:8086
    - INFLUXDB_TOKEN=token
    - INFLUXDB_BUCKET=prg
    - INFLUXDB_USERNAME=db
    - INFLUXDB_PASSWORD=12345678
  devices:
    - "/dev/i2c-1:/dev/i2c-1"
    - "/dev/ttyS0:/dev/ttyS0"
  depends_on:
    - "db"
  tty: true

環境変数で認証情報を取得できるよう設定してあります

environment:
  - ENV=prod
  - INFLUXDB_URL=http://db:8086
  - INFLUXDB_TOKEN=token
  - INFLUXDB_BUCKET=prg
  - INFLUXDB_USERNAME=db
  - INFLUXDB_PASSWORD=12345678

通常、Dockerコンテナとホストは独立した環境に置かれるためホストにつながっているセンサーの情報を取得することはできません。
コンテナからもセンサーの情報を取得できるようにdevicesでマウントします。

devices:
  - "/dev/i2c-1:/dev/i2c-1"
  - "/dev/ttyS0:/dev/ttyS0"

Dockerfile

FROM rust:1.79.0-bookworm as base
WORKDIR /app
RUN apt update && apt install -y build-essential pkg-config libudev-dev

FROM rust:1.79.0-bookworm as tools
WORKDIR /app
RUN apt update && apt install -y curl
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
RUN cargo binstall -y cargo-chef cargo-watch

FROM base as planner
COPY --from=tools /usr/local/cargo/bin/cargo-chef /usr/local/cargo/bin/cargo-chef
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM base as cacher_develop
COPY --from=tools /usr/local/cargo/bin/cargo-chef /usr/local/cargo/bin/cargo-chef
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --recipe-path recipe.json

FROM base as develop
WORKDIR /app
EXPOSE 8080
RUN rustup component add clippy rustfmt
COPY --from=tools /usr/local/cargo/bin/cargo-watch /usr/local/cargo/bin/cargo-watch
COPY --from=cacher_develop /app/target /app/target
CMD cargo watch -x run

FROM base as cacher_arm_musl
COPY --from=tools /usr/local/cargo/bin/cargo-chef /usr/local/cargo/bin/cargo-chef
COPY --from=planner /app/recipe.json recipe.json
RUN apt install -y g++-aarch64-linux-gnu
RUN rustup target add aarch64-unknown-linux-musl
RUN cargo chef cook --recipe-path recipe.json --release --target aarch64-unknown-linux-musl

FROM cacher_arm_musl as builder_arm_musl
WORKDIR /app
COPY . /app
RUN cargo build --release --target aarch64-unknown-linux-musl
WORKDIR /app/target/aarch64-unknown-linux-musl/release
CMD ["sleep", "infinity"]

FROM debian:stable-20240612-slim as runner_arm_musl
WORKDIR /app
COPY ./my-room-sensor /app
CMD ["./my-room-sensor"]
  • イメージのサイズ削減・ビルド時のキャッシュの有効活用のためマルチステージングでビルドを行っています
  • cargo-chefを使用することでDockerのキャッシュを効かせ、ビルド速度を高速化しています

https://github.com/LukeMathWalker/cargo-chef

  • 開発環境上ではdevelopターゲットを使用することにします
    • cargo-watchでホットリロードが効くようになっていて便利です

https://github.com/watchexec/cargo-watch

  • clippyやrustfmtも入れてあります
  • 本番環境(ラズパイ)上でコンテナをビルドするとRustのコンパイルがめちゃくちゃ遅いため、コンパイルを事前に済ませておき、ラズパイ上ではバイナリを使用するだけの状態にしておきます
    • Dockerfileのbuilder_arm_muslターゲットを使用してarmで静的リンクされたバイナリを事前に作っておきます。

      #!/bin/bash -e
      
      docker build -t sensor:latest --target builder_arm_musl .
      
      # run the container and get container id
      container_id=$(docker run -d sensor:latest)
      
      # get binary from container
      docker cp $container_id:/app/target/aarch64-unknown-linux-musl/release/my-room-sensor ./my-room-sensor
      
      # stop and remove container
      docker stop $container_id
      docker rm $container_id
      
    • ラズパイ上ではrunner_arm_muslターゲットを使用して、事前に作成したバイナリをコピーするだけで動くようにしてあります。

簡易的なクラス図

main.rs

use my_room_sensor::bme680::BME680;
use my_room_sensor::influx_db::InfluxDBNotification;
use my_room_sensor::mh_z19::Mhz19;
use my_room_sensor::run;
use my_room_sensor::sensors::{MockSensor, Sensors};
use std::cell::RefCell;
use std::rc::Rc;

#[tokio::main]
async fn main() {
    let env = std::env::var("ENV").unwrap();
    let url = std::env::var("INFLUXDB_URL").unwrap();
    let bucket = std::env::var("INFLUXDB_BUCKET").unwrap();
    let token = std::env::var("INFLUXDB_TOKEN").unwrap();

    let notification = InfluxDBNotification::new(&url, &bucket, &token).await;
    
    // 環境ごとに実装を切り替え
    match env.as_str() {
        "local" => {
            let mock_sensor = Rc::new(RefCell::new(MockSensor::new()));
            let sensor = Sensors::new(
                mock_sensor.clone(),
                mock_sensor.clone(),
                mock_sensor.clone(),
                mock_sensor.clone(),
                mock_sensor,
            )
            .unwrap();
            // 下のrunの呼び出しとまとめて一つにしたかったが、コンパイルエラーでうまく動かず...
            run(sensor, vec![Box::new(notification)]).await;
        }
        _ => {
            let mhz19 = Rc::new(RefCell::new(Mhz19::new("/dev/ttyS0").unwrap()));
            let bme680 = BME680::new("/dev/i2c-1").unwrap();
            let bme680 = Rc::new(RefCell::new(bme680));
            let sensor = Sensors::new(
                bme680.clone(),
                bme680.clone(),
                bme680.clone(),
                bme680,
                mhz19,
            )
            .unwrap();
            run(sensor, vec![Box::new(notification)]).await;
        }
    };
}
  • 環境変数から設定を読み取って、lib.rsに記述してある本体の処理を実行するようになっています。

  • 開発環境の場合はモック、本番環境(ラズパイ)の場合には実際のセンサーの値を読み取るように依存性注入(DI)を行っています。

    // 環境ごとに実装を切り替え
        match env.as_str() {
            "local" => {
    						// ローカル開発環境の場合(モックする)
            }
            _ => {
    						// ラズパイの場合
            }
    
    • センサーがつながっていない環境(Mac)でも開発したいという思いがあり、センサーがなくても開発が進められるよう実装をモックしています。

    • Sensorsはセンサーに関わるDIコンテナになっており、内部でSensors::newで具体実装を受け取るようになっています

      pub struct Sensors<T, H, P, G, C> {
          temperature_sensor: Rc<RefCell<T>>,
          humidity_sensor: Rc<RefCell<H>>,
          pressure_sensor: Rc<RefCell<P>>,
          gas_resistance_sensor: Rc<RefCell<G>>,
          co2_density_sensor: Rc<RefCell<C>>,
      }
      
      // 以下のトレイトを満たす構造体を保持するDIコンテナ
      pub trait TemperatureSensor {
          fn read_temperature(&mut self) -> Result<f32, Box<dyn std::error::Error>>;
      }
      
      pub trait HumiditySensor {
          fn read_humidity(&mut self) -> Result<f32, Box<dyn std::error::Error>>;
      }
      
      pub trait PressureSensor {
          fn read_pressure(&mut self) -> Result<f32, Box<dyn std::error::Error>>;
      }
      
      pub trait GasResistanceSensor {
          fn read_gas_resistance(&mut self) -> Result<u32, Box<dyn std::error::Error>>;
      }
      
      pub trait CO2DensitySensor {
          fn read_co2_density(&mut self) -> Result<u16, Box<dyn std::error::Error>>;
      }
      
  • Rc<RefCell<T>>を初めて使いましたが、なかなか便利ですね

    • DIする際には所有権が問題になってくるわけですが、スマートポインタを使って解決しました
    • 以下の比較表を見ながら、共有と書き換えができれば大丈夫だなとなってRc<RefCell<T>>>を選択しました
  • InfluxDBのクライアントの設定もここで行っています。

    let notification = InfluxDBNotification::new(&url, &bucket, &token).await;
    

lib.rs

pub mod bme680;
mod cache;
pub mod influx_db;
pub mod mh_z19;
pub mod notification;
pub mod sensors;

use crate::notification::Notification;
use crate::sensors::Sensors;

pub async fn run<T, H, P, G, C>(
    mut sensors: Sensors<T, H, P, G, C>,
    notification: Vec<Box<dyn Notification>>,
) where
    T: sensors::TemperatureSensor,
    H: sensors::HumiditySensor,
    P: sensors::PressureSensor,
    G: sensors::GasResistanceSensor,
    C: sensors::CO2DensitySensor,
{
    loop {
        let temperature = sensors.read_temperature().unwrap();
        let humidity = sensors.read_humidity().unwrap();
        let pressure = sensors.read_pressure().unwrap();
        let gas_resistance = sensors.read_gas_resistance().unwrap();
        let co2_density = sensors.read_co2_density().unwrap();
        let di = 0.81 * temperature + 0.01 * humidity * (0.99 * temperature - 14.3) + 46.3;

        println!(
            "Temperature: {}°C, Humidity: {}%, Pressure: {}hPa, Gas Resistance: {}Ω, CO2 Density: {}ppm",
            temperature, humidity, pressure, gas_resistance, co2_density
        );
        for n in notification.iter() {
            n.send_notification(
                temperature,
                humidity,
                pressure,
                gas_resistance,
                co2_density,
                di,
            )
            .await
            .unwrap();
        }

        std::thread::sleep(std::time::Duration::from_secs(5));
    }
}
  • run関数だけ定義してあり5秒ごとにセンサーの値を読み取って、InfluxDBに保存し続ける処理を書いています。

    pub async fn run(mut sensors, notification) 
    {
        loop { // 無限ループ
            // センサーの値を読み取り
    
            // センサーの値を外部に通知(InfluxDB)
    
            // 5秒待機
            std::thread::sleep(std::time::Duration::from_secs(5));
        }
    }
    
    • どうやって外部に値を通知するかはトレイトによって抽象化され意識しないように作られています。
  • センサーの値を読み取る処理はDIコンテナのSensorsのメソッドを通して行います

    let temperature = sensors.read_temperature().unwrap();
    let humidity = sensors.read_humidity().unwrap();
    let pressure = sensors.read_pressure().unwrap();
    let gas_resistance = sensors.read_gas_resistance().unwrap();
    let co2_density = sensors.read_co2_density().unwrap();
    let di = 0.81 * temperature + 0.01 * humidity * (0.99 * temperature - 14.3) + 46.3;
    
    • Sensorsの温度をとるメソッドは、実際にどう値を取るかは具体実装の構造体に処理を委譲しています

      pub struct Sensors<T, H, P, G, C> {
          temperature_sensor: Rc<RefCell<T>>,
          // 略
      }
      
      impl<T, H, P, G, C> Sensors<T, H, P, G, C> 
      where
          T: TemperatureSensor,
          // 略
      {
          // 略
          pub fn read_temperature(&mut self) -> Result<f32, Box<dyn std::error::Error>> {
              // temperature_sensorのread_temperatureを呼び出しているだけ
              self.temperature_sensor.borrow_mut().read_temperature()
          }
      }
      
  • センサーの値を外部に通知する処理はNotificationトレイトのVecを順番に呼び出します。

    • Notificationトレイトは以下のメソッドのみ定義するよう構造体に要請します。

      #[async_trait::async_trait]
      pub trait Notification {
          async fn send_notification(
              &self,
              temperature: f32,
              humidity: f32,
              pressure: f32,
              gas_resistance: u32,
              co2_density: u16,
              discomfort_index: f32,
          ) -> Result<(), Box<dyn std::error::Error>>;
      }
      
    • 利用者側はforで回しながらメソッドを呼び出すだけで具体的にどのような処理が走るかは意識せずに済みます。

      for n in notification.iter() {
          n.send_notification(
              temperature,
              humidity,
              pressure,
              gas_resistance,
              co2_density,
              di,
          )
          .await
          .unwrap();
      }
      

その他具体実装

BME680やMH-Z19Cのセンサーからのデータの読み取り、InfluxDBへのデータ送信の具体実装は以下のソースコードをご覧ください。

https://github.com/Penpen7/my-room-sensor/blob/main/sensor/src/bme680.rs

https://github.com/Penpen7/my-room-sensor/blob/main/sensor/src/mh_z19.rs

https://github.com/Penpen7/my-room-sensor/blob/main/sensor/src/influx_db.rs

常駐化

このままだとラズパイを再起動させた際にいちいち起動し直さないといけないため、サービス化してやります。

  • 以下のような内容のmy-room-sensor.serviceを作成します。

    [Unit]
    Description=managed by docker-compose
    Requires=docker.service
    
    [Service]
    Type=simple
    
    WorkingDirectory=/home/naoki/docker-grafana
    ExecStart=/usr/bin/docker compose up --abort-on-container-exit
    ExecStop=/usr/bin/docker compose stop
    TimeoutStartSec=10min
    Restart=always
    RestartSec=10s
    User=naoki
    
    [Install]
    WantedBy=multi-user.target
    
  • systemctlを使用してサービス化します

    sudo cp my-room-sensor.service /etc/systemd/system/
    sudo systemctl daemon-reload
    sudo systemctl enable my-room-sensor
    sudo systemctl start my-room-sensor
    

まとめ

Docker化することでラズパイが手元にない環境でも気軽に開発できるようになりました!
あとはRustのコードをゴリゴリ書けたので勉強になったのと、やっぱりRustを書いていくのは楽しいなぁと思いました。
コードのリファクタなどまだまだ改善できる点はあると思うので、時間があるときに直していこうと思っています。

参考文献

© Penpen7