はじめに
第1回で基板を作り、第2回でその基板から Wi-Fi 経由でデータを飛ばしました。では、飛ばした先には何があるのか。 それが今回の話です。
センサーの値を 60 秒ごとに投げつけても、受け止める相手がいなければ何も残りません。今回作るのは、受け止める人(MQTT ブローカー)・仕分ける人(Telegraf)・貯める人(InfluxDB) の3人組です。全部 Docker Compose で1つのディレクトリにまとめました。
そして、この3人組の安全を決めているのが、MQTT の ACL に出てくる %u という2文字です。たった2文字で「1台の鍵が漏れても、他の機体になりすませない」という性質が手に入ります。実際に自分でなりすましを試して、遮断されることを確かめたところまで書きます。
自作のセンサー基板から Grafana のダッシュボードまで、自宅の環境を測る仕組みを丸ごと作る連載です。基板 → ファームウェア → サーバー → 可視化の4回で、1枚の基板が部屋のグラフになるところまで通します。
- 第1回 基板設計・製造 — KiCad で回路図を描いて JLCPCB に発注し、全部手はんだで組む
- 第2回 ファームウェア — ESP-IDF でセンサ3種のドライバを自前で書き、MQTT で飛ばす
- 第3回 サーバー(この記事)— Mosquitto・Telegraf・InfluxDB の受け皿を Docker で立てる
- 第4回 ダッシュボード — Grafana の8パネルで、部屋の空気を1画面にする
KiCad / JLCPCB / 手はんだ"] --> B["第2回 ファームウェア
ESP-IDF / I2Cドライバ
Wi-Fi + MQTT送信"] B --> C["第3回 サーバー
(この記事)
Mosquitto / Telegraf
InfluxDB"] C --> D["第4回 ダッシュボード
Grafana"] style C fill:#e3f2fd
📝 この記事でやること
- MQTT・時系列データベース・Telegraf が何者なのかを、使う前に整理する
- Docker Compose で3サービスを立てる。ポートをどのアドレスにバインドするかの設計判断
- UFW は Docker の公開ポートに効かない。 ではどう守るのか
- ACL の
%u置換でなりすましを構造的に潰す。自分で6パターン試した結果つき - ACL 違反の publish は、エラーにならず黙って捨てられる。 この罠の見つけ方
- パスワードを画面にも
psにも出さずに発行するスクリプト - InfluxDB のトークンを3本まとめて露出させた事故と、その後始末
Raspberry Pi や自宅サーバーで Docker を少し触ったことがある方を想定しています。MQTT も InfluxDB も触ったことがない、という前提で書いているので、まず「それは何をするものなのか」から始めます。
🧭 前提:MQTT・時系列DB・Telegraf とは
サーバーの中身に入る前に、登場人物3人の役割をはっきりさせておきます。すでにご存じの方は次の章へ飛んでください。
MQTT — 郵便受けに投げ込む通信
MQTT(MQ Telemetry Transport)は、IoT でよく使われる軽量なメッセージングプロトコルです。HTTP と決定的に違うのは、送る側と受け取る側が互いを知らないことです。
センサー基板は「サーバーの /api/telemetry に POST する」のではなく、home/sensor/sensor_001/telemetry という名前の郵便受けに投げ込むだけ。その郵便受けを見張っている人が誰なのか、何人いるのか、基板は知りません。この仲介役がブローカー(今回は Mosquitto)です。
publisher"] -->|"publish
home/sensor/sensor_001/telemetry"| B["ブローカー
Mosquitto"] P2["IR基板
publisher"] -->|"publish
home/ir/ir_001/state"| B B -->|"subscribe
home/sensor/+/telemetry"| S1["Telegraf
subscriber"] B -->|"subscribe
home/#"| S2["手元の確認用
subscriber"] style B fill:#e3f2fd
- publish(発行)/ subscribe(購読):投げる側と受け取る側。両者は直接つながらず、必ずブローカーを経由します
- トピック:郵便受けの名前。
home/sensor/sensor_001/telemetryのようにスラッシュで階層を作ります。購読側は+(1階層のワイルドカード)や#(以下すべて)が使えます - QoS:配送保証のレベル。0 は投げっぱなし、1 は「最低1回は届く」(重複しうる)、2 は「ちょうど1回」。この連載では QoS1 を使っています
- retain(保持):ブローカーがトピックごとに最後の1通を保存しておく仕組み。後から購読した人も、次の送信を待たずに最新の状態を受け取れます
- LWT(Last Will and Testament=遺言):クライアントが予告なく切れたときに、ブローカーが代わりに送ってくれるメッセージ。「この機体は落ちました」を自動で通知できます
第2回で書いたファームウェアが使うトピックは、2本だけです。
| トピック | 中身 | retain | 用途 |
|---|---|---|---|
home/sensor/<device_id>/telemetry |
測定値(数値のみ) | false | 60秒ごとの本体データ |
home/sensor/<device_id>/state |
状態(文字列) | true | 版数・IP・センサ状態・死活。LWT もここ |
なぜ HTTP POST ではなく MQTT なのか。 以前 ESP32 から HTTP で JSON を POST する記事も書きましたが、あの方式は1回ごとに接続を張り直します。60 秒ごとに一生投げ続ける用途では、接続を張りっぱなしにできる MQTT のほうが素直です。受け手が増えたときに送信側を触らなくていい、という点も効きます。
時系列データベース — なぜ RDB ではないのか
センサーのデータは「時刻と、そのときの値」の繰り返しです。1台が 60 秒ごとに 15 項目を送ると、1日で約 2 万点。台数を増やせばそのまま倍々に増えます。
これを普通のリレーショナルデータベースに入れても動きはしますが、時系列データベースはこの形だけに特化して作られています。
| 一般的な RDB | 時系列DB(InfluxDB) | |
|---|---|---|
| データの形 | 行と列を自由に設計 | 時刻がすべての行の主役で固定 |
| 書き込み | 更新・削除が前提 | 追記がほぼすべてとして最適化 |
| 圧縮 | 汎用 | 時刻順に並んだ数値に特化して高効率 |
| 古いデータ | 自分で消す仕組みを作る | retention(保持期間)で自動削除 |
| よく使う問い合わせ | 結合・集計 | 「直近24時間を1分平均で」が一発 |
InfluxDB のデータは4つの部品でできています。
- measurement:RDB でいうテーブル名にあたるもの。この構成では
env(測定値)とdevice_state(状態)の2つ - tag:検索やグループ化に使うラベル。文字列で、索引が張られます。ここでは
device_idだけ - field:実際の測定値。
temperature_bmeやco2など。索引は張られません - retention:バケット(データの入れ物)ごとの保持期間。ここでは 8760 時間=365 日にしていて、これを過ぎたデータは自動で消えます
tag に何を入れるかが設計の勘所です。理由は最後の章で書きます。
Telegraf — ブローカーと DB の間を埋める
MQTT ブローカーはメッセージを配るだけで、保存はしません(retain の最後の1通を除く)。InfluxDB は受け取ったものを保存するだけで、自分で取りに行きません。この2つの間には、誰かが立つ必要があります。
Telegraf は、InfluxDB の開発元が公式に出しているデータ収集エージェントです。「どこから読むか」(input プラグイン)と「どこへ書くか」(output プラグイン)を設定ファイルで指定するだけで、その間を延々と運んでくれます。今回は input に mqtt_consumer、output に influxdb_v2 を指定しています。
ここを自作せずに既製品を使った理由は、後半の「なぜ Telegraf なのか」で比較表つきで書きます。
🏗️ 構成:Docker Compose 3本
前提が揃ったので、実際の構成です。サーバーは Ubuntu 26.04 LTS の自宅マシン1台(26.04 へのアップグレード記録はこちら)。ここに Docker Compose で3サービスを追加しました。
ESP32-S3"] -->|"MQTT 3.1.1 / QoS1"| M subgraph HOST["自宅サーバー(Ubuntu 26.04 / Docker)"] M["Mosquitto 2
192.168.0.9:1883"] -->|"tcp://mosquitto:1883"| T["Telegraf 1.30
mqtt_consumer ×2"] T -->|"http://influxdb:8086"| I["InfluxDB 2.7
bucket = home_sensors
retention 365日"] I -.->|"第4回"| G["Grafana
127.0.0.1:3001"] end style M fill:#e3f2fd style I fill:#e8f5e9
| サービス | イメージ | 公開ポート | メモリ見積 |
|---|---|---|---|
| mosquitto | eclipse-mosquitto:2 |
192.168.0.9:1883 |
〜10MB |
| influxdb | influxdb:2.7 |
127.0.0.1:8086 |
300〜400MB |
| telegraf | telegraf:1.30 |
なし(内部ネットワークのみ) | 〜50MB |
ディレクトリはこの形です。最初から compose 管理にしました。既存のサーバーに「compose の外で動いているコンテナ」を増やすと、後から全体像が追えなくなるからです。
~/neuralhub-smarthome/
├ docker-compose.yml
├ .env ← 600・シークレット・.gitignore 対象
├ .gitignore
├ mosquitto/
│ ├ mosquitto.conf ← 600
│ ├ acl ← 600
│ └ passwd ← 600・PBKDF2-SHA512 ハッシュのみ
├ telegraf/telegraf.conf
├ grafana/ ← ダッシュボード JSON のバックアップ
├ add-sensor.sh ← 基板追加の自動化
└ devices.yaml ← device_id → 名前/場所/用途 の台帳
なお 192.168.0.9 は自宅 LAN 内のプライベートアドレスで、外部から到達することはできません。以降に出てくる IP もすべて同様です。
ポートをどのアドレスにバインドするか
Docker の ports: は、書き方によってどこから見えるかが変わります。ここは意識して分けました。
| ポート | バインド先 | 理由 |
|---|---|---|
1883(MQTT) |
192.168.0.9:1883 |
LAN の ESP32 から届く必要がある。ただし LAN インタフェースだけに限定し、VPN や他のインタフェースには出さない |
8086(InfluxDB) |
127.0.0.1:8086 |
管理 UI と CLI は SSH トンネル越しに使う。Grafana からは compose 内部 DNS の http://influxdb:8086 で届くので、ホストに公開する必要がない |
ports: - "1883:1883" と書くと 0.0.0.0、つまり全インタフェースで待ち受けます。アドレスを明示して書くだけで、露出範囲を1行で絞れます。
ここが今回いちばん共有したい注意点です。Ubuntu で UFW を設定していても、Docker が公開したポートはその許可・拒否をすり抜けて外から見えます。
Docker はコンテナのポートを公開するときに iptables へ直接ルールを入れます。UFW もまた iptables の上に乗った管理ツールなので、両者は同じ土俵で戦うことになり、Docker 側のルールが先に評価されます。「UFW で 1883 を閉じたから安全」は成立しません。
この構成での担保は2つです。
- バインドアドレスの限定(上の表)。そもそも待ち受けるインタフェースを絞る
- 認証を必須にする。匿名接続を禁止し、全クライアントにユーザー名とパスワードを要求する
ファイアウォールを頼りにしない前提で設計するのが安全側です。
この構成では MQTT over TLS(8883)を導入していません。 LAN 内限定の運用であること、そして ESP32 側の実装と証明書の更新運用を第1段階から抱えたくなかったためです。
LAN の中身が信用できない環境(来客用 Wi-Fi と同一セグメントなど)では、この割り切りはそのまま使えません。 認証情報は平文で流れます。TLS の導入は今後の課題として残しています。
💾 データの置き場所:bolt は SSD、engine は HDD
「全部 Docker のボリュームでいいや」で済ませなかった部分です。サーバーには SSD と HDD の両方があるので、性質で分けました。
| サービス | 中身 | 置き場所 | 理由 |
|---|---|---|---|
| mosquitto | 設定・ACL・passwd | compose ディレクトリ内(SSD・ro マウント) | 設定を compose と一体で版管理・バックアップできる |
| mosquitto | /mosquitto/data |
HDD の /data/storage4/mosquitto/data |
retain と QoS1 セッションの永続化 |
| influxdb | /etc/influxdb2 |
Docker Volume(SSD) | 設定。小さい |
| influxdb | /var/lib/influxdb2 |
Docker Volume(SSD) | bolt メタDB。小さいがランダムIOが多い |
| influxdb | /var/lib/influxdb2/engine |
HDD の /data/storage4/influxdb/engine |
TSM の実データ。ひたすら増える |
InfluxDB 2 系のデータは、性格の違う2つに分かれています。bolt はメタ情報を持つ小さなキーバリューDBで、細かいランダムアクセスが走ります。engine は TSM ファイル(実際の時系列データ)で、こちらは順に書き足されて容量が増えていきます。
小さくてランダムに触られるものを SSD に、大きくて順に増えるものを HDD に。 記憶装置の得意分野に合わせただけですが、これをやっておくと「容量が増えたから SSD を買い足す」という話にならずに済みます。
データ量の見積もりは、1台 30 秒間隔・約 15 フィールドで1日あたり約 4.3 万点。5台に増やしても圧縮後で年 300MB 程度の計算でした。実際の運用は 60 秒間隔なのでこの半分です。HDD の空きが 2.6TB あることを考えると、容量はまったく問題になりません。
uid 1000 の落とし穴
HDD 側のディレクトリ /data/storage4 は drwx------(所有者のみアクセス可・uid 1000)でした。このままコンテナを既定のユーザーで起動すると、bind mount 先のディレクトリを辿れずに失敗します。
対処は、compose の全サービスを user: "1000:1000" で起動することです。1883 も 8086 も 1024 以上のポートなので、root 権限は要りません。
これには副次的な効果もありました。600 で置いたシークレットファイルを、コンテナの実行ユーザーが読めないという問題も同時に消えます(同じサーバーの Grafana は uid 472 で動いていて、実際にこれを踏んでいます)。
シークレットを env_file に書かない
ここは設計として意図的に選んだ部分です。compose の env_file: と environment: は使っていません。
env_file: や environment: で渡した値は、コンテナの設定情報 .Config.Env にそのまま載ります。 つまり docker inspect <コンテナ名> を実行できる人には、パスワードもトークンも平文で見えます。
Docker を操作できる時点でほぼ何でもできるのは事実ですが、「うっかり inspect の出力をログや issue に貼る」という事故の経路を、設計で1本消しておく価値はあります。
代わりに、.env(パーミッション 600)を読み取り専用でマウントして、起動コマンドの中で source する方式に統一しました。
volumes:
- ./.env:/run/secrets/influxdb.env:ro
entrypoint: ["/bin/sh", "-c"]
command:
- "set -a; . /run/secrets/influxdb.env; set +a; exec /entrypoint.sh influxd"
set -a は「以降に定義した変数を自動で export する」という指定です。ファイルを読み込んで環境変数に展開し、set +a で元に戻してから本来のエントリポイントを exec します。プロセスの環境変数としては存在するが、コンテナの設定情報には残らないという状態になります。
効果は数えて確認しました。3コンテナぶんの docker inspect 全文を検索して、シークレット由来の文字列7種はいずれも0件。
🔐 ACL の %u 置換で、なりすましを塞ぐ
MQTT ブローカーは、素で立てると誰でも接続できて、誰でもどのトピックにも書けます。 家庭内 LAN であっても、その状態で 24 時間動かし続けるのは気持ちが悪い。
mosquitto.conf の要点はこれだけです。
allow_anonymous false
password_file /mosquitto/config/passwd
acl_file /mosquitto/config/acl
persistence true
persistence_location /mosquitto/data/
autosave_interval 300
max_keepalive 120
max_packet_size 8192
message_size_limit 8192
1行目の allow_anonymous false で匿名接続を禁止し、passwd(PBKDF2-SHA512 のハッシュのみが入る)で認証、acl でトピックごとの権限を決めます。
1デバイス=1アカウントにする
パスワードは全機体で共通にしない、というのが出発点です。センサー基板は部屋に置きっぱなしで、しかもフラッシュの中に認証情報が入っています。1枚が誰かの手に渡ったとき、そこから全体が抜けないようにしておきたい。
そこで MQTT のユーザー名を device_id そのものにしました。sensor_001 という基板は、sensor_001 というユーザーでログインします。
ACL 全文
そのうえで、ACL はこれだけです。機体を増やしても、このファイルは1行も増えません。
# 既定は「拒否」。ここに書かれた組み合わせだけ許可される。
# pattern 行は全ユーザーに適用され、%u はログイン中のユーザー名に置換される。
# --- センサ基板 (ユーザー名 = sensor_00X) ---
pattern write home/sensor/%u/telemetry
pattern write home/sensor/%u/state
pattern read home/sensor/%u/cmd/#
# --- IR 基板 (ユーザー名 = ir_00X) ---
pattern write home/ir/%u/state
pattern read home/ir/%u/send
pattern read home/ir/%u/learn
# --- 取り込み専用 (InfluxDB へのブリッジ) : 読み取りのみ ---
user telegraf
topic read home/#
# --- 保守・疎通確認用 ---
user ops
topic readwrite home/#
pattern 行の %u が、接続してきたユーザー名に置き換わります。 これが設計の核心です。
pattern write home/sensor/%u/telemetry"] R --> U1["sensor_001 として接続"] R --> U2["sensor_002 として接続"] U1 --> E1["書けるのは
home/sensor/sensor_001/telemetry
だけ"] U2 --> E2["書けるのは
home/sensor/sensor_002/telemetry
だけ"] E1 --> OK["自分のトピックへ publish
通る"] E1 --> NG["sensor_002 のトピックへ publish
ブローカーが破棄"] style OK fill:#e8f5e9 style NG fill:#ffcdd2 style R fill:#e3f2fd
ユーザー名と、書き込めるトピックの階層が、ブローカーの中で自動的に紐づきます。 結果として次のようになります。
| ユーザー | 用途 | 権限 |
|---|---|---|
sensor_001, sensor_002, … |
センサー基板 | 自分の home/sensor/<自分>/… のみ |
ir_001, … |
IR 基板 | 自分の home/ir/<自分>/… のみ |
telegraf |
InfluxDB への取り込み | home/# の読み取りのみ |
ops |
保守・疎通確認 | home/# の読み書き |
仮に sensor_001 のパスワードが漏れたとします。攻撃者にできるのは、home/sensor/sensor_001/… に嘘のデータを書くことだけです。
- 他の基板のトピックには書けない(
%uが自分の名前に固定されるため) - 他の基板のデータを読むこともできない(センサー基板の
read権限はcmd以下しかない) - 取り込み経路の
telegrafは読み取り専用なので、ここが漏れてもデータは書き換えられない
被害をその1台ぶんに閉じ込めるのが目的です。そして、そのために書いた設定は %u という2文字だけ。
実際に試した6パターン
設定を書いただけでは「たぶん効いているはず」で終わってしまうので、自分でなりすましを試しました。
| # | やったこと | 結果 |
|---|---|---|
| T1 | 匿名で接続 | Connection Refused: not authorised(exit=5)→ 拒否 |
| T2 | 正しいユーザー名・誤ったパスワードで接続 | Connection Refused: not authorised(exit=5)→ 拒否 |
| T3 | ops の正規認証で publish |
成功 |
| T4a | sensor_001 が自分の state へ publish |
成功 |
| T4b | sensor_001 が sensor_002 を名乗って publish |
遮断(retain に sensor_002 が現れない) |
| T5 | 読み取り専用の telegraf が既存 retain の上書きを試行 |
書き込み拒否(値は online のまま) |
| T6 | 新規発行した sensor_002 が sensor_001 を名乗って publish |
遮断(sensor_001 は online のまま) |
T4b と T6 が、双方向で試したなりすましです。既存の機体が新しい機体を騙ることも、新しい機体が既存を騙ることも、どちらもできません。
罠:ACL 違反はエラーにならない
ここで1つ、知らないと確実にハマるポイントがあります。
MQTT の仕様上、ACL に違反した publish に対して、ブローカーはエラーを返しません。 メッセージを静かに破棄して、接続はそのまま維持されます。
つまり送信側のコマンドは正常終了します。 mosquitto_pub の終了コードは 0。画面には何も出ません。
「エラーが出なかったから通った」と読んでしまうのが、この罠の本体です。 設定を間違えて全部弾かれていても、送信側からは成功に見えます。逆に、遮断されているつもりが通っていても気づけません。
では、どうやって遮断を確認するのか。別のトピックの retain を見ます。
state トピックは retain=true で運用しているので、ブローカーには常に「最後に書かれた1通」が残っています。 そこで、
sensor_002の state にonlineが retain されている状態を作るsensor_001として接続し、home/sensor/sensor_002/stateへ別の値を publish する- retain された値を購読して読み出す
3 で読み出した値が online のまま変わっていなければ、2 の publish は届いていない、つまり ACL で遮断されたと確定できます。送信側の戻り値ではなく、受信側の状態で判定する。 これが上の表の T4b・T5・T6 で使った方法です。
「拒否されること」を確かめるテストは、拒否された側からは見えないことがある。 効いているかどうかは、守りたい対象の状態が変わっていないことで確かめる。ACL に限らず、権限まわりの検証はこの形にしておくと嘘をつきません。
🔑 パスワードを画面にも ps にも出さずに発行する
基板を1枚増やすたびに、MQTT のアカウントを1つ作ります。これを手作業でやると、パスワードがどこかに残ります。
- コマンドライン引数に書けば、
psで他のユーザーに見え、シェルの履歴にも残る - 画面に表示すれば、ターミナルのスクロールバックに残り、スクリーンショットにも写る
そこで発行を add-sensor.sh に閉じ込めました。要点はこれだけです。
umask 077
PW="$(openssl rand -hex 20)"
printf '%s:%s\n' "$DEV" "$PW" > .pwtmp
chmod 600 .pwtmp
docker run --rm --user 1000:1000 -v "$PWD:/w" eclipse-mosquitto:2 mosquitto_passwd -U /w/.pwtmp
cat .pwtmp >> mosquitto/passwd
: > .pwtmp
docker compose kill -s HUP mosquitto
1行ずつ見ると、それぞれに理由があります。
| 行 | やっていること | 狙い |
|---|---|---|
umask 077 |
以降に作るファイルを所有者のみ読み書き可に | 一時ファイルが一瞬でも 644 で存在する隙をなくす |
openssl rand -hex 20 |
40 文字の16進文字列を生成 | 記号を含まない(後述) |
printf … > .pwtmp |
平文をファイル経由で渡す | argv に載せない。ps にも履歴にも出ない |
mosquitto_passwd -U |
ファイル内の平文をハッシュに置換 | PBKDF2-SHA512。平文はここで消える |
: > .pwtmp |
一時ファイルを空にする | 平文を残さない |
kill -s HUP |
Mosquitto に HUP シグナル | 無停止で passwd と ACL を再読み込み |
openssl rand -hex が生成するのは 0〜9 と a〜f だけの文字列です。記号が混じらないので、シェルのクォート、CSV、環境変数ファイル、YAML のどこを通っても壊れません。
40 文字の16進は 160 ビットぶんの強度があります。記号を諦めても長さで取り返せるので、「クォート事故を構造的に起こさない」ほうを取りました。
実際この連載では、別の場面でクォートに起因する事故を1度踏んでいます。 通り道が増えるほど、記号は敵になります。
最後の HUP も地味に効きます。基板を1枚追加するのに、他の基板の接続を切る必要がない。 コンテナを再起動すると、動いている全機体が一度切断されてしまいます。
🔀 なぜ Telegraf なのか
MQTT から読んで InfluxDB に書くだけなら、Python で 150 行も書けば動きます。実際その案も検討しました。
| Telegraf | 自作ブリッジ(Python) | Node-RED | |
|---|---|---|---|
| 実装量 | 設定 25 行 | 150〜300行+テスト | GUI フロー |
| 再接続・再試行 | 組み込み | 自前実装 | 要ノード追加 |
| 障害時のバッファ | metric_buffer_limit |
自前実装 | 実質なし |
| 欠測フィールド耐性 | JSON パーサが自動で無視 | 自由(ただし自前) | function ノードで自作 |
| 差分レビュー | 設定ファイルなので容易 | 依存更新もバグも自分持ち | フローが JSON で困難 |
| 追加で開くポート | なし | なし | :1880 |
決め手は真ん中の2行です。再接続・バッファ・バッチ書き込みは、自分で書くと必ず作り込みになります。「MQTT から読んで DB に書く」だけなら 30 分で書けますが、そのあとに来るのは以下の質問です。
- ブローカーが再起動したら、勝手に再接続するか
- InfluxDB が落ちている間に届いたデータは、どこへ行くのか
- 1点ずつ書いていないか(毎秒 HTTP リクエストを投げていないか)
- センサーが1つ壊れてフィールドが欠けた JSON が来たとき、例外で止まらないか
これらは全部、公式の収集エージェントなら設定1行で済んでいる部分です。Node-RED も候補でしたが、データ取り込みの常時経路に GUI ツールを置かないという判断で外しました(自動化の用途では今も便利です)。
telegraf.conf の中身
[agent]
interval = "10s"
flush_interval = "10s"
metric_buffer_limit = 100000 # InfluxDB 停止中もメモリ上に退避し、復旧後に流す
omit_hostname = true
[[inputs.mqtt_consumer]] # (1) telemetry -> measurement "env"
servers = ["tcp://mosquitto:1883"]
topics = ["home/sensor/+/telemetry"]
qos = 1
client_id = "telegraf_ingest"
persistent_session = true
data_format = "json" # v1 パーサ
name_override = "env"
tag_keys = ["device_id"]
[[inputs.mqtt_consumer]] # (2) state -> measurement "device_state"
topics = ["home/sensor/+/state", "home/ir/+/state"]
client_id = "telegraf_state" # (1)と別IDにする(同一だとブローカーに切断される)
name_override = "device_state"
tag_keys = ["device_id"]
json_string_fields = ["status", "fw", "ip", "sensors_*"]
[[outputs.influxdb_v2]]
urls = ["http://influxdb:8086"]
organization = "neuralhub"
bucket = "home_sensors"
mqtt_consumer を2つ書いているのがポイントです。
measurement を2つに分ける理由
| measurement | 中身 | フィールド |
|---|---|---|
env |
telemetry(数値のみ) | temperature_bme / co2 / pm2p5 など |
device_state |
state(文字列) | status / fw / ip / sensors_<センサ名> |
分けているのは、型の衝突を構造的に避けるためです。 InfluxDB のフィールドには型があり、同じ measurement の同じフィールド名に数値と文字列を混ぜて書くと拒否されます。 測定値と状態文字列を1か所に流し込むと、いずれ必ずここで詰まります。
入り口の時点で別の measurement に分けておけば、そもそも混ざりません。 第1回で「温湿度が2系統ダブっているのは、判断を受信側に倒すため」と書きましたが、これも同じ発想です。後で困る組み合わせは、最初から作らない。
state 側の json_string_fields に sensors_* とワイルドカードを書いているのも同じ理由で、新しいセンサーが増えても設定を触らずに済みます(ネストした sensors{} は sensors_bme280 の形に平坦化されて届きます)。
state は retain=true なので、Telegraf が再接続した直後に最新の state が再送されて DB に入ります。 LWT({"status":"offline"})も同じ経路を通るので、機体が落ちた記録も自動で残ります。
最初、2つの mqtt_consumer に同じ client_id を書いていました。症状は「Telegraf の MQTT 接続が繰り返し切れる」。
原因は MQTT の仕様です。同じ client_id で新しい接続が来たら、ブローカーは既存の接続を切ります。 同一 ID の重複を許さないための挙動で、正しい動作です。
つまり、2本の接続がお互いを蹴り合い続ける状態になっていました。片方が繋がると、もう片方が再接続して前者を蹴る。無限に続きます。対処は片方の ID を変えるだけ(telegraf_ingest と telegraf_state)ですが、設定を丸ごとコピーして片方だけ書き換えるときに踏みやすいので、設定ファイルのコメントに理由を残しました。
🗄️ InfluxDB:トークンをスコープで縛る
InfluxDB 側は素直です。組織とバケットを1つずつ作って終わりです。
| 項目 | 値 |
|---|---|
| org | neuralhub |
| bucket | home_sensors |
| retention | 8760h=365 日 |
| measurement | env(測定値)/ device_state(状態) |
| tag | device_id のみ |
| time | サーバーの受信時刻(基板は時刻を送らない) |
最後の行は第2回で決めた設計です。基板は timestamp を送りません。 NTP が同期する前に送信してしまったとき、壊れた時刻のデータが DB に入るという事故を、構造的に防ぐためです。60 秒間隔の環境データなら、受信時刻との差は誤差の範囲に収まります。
届いたデータを Data Explorer で見る
home_sensors バケットの device_id = sensor_001 から temperature_bme と temperature_sen を選び、Past 24h で重ねたところ。基板が投げた MQTT を Telegraf が拾って InfluxDB まで運んでいる、その着地の証拠がこの2本。0.5℃ 前後ずれたまま、同じ谷と同じ山に同じタイミングで反応している — 第1回で温湿度を2系統ダブらせた設計が、そのまま画面に出ている。左端の _measurement に env と device_state が並んでいるのも、上の表のとおり
なお、この画面は LAN の別の PC から http://192.168.0.9:8086 を叩いても出てきません。ssh -L 8086:127.0.0.1:8086 <サーバー> でトンネルを掘り、手元の localhost:8086 として開いて撮ったものです。8086 が LAN 側から見えないこと自体が、バインド先を 127.0.0.1 に絞った効果というわけです。
トークンは用途ごとに分ける
InfluxDB 2 系のアクセスはトークンで制御します。ここで admin トークンを使い回さないのが要点です。
| トークン | 権限 |
|---|---|
admin-rotated-20260823 |
operator(44 権限) |
telegraf-write |
home_sensors バケットへの write のみ |
grafana-read-v2 |
home_sensors バケットからの read のみ |
Telegraf は書くだけ、Grafana は読むだけ。それ以外は何もできません。 仮に Grafana の設定からトークンが漏れても、できるのは1つのバケットを読むことだけです。
💥 やらかし:一覧コマンドがトークン3本を吐いた
発行済みトークンの権限を確認しようとして、トークンの値そのものを作業ログに残しました。
| 内容 | |
|---|---|
| 事象 | 権限を確認するため influx auth list を既定のオプションのまま実行したところ、出力に Token 列(値そのもの)が含まれていた。admin・Telegraf 用・Grafana 用の計3本が作業ログに残った |
| 対処 | ① 新しい operator トークンを作成して疎通確認(値は表示せず)→ ② スコープ限定トークン2本を作り直し → ③ 露出した3本すべてを influx auth delete で失効 → ④ .env を新しい値へ更新(600 のまま・値は表示せず)→ ⑤ 有効なトークンが3本で、権限が想定どおりであることを値を出さずに確認 |
| 再発防止 | 以後、トークン一覧は --json で取得し description と permissions だけを表示する。influx auth list の素の実行は禁止 |
これは InfluxDB に限った話ではありません。「一覧」「詳細表示」の類のコマンドが、秘密の値そのものを既定で出すのはよくあることです。権限を確認したいだけなのに、値まで付いてくる。
そして、対処の方法ははっきりしています。
- 露出したものは全部失効させる。 使っていないものも、後で使うつもりのものも含めて全部
- 新しいものを発行して、置き換える
- 確認手順そのものを、値が出ない形に変える(
--jsonで必要な列だけ抜く)
再起動して壊れないかを確かめる
自宅サーバーは停電もするし、こちらの都合で再起動もします。電源が落ちてデータが消える構成だと意味がないので、確認しました。
| やったこと | 結果 |
|---|---|
docker compose restart |
3コンテナとも復帰・データ保持 |
docker compose down の後 up -d |
3コンテナとも復帰・投入済みデータ13フィールドすべて残存 |
| 初期セットアップが再実行されないか | されない(バケットは1つのまま・retention 365 日も維持) |
| MQTT の retain | down / up 後も state が残存 |
既存環境への影響も見ています。サーバー上のコンテナは 17本から20本に増えましたが、もともと動いていたサービス(:3000 / :4000 / :5002 / :6379 / :8888 / :3001 / :9090)はすべて生存していました。既存の compose に手を入れず、新しいプロジェクトとして足したので、当然といえば当然の結果です。
🧾 名前を DB に入れない — devices.yaml
最後に、地味ですが後から効く設計を1つ。
基板には sensor_001 のような ID しかありません。「リビング」「寝室」といった人間が読む名前は、どこにも入っていないのです。
これは面倒に見えますが、意図的です。名前を InfluxDB の tag に入れると、名前を変えた瞬間に過去データの系列が分裂します。
location=リビング
1〜3月のデータ"] B2["device_id=sensor_001
location=書斎
4月〜のデータ"] B1 -.->|"別系列になり
グラフが分断"| B2 end subgraph GOOD["tag は device_id だけ"] G1["device_id=sensor_001
すべての期間で1系列"] G2["devices.yaml
sensor_001 は書斎"] G2 -.->|"表示のときだけ
名前を当てる"| G1 end style BAD fill:#ffebee style GOOD fill:#e8f5e9
模様替えで基板を隣の部屋に移した、名前の表記を変えた。それだけでグラフが途中で切れるのは、明らかに間違っています。 時系列データが持つべきなのは「どの機体か」であって、「その機体が今どう呼ばれているか」ではありません。
そこで台帳は別ファイルに置き、表示層で名前を当てる構成にしました。
devices:
sensor_001:
name: "未設定"
location: "未設定"
purpose: "環境モニタ"
hardware: "ESP32-S3 / BME280 + BH1750 + SEN63C"
基板を増やすときにやるのは、devices.yaml に数行足して、生成スクリプトの出力を Grafana の変数に貼るだけ。 パネルの編集は不要です。名前を変えたくなったら、この YAML を書き換えれば表示だけが変わり、過去のデータは1本の系列のまま残ります。
✅ まとめ
- ACL の
%uは接続中のユーザー名に置換される。 ユーザー名をdevice_idにしておけば、1台の鍵が漏れても他の機体になりすませない。 機体が増えても ACL は1行も増えない - ACL 違反の publish はエラーにならない。 ブローカーが黙って捨て、送信側の exit code は 0。遮断の確認は「守りたい側の retain が変わらないこと」で行う
- UFW は Docker の公開ポートに効かない。 バインドアドレスの限定(
192.168.0.9:/127.0.0.1:)と認証必須で担保する - シークレットを
env_file:/environment:で渡すとdocker inspectに平文で出る。 600 のファイルを ro マウントしてcommand内でsourceする - 秘密は argv に載せない・画面に出さない・記号を含まない。 生成は
openssl rand -hex、反映は HUP で無停止 - 一覧コマンドは秘密を吐く。 露出したら全数失効させれば実害はゼロにできる。確認手順そのものを、値が出ない形に変える
- 再接続とバッファを自分で書くと必ず作り込みになる。 そこは公式の収集エージェント(Telegraf)に任せる
- 数値と文字列は入り口で measurement を分ける。 型衝突は起きてから直すより、起こらない形にするほうが安い
- 名前は DB に入れない。 tag は
device_idだけにして、表示層の台帳で名前を当てる
これで、基板が投げたデータがサーバーに 365 日ぶん貯まるところまで通りました。ただし今は、データベースの中を覗かないと何も見えません。
次回は最終回、Grafana でダッシュボードを作ります。温湿度の2系統を1枚のグラフに重ね、CO2 が 1000ppm を超えたら赤くし、基板を増やしたらパネルが自動で増えるところまで作り込みます。1枚の基板が、ようやく部屋のグラフになります。
関連記事
- KiCadで作るESP32-S3センサー基板|設計から手はんだ実装まで【自宅センサー基盤 #1】 — 今回データを送ってくる基板を作った回
- ESP-IDFで自作するI2Cセンサードライバ|vTaskDelayの罠【自宅センサー基盤 #2】 — 今回サーバーが受け取るデータを作っている回
- Grafanaで作る自宅環境ダッシュボード|8パネルの設計【自宅センサー基盤 #4】 — 今回貯めたデータを1画面にする回
- Ubuntu 26.04 LTS アップグレード手順|24.04からの移行を実機記録で解説 — このサーバーの OS
- ESP32-S3 × ESP-IDF|cJSONとHTTP ClientでJSONをPOST送信 — MQTT ではなく HTTP を選んだ場合の実装
- ESP32でgRPC vs JSON 実測比較|シリアライズ2.7倍速・データ量1/3 — 通信プロトコルを実測で比べた回