工場や現場の温度監視(HACCP・衛生管理)をIoT化しようとすると、 「ESP32で測る」だけでは終わりません。
送信経路の安定化、外部公開の安全性、DB設計、そして最終的な帳票・記録システム(Exment等)への連携まで、 意外と“地雷”が多いです。
この記事では、実際に運用している構成をベースに、 ESP32 → リバースプロキシ → APIサーバー(FastAPI/Uvicorn)→ MariaDB → Exment という一連の流れを、全体像 → つまずきポイント → 構築手順 → Exment連携 → セキュリティ → 備忘録テンプレの順で整理します。
URLや鍵情報はダミーに置き換えています(実運用では必ず置き換えてください)。
1. 全体像:データはどこを通って、どこで守るのか
1-1. システム構成(概念図)
[ESP32 + DS18B20]
|
| HTTPS POST /api/telemetry (X-API-Key を付与)
v
[Reverse Proxy (Nginx) / Public Entry]
tempmon-api.example.work
|
| proxy_pass http://API_SERVER:8000/api/telemetry
v
[API Server (FastAPI/Uvicorn)]
/opt/tempmon-api (venv)
|
| INSERT INTO MariaDB (TemperatureDB.esp32_readings)
v
[MariaDB (SQL Server)]
TemperatureDB
|
| (バッチ/ジョブ/手動) Exmentへ転送
v
[Exment]
OAuth2 (grant_type=api_key) → Bearer Token → /admin/api/data/{table_id}
1-2. 役割分担(重要)
- ESP32:温度計測+送信(再送・時刻同期など)
- リバースプロキシ:外部公開の入口(TLS終端、制限、ログ)
- APIサーバー(FastAPI):受信データ検証→DB書込(例外処理・冪等性)
- MariaDB:生データの蓄積(後から集計・再送・補正が可能)
- Exment:運用記録(帳票・検索・権限・現場運用UI)
ポイントは、「生データはまずDBに落とす」こと。
Exment(や他のSaaS/業務DB)へ直送すると、通信失敗時の復旧や再送が面倒になりやすいです。
まずSQLに集約し、後段を“いつでも再実行できる転送”にしておくと運用が安定します。
2. つまずきポイント(現場で起きやすい問題)
2-1. 「内部ドメイン」の誤解
https://tempmon-api.example.work/api/telemetry は “内部用” ではなく、 ESP32が外部から叩く入口URLです。
内部なのは、リバプロが裏側で叩く http://API_SERVER:8000 の方です。
2-2. APIキーをどこで使うのかが混乱する
APIキーは次の2点で混乱します:
- 送る側:ESP32(またはリバプロ)がヘッダーに載せる
- 検証する側:APIサーバー(FastAPI)が照合する
本記事の構成では、ESP32が入口URLへ送信し、APIサーバー側でAPIキーを照合します。
※リバプロで検証する設計も可能ですが、まずは構成を単純にしてから強化する方がトラブルが減ります。
2-3. Exment APIが「普通のAPIキー方式」じゃない
Exmentは、よくある X-API-Key: xxx だけで叩けるAPIではなく、 OAuth2(ただし grant_type がカスタム)の2段階です。
- Step1:
grant_type=api_keyで Bearerトークン取得 - Step2:トークンを
Authorization: Bearer ...で付けてAPI呼び出し
3. 構築手順:ESP32 → リバプロ → FastAPI → MariaDB
3-1. APIサーバー(FastAPI/Uvicorn)の最小実装例
以下は実運用パターンを簡略化した例です(ダミー値)。
# /opt/tempmon-api/app.py (例)
from fastapi import FastAPI, Request, Header, HTTPException
from pydantic import BaseModel, Field
from typing import List, Optional
import os
import pymysql
DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
DB_PORT = int(os.getenv("DB_PORT", "3306"))
DB_NAME = os.getenv("DB_NAME", "TemperatureDB")
DB_USER = os.getenv("DB_USER", "tempapi")
DB_PASS = os.getenv("DB_PASS", "")
API_KEY = os.getenv("API_KEY", "") # 固定キー(まずは1本)
TABLE = "esp32_readings"
app = FastAPI(title="TempMon Telemetry API", version="1.0")
class Reading(BaseModel):
sensor_hw_id: str = Field("", max_length=16)
raw_celsius: float
offset_c: float = 0.0
celsius: float
class Telemetry(BaseModel):
device_id: str = Field(..., max_length=32)
recorded_at: str = "" # "YYYY-MM-DD HH:MM:SS"
ts_device: int # epoch_ms
rssi: Optional[int] = 0
readings: List[Reading]
def get_conn():
return pymysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
database=DB_NAME, charset="utf8mb4", autocommit=True,
cursorclass=pymysql.cursors.DictCursor,
)
@app.get("/health")
def health():
return {"ok": True}
@app.post("/api/telemetry")
async def telemetry(req: Request, x_api_key: Optional[str] = Header(default="")):
# ★APIキー照合(入口の最低限の防御)
if API_KEY and (not x_api_key or x_api_key != API_KEY):
raise HTTPException(status_code=401, detail="invalid api key")
payload = await req.json()
data = Telemetry(**payload)
if not data.readings:
raise HTTPException(status_code=400, detail="no readings")
src_ip = (req.client.host if req.client else "") or ""
sql = f"""
INSERT IGNORE INTO {TABLE} (
device_id, recorded_at, ts_device,
sensor_hw_id, raw_celsius, offset_c, celsius,
src_ip, rssi
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
"""
inserted = 0
with get_conn() as conn:
with conn.cursor() as cur:
for r in data.readings:
cur.execute(sql, (
data.device_id, data.recorded_at, int(data.ts_device),
(r.sensor_hw_id or "")[:16],
float(r.raw_celsius), float(r.offset_c), float(r.celsius),
src_ip, int(data.rssi or 0),
))
if cur.rowcount == 1:
inserted += 1
return {"ok": True, "inserted": inserted, "readings": len(data.readings)}
3-2. .env(設定はsystemdで読み込む)
# /opt/tempmon-api/.env(例:値はダミー)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=TemperatureDB
DB_USER=tempapi
DB_PASS=************
API_KEY=************ # まずは固定キー1本(複数化は後述)
3-3. systemdで常駐化(venvで動かす)
# /etc/systemd/system/tempmon-api.service(例)
[Unit]
Description=TempMon API (FastAPI/Uvicorn)
After=network.target mariadb.service
[Service]
Type=simple
WorkingDirectory=/opt/tempmon-api
EnvironmentFile=/opt/tempmon-api/.env
ExecStart=/opt/tempmon-api/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8000 --log-level info
Restart=always
RestartSec=2
User=tempmon
Group=tempmon
[Install]
WantedBy=multi-user.target
確認コマンド:
sudo systemctl daemon-reload
sudo systemctl enable --now tempmon-api
sudo systemctl status tempmon-api --no-pager -l
journalctl -u tempmon-api -n 200 --no-pager
3-4. MariaDB(テーブル例)
CREATE TABLE esp32_readings (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
device_id VARCHAR(32) NOT NULL,
recorded_at VARCHAR(19) NOT NULL,
ts_device BIGINT NOT NULL,
sensor_hw_id VARCHAR(16) NOT NULL,
raw_celsius DOUBLE NOT NULL,
offset_c DOUBLE NOT NULL,
celsius DOUBLE NOT NULL,
src_ip VARCHAR(64) NOT NULL,
rssi INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_1 (device_id, ts_device, sensor_hw_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
UNIQUE KEY + INSERT IGNORE により、再送や二重送信が起きてもDBが壊れにくくなります(冪等性)。
4. リバースプロキシ(Nginx):入口URLを作る
ESP32が叩くURL https://tempmon-api.example.work/api/telemetry は、 リバプロ側で「そのドメインの受け口」を作り、裏側のAPIへ転送します。
server {
listen 443 ssl;
server_name tempmon-api.example.work;
# TLS設定は省略(Let's Encrypt等)
location /api/telemetry {
proxy_pass http://API_SERVER:8000/api/telemetry;
# 方式A:ESP32が送るX-API-KeyをそのままAPIへ渡す(ESP32にキーを持たせる)
proxy_set_header X-API-Key $http_x_api_key;
# 方式B:リバプロが固定で付与(ESP32にキーを持たせない)
# proxy_set_header X-API-Key "************";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
推奨の追加防御:IP制限・レート制限(ESP32台数が少ないほど効きます)。
5. Exment連携が最大の地雷:APIキー「だけ」では絶対に動かない
ここがこの構成で一番ハマるポイントです。
Exmentは「APIキーを発行して、そのキーをヘッダーに付けて叩けばOK」という一般的なREST APIとは違います。
APIキーは“直接使う鍵”ではなく、アクセストークン(Bearer)を作るための材料です。
5-1. Exmentで必要な“発行物”はAPIキーだけじゃない(3点セット)
- client_id(OAuthクライアントID)
- client_secret(OAuthクライアント秘密鍵)
- api_key(Exmentが発行するAPIキー)
「APIキーを作ったのに動かない」という場合の多くは、
client_id / client_secret が手元に無い、あるいは 組み合わせが違うのが原因です。
※この3つはセットで管理してください(どれか1つ欠けても認証できません)。
5-2. 認証は2段階:トークン取得 → トークンでAPI呼び出し
- Step1:アクセストークンを取得
POST /admin/oauth/token
ここでgrant_type="api_key"というカスタムgrant_typeを使います。
標準のclient_credentialsは通りません。 - Step2:BearerトークンでAPI呼び出し
Authorization: Bearer {access_token}を付けてPOST /admin/api/data/{table_id}に書き込みます。
Step1:トークン取得(ダミー値)
curl -sS -X POST "https://exment.example.work/admin/oauth/token" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "api_key",
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_secret": "************",
"api_key": "key_************",
"scope": "me value_read value_write"
}'
Step2:Bearerでデータ書き込み(table_idで指定)
TOKEN="eyJ0eXAiOiJKV1QiLCJhbGciOi..."
TABLE_ID="91"
curl -sS -X POST "https://exment.example.work/admin/api/data/${TABLE_ID}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"value": {
"unique_key": "ESP32_ABC_20260215_120000",
"check_slot": "2026-02-15 12:00:00",
"temp_c": 23.5,
"device_id": "esp32-01"
}
}'
注意:Exmentは テーブル名ではなく table_id を使うことが多いです。
また、POSTボディは {"value": {...}} でラップが必須になりやすいです。
5-3. よくある誤解:こうすると失敗する
❌ APIキーを“直接”ヘッダーに入れる(一般的RESTのノリ)
curl -X POST "https://exment.example.work/admin/api/data/91" \
-H "X-API-Key: key_************" \
-d '{ "value": { ... } }'
→ 多くのケースで 401 Unauthorized。
ExmentのAPIキーはBearerトークンの材料であり、直接の認証キーではありません。
❌ OAuth2標準の grant_type=client_credentials を使う
{
"grant_type": "client_credentials",
"client_id": "...",
"client_secret": "..."
}
→ 400 Bad Request になりがち。
Exmentは grant_type=”api_key”(カスタム)を使います。
5-4. Exmentのもう1つの罠:権限は“APIキー作成者”で事故りやすい
ExmentのAPIは、実行主体の権限がないと403になります。
そのため、管理者(Admin)でAPIキーを発行して運用すると、 APIが「実質Admin権限で動けてしまう」状態になりがちです。
推奨は、API専用ユーザー(サービスアカウント)を作り、 必要最小限の権限(例:特定テーブルの追加だけ)を付けたグループに所属させて、 そのユーザーでAPIキー(+クライアント)を発行することです。
6. 「複数APIキー」「ユーザー別権限」をどうするか(TempMon API側)
現状のFastAPIサンプル(固定キー1本)は、複数端末・権限差に弱いです。
将来を見据えるなら、次の順で強化するのが現実的です。
6-1. 最小強化:複数キー(同一権限)
# .env(例)
API_KEYS=key_a,key_b,key_c
ただし、この方式は「鍵ごとの権限」を持てません(全部同じ扱い)。
6-2. 本命:DBにキーとスコープ(権限)を持つ
端末ごとに鍵を失効したい、閲覧用と書き込み用を分けたい…などが出てきたら、 DBでキー管理が運用上ラクです。
- api_keys:key_hash / enabled / owner / role
- api_key_scopes:telemetry:write / telemetry:read 等
これにより「このキーは書き込みだけ」「このキーは閲覧だけ」などが実現できます。
(Exment側も同様に“専用ユーザー+最小権限”が安全です)
7. セキュリティ設計:APIキー/シークレット/トークンを「どこに持たせるか」問題
この手のIoT連携で本質的に難しいのは、 秘密情報(APIキーやclient_secret)をどこに置くかです。
一度でも漏れたら「そのキーで出来ること全部」が乗っ取られるため、 実装前に“置き場所”を決めておくのが重要です。
7-1. 何が秘密情報なのか(リスクレベル順)
- 最重要(漏洩=即時全権奪取):Exmentの
client_secret、(存在するなら)ExmentのAPIキー一式 - 重要(漏洩=不正操作/改ざん):入口API(TempMon)のAPIキー(X-API-Key)、Exmentの
access_token - 準重要:エンドポイントURL、デバイスID
結論:Exmentのclient_secretは絶対に端末(ESP32)に持たせないのが鉄則です。
7-2. “置き場所”の推奨構成
- ESP32(エッジ):できれば秘密情報を持たない(持つなら端末固有キー)
- APIサーバー(中継層):入口認証とバリデーション、DB書き込み
- 転送ジョブ(バックエンド):Exmentの client_secret 等を保持し、SQL→Exment を担当
こうすると、仮にESP32が盗難・解析されても Exmentを直接叩ける秘密情報は漏れないため被害が限定されます。
7-3. トークンは「キャッシュ」と「更新」がセット
Exmentはトークン取得が必要なため、毎回取得すると負荷が高まります。
そこで access_token をキャッシュしますが、保存場所は慎重に選びます。
- OK:サーバーのメモリ内キャッシュ(プロセスが落ちれば消える)
- OK:サーバーのローカルファイル(権限600、root/専用ユーザーのみ)
- 注意:DB平文保存(漏洩時の説明責任が重い)
- NG寄り:ESP32のフラッシュに長期保存(盗難/解析リスク)
7-4. 端末(ESP32)に鍵を持たせる場合の現実解
案A:端末ごとの個別APIキー(強く推奨)
端末IDとAPIキー(ハッシュ化)をサーバー側で管理し、 漏洩端末だけ失効できるようにします。
案B:署名方式(HMAC)
HMAC(timestamp + payload) で署名し、鍵を直接送らない方式。
ただし正確な時刻同期が必要になります。
7-5. サーバー側で秘密情報を守る最低限の設定
# .envファイルを守る(例)
sudo chown tempmon:tempmon /opt/tempmon-api/.env
sudo chmod 600 /opt/tempmon-api/.env
# systemdの実行ユーザー確認
systemctl show tempmon-api -p User -p Group -p EnvironmentFile
8. まとめ:この構成が強い理由
- ESP32は軽く、入口URLに投げるだけ
- リバプロで外部公開を安全に(TLS/制限/ログ)
- FastAPIで受信検証・冪等INSERT・例外処理
- SQLに生データを残すので、後から再送・補正・集計が可能
- Exmentはクセ強認証でも、SQL→Exmentへ分離すると運用が安定
付録:備忘録テンプレ(ダミーで記録しておくと後で助かる)
サーバー一覧
- Reverse Proxy:
PUBLIC_RP_IP/ ドメイン:tempmon-api.example.work - API/SQL Server:
API_SQL_IP(FastAPI:8000, MariaDB:3306) - Exment:
exment.example.work
APIサーバー(FastAPI)
- WorkingDirectory:
/opt/tempmon-api - venv:
/opt/tempmon-api/venv - service:
/etc/systemd/system/tempmon-api.service - .env:
/opt/tempmon-api/.env - Endpoint:
POST /api/telemetry/GET /health
DB
- DB名:
TemperatureDB - テーブル:
esp32_readings - DBユーザー:
tempapi(権限:INSERT/SELECT等、必要最小)
Exment(API)
- Token取得:
POST /admin/oauth/token(grant_type=api_key) - 書き込み:
POST /admin/api/data/{table_id} - 必要:client_id / client_secret / api_key / scope
- 推奨:専用ユーザー(サービスアカウント)で発行し最小権限にする

コメント