はじめに

第1回で基板を作り、第2回でその基板から Wi-Fi 経由でデータを飛ばしました。では、飛ばした先には何があるのか。 それが今回の話です。

センサーの値を 60 秒ごとに投げつけても、受け止める相手がいなければ何も残りません。今回作るのは、受け止める人(MQTT ブローカー)・仕分ける人(Telegraf)・貯める人(InfluxDB) の3人組です。全部 Docker Compose で1つのディレクトリにまとめました。

そして、この3人組の安全を決めているのが、MQTT の ACL に出てくる %u という2文字です。たった2文字で「1台の鍵が漏れても、他の機体になりすませない」という性質が手に入ります。実際に自分でなりすましを試して、遮断されることを確かめたところまで書きます。

🗓️ 連載「自宅センサー基盤」#3(全4回予定)

自作のセンサー基板から Grafana のダッシュボードまで、自宅の環境を測る仕組みを丸ごと作る連載です。基板 → ファームウェア → サーバー → 可視化の4回で、1枚の基板が部屋のグラフになるところまで通します。

flowchart LR A["第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)です。

flowchart LR P1["センサ基板
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 / トピック / QoS / retain / LWT
  • 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分平均で」が一発
💡 ワード解説:measurement / tag / field / retention

InfluxDB のデータは4つの部品でできています。

  • measurement:RDB でいうテーブル名にあたるもの。この構成では env(測定値)と device_state(状態)の2つ
  • tag検索やグループ化に使うラベル。文字列で、索引が張られます。ここでは device_id だけ
  • field実際の測定値temperature_bmeco2 など。索引は張られません
  • 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サービスを追加しました。

flowchart TD B1["センサ基板 #1
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行で絞れます。

⚠️ UFW は Docker の公開ポートに効かない

ここが今回いちばん共有したい注意点です。Ubuntu で UFW を設定していても、Docker が公開したポートはその許可・拒否をすり抜けて外から見えます。

Docker はコンテナのポートを公開するときに iptables へ直接ルールを入れます。UFW もまた iptables の上に乗った管理ツールなので、両者は同じ土俵で戦うことになり、Docker 側のルールが先に評価されます。「UFW で 1883 を閉じたから安全」は成立しません。

この構成での担保は2つです。

  1. バインドアドレスの限定(上の表)。そもそも待ち受けるインタフェースを絞る
  2. 認証を必須にする。匿名接続を禁止し、全クライアントにユーザー名とパスワードを要求する

ファイアウォールを頼りにしない前提で設計するのが安全側です。

⚠️ TLS は入れていない(現時点の割り切り)

この構成では 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/storage4drwx------(所有者のみアクセス可・uid 1000)でした。このままコンテナを既定のユーザーで起動すると、bind mount 先のディレクトリを辿れずに失敗します。

対処は、compose の全サービスを user: "1000:1000" で起動することです。1883 も 8086 も 1024 以上のポートなので、root 権限は要りません。

これには副次的な効果もありました。600 で置いたシークレットファイルを、コンテナの実行ユーザーが読めないという問題も同時に消えます(同じサーバーの Grafana は uid 472 で動いていて、実際にこれを踏んでいます)。

シークレットを env_file に書かない

ここは設計として意図的に選んだ部分です。compose の env_file:environment: は使っていません。

⚠️ 環境変数で渡した秘密は docker inspect に平文で出る

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 が、接続してきたユーザー名に置き換わります。 これが設計の核心です。

flowchart TD R["ACL に書いてあるのは1行
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/# の読み書き
✅ 1台の鍵が漏れても、他の機体になりすませない

仮に 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_001sensor_002 を名乗って publish 遮断(retain に sensor_002 が現れない)
T5 読み取り専用の telegraf が既存 retain の上書きを試行 書き込み拒否(値は online のまま)
T6 新規発行した sensor_002sensor_001 を名乗って publish 遮断sensor_001online のまま)

T4b と T6 が、双方向で試したなりすましです。既存の機体が新しい機体を騙ることも、新しい機体が既存を騙ることも、どちらもできません。

罠:ACL 違反はエラーにならない

ここで1つ、知らないと確実にハマるポイントがあります。

⚠️ 違反した publish は「黙って」捨てられる。exit code は 0

MQTT の仕様上、ACL に違反した publish に対して、ブローカーはエラーを返しません。 メッセージを静かに破棄して、接続はそのまま維持されます。

つまり送信側のコマンドは正常終了します。 mosquitto_pub の終了コードは 0。画面には何も出ません。

「エラーが出なかったから通った」と読んでしまうのが、この罠の本体です。 設定を間違えて全部弾かれていても、送信側からは成功に見えます。逆に、遮断されているつもりが通っていても気づけません。

では、どうやって遮断を確認するのか。別のトピックの retain を見ます。

state トピックは retain=true で運用しているので、ブローカーには常に「最後に書かれた1通」が残っています。 そこで、

  1. sensor_002 の state に online が retain されている状態を作る
  2. sensor_001 として接続し、home/sensor/sensor_002/state へ別の値を publish する
  3. 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_consumer2つ書いているのがポイントです。

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_fieldssensors_* とワイルドカードを書いているのも同じ理由で、新しいセンサーが増えても設定を触らずに済みます(ネストした sensors{}sensors_bme280 の形に平坦化されて届きます)。

state は retain=true なので、Telegraf が再接続した直後に最新の state が再送されて DB に入ります。 LWT({"status":"offline"})も同じ経路を通るので、機体が落ちた記録も自動で残ります。

⚠️ 罠:client_id が同じだと、延々と切断され続ける

最初、2つの mqtt_consumer同じ client_id を書いていました。症状は「Telegraf の MQTT 接続が繰り返し切れる」。

原因は MQTT の仕様です。同じ client_id で新しい接続が来たら、ブローカーは既存の接続を切ります。 同一 ID の重複を許さないための挙動で、正しい動作です。

つまり、2本の接続がお互いを蹴り合い続ける状態になっていました。片方が繋がると、もう片方が再接続して前者を蹴る。無限に続きます。対処は片方の ID を変えるだけ(telegraf_ingesttelegraf_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 で見る

InfluxDB 2.7 の Data Explorer。home_sensors バケットの device_id=sensor_001 について temperature_bme と temperature_sen の24時間ぶんを重ねたグラフと、下部の _measurement / device_id / _field を選ぶクエリビルダー

home_sensors バケットの device_id = sensor_001 から temperature_bmetemperature_sen を選び、Past 24h で重ねたところ。基板が投げた MQTT を Telegraf が拾って InfluxDB まで運んでいる、その着地の証拠がこの2本。0.5℃ 前後ずれたまま、同じ谷と同じ山に同じタイミングで反応している — 第1回で温湿度を2系統ダブらせた設計が、そのまま画面に出ている。左端の _measurementenvdevice_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 で取得し descriptionpermissions だけを表示する。influx auth list の素の実行は禁止
📌 一覧コマンドは、平気で秘密を吐く

これは InfluxDB に限った話ではありません。「一覧」「詳細表示」の類のコマンドが、秘密の値そのものを既定で出すのはよくあることです。権限を確認したいだけなのに、値まで付いてくる。

そして、対処の方法ははっきりしています。

  1. 露出したものは全部失効させる。 使っていないものも、後で使うつもりのものも含めて全部
  2. 新しいものを発行して、置き換える
  3. 確認手順そのものを、値が出ない形に変える--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 に入れると、名前を変えた瞬間に過去データの系列が分裂します。

flowchart TD subgraph BAD["名前を tag に入れた場合"] B1["device_id=sensor_001
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枚の基板が、ようやく部屋のグラフになります。


関連記事