🌡️ 室内環境をRaspberry Pi+Docker+grafanaで可視化する
はじめに
以下でRaspberry Pi+BME680+MH-Z19C+Python+Ambientを使って室内環境を可視化したわけですが、
より良い可視化ツールを使いたかったのと、コンテナで快適に開発を進めたかったので、様々挑戦してみました。
実装はこちらにおいています。
構成図
構成図は以下のようになっており、ラズパイ上に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で作られています!

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 /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 /usr/local/cargo/bin/cargo-chef /usr/local/cargo/bin/cargo-chef
COPY /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 /usr/local/cargo/bin/cargo-watch /usr/local/cargo/bin/cargo-watch
COPY /app/target /app/target
CMD cargo watch -x run
FROM base as cacher_arm_musl
COPY /usr/local/cargo/bin/cargo-chef /usr/local/cargo/bin/cargo-chef
COPY /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のキャッシュを効かせ、ビルド速度を高速化しています
- 開発環境上ではdevelopターゲットを使用することにします
- 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へのデータ送信の具体実装は以下のソースコードをご覧ください。
常駐化
このままだとラズパイを再起動させた際にいちいち起動し直さないといけないため、サービス化してやります。
-
以下のような内容の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のブログ