会社のCO₂濃度を可視化してみた
なんか空気が薄い気がする
みなさんこんにちは。みうたくです。
弊社のオフィスは、昨年10月に移転しました。
とってもかっこいいオフィスです。
オフィスの内観とかは、ぜひこちらをご覧下さい。
とってもかっこいいオフィスです。
ただ、せっかくいいオフィスに移転したのに、空気が薄い気がするときがあって、それを可視化したいな〜と思ったんですよね。オフィスの空調環境のオブザーバビリティをしたくなったわけです。
せっかくかっこいいオフィスに出社したのに、なんか集中できないから在宅勤務で良いな -> 帰宅するか…ってなっちゃうことがよくありました。。。
CO₂の濃度が高い?
まず、厚労省のHPを参考にするのですが、
安衛法第65条第1項では、「事業者は、有害業務を行う屋内作業場その他の作業場で、政令で定めるものについて、厚生労働省令で定めるところにより、必要な作業環境測定を行い、及びその結果を記録しておかなければならない」と定められています。
とあります。
弊社オフィスですと、
[5]中央管理方式の空気調和設備を設けている建築物の室で事務所の用に供されるもの
が、当てはまりそうです。
また、厚労省の資料(pdf)によると、
- 一酸化炭素濃度
- 二酸化炭素濃度
- 室温
- 外気温
- 相対湿度
を測定することが義務付けられているようです。
これらの数値が悪いと、シックビル症候群などのリスクがあるようです。
シックビル症候群になると、
- 粘膜症状: 眼、鼻、喉の刺激
- 中枢神経系症状: 頭痛、疲労、倦怠感
- 精神神経症状: 抑うつ、不安、集中力・記憶力の低下
- 呼吸器症状: 胸部圧迫感、息切れ、咳
- 皮膚症状: 乾燥、掻痒感、紅斑、じんましん、湿疹
のような症状が現れるらしく、このうち、
精神神経症状の「集中力・記憶力の低下」は、一酸化炭素濃度や二酸化炭素濃度の高さが起因しているはずです。
※追記(2026-02-19)
実際に計測を開始したところ、基準値を上回るような値は特に示していませんでした!(やった〜!)
可視化がひとつの目的ではあったのと、人が出社したタイミングでの値の推移を見ると結構面白いですね。
他の値はキャリブレーション前なので載せていませんが、値の変動が始まるタイミングが席によって違うのもまた面白いです。

測定しよう
じゃあ、オフィスの空気状態の良し悪しを、出社する判断基準とすることができたら、みんな幸せになれるよね?と思いました。
それならば、測定できる既製品買えばいいじゃないと思うかもしれないですが、それじゃあ面白くないじゃないですか。
どうせならSlackとかで通知させたいし、ダッシュボードとかで可視化したいじゃないですか。
そこで、ものづくり部の予算が余っており、ちょうどラズパイとセンサを購入できそうだったので、これを使おうと考えました。
今回は、二酸化炭素のみを測定します。
実は昔、ラズパイとMH-Z19で二酸化炭素濃度の測定自体はしたことがあったので、一定の知見があったのも理由の一つです。
詳しい使い方とかは「mh-z19 ラズベリーパイ」とかでググってみると記事がたくさん出てくると思います。
材料紹介
ハードとして利用したのは、先述のMH-Z19と、Raspberry Pi Zero 2WHです。
安価に用意できることや、調達のしやすさから採用しました。
可視化等には、GoogleCloudのCloudLoggingとCloudMonitorを採用しました。
Grafanaのデータソースにするとかもできますし、BigQueryにLog Sinkすれば、Lookerを始めとしたBIダッシュボードとかに活用するなりなんなりもできます。
まあ、何より、流すログ的に、コストがろくにかかるようなものではないので、利便性を考えてGCPを採用しよう、となりました。
設定していく
1. まずはRaspberry Pi Imager でSDカードを焼き焼き
- OSはRaspberry Pi OS Lite (64bit)
- Debian 13(trixie)ベースです。
- 会社のWi-FiのSSID
- SSHは有効。公開鍵認証をするように。
- 鍵はパスワードマネージャで登録。
- unix user のパスワードも。
- Raspberry Pi Connectで遠隔で操作可能に。
- IPアドレスを会社では固定できないはずなので、直接接続したい時困るので。
- インターネットさえつながってればOK。
- アカウント情報はパスワードマネージャに登録。
2. いつもの
しばらくするとSSHできるようになるのでSSHします(公開鍵認証で入れる想定)。
sudo apt update && sudo apt upgrade -y && sudo apt autoremove -y
sudo vim.tiny /etc/ssh/sshd_config
- #PermitRootLogin prohibit-password
- #PasswordAuthentication yes
- #PermitEmptyPasswords no
+ PermitRootLogin no
+ PasswordAuthentication no
+ PermitEmptyPasswords no
sudo systemctl restart ssh # 別窓でSSHして公開鍵認証のみできて、パスワード認証できないことを確認
sudo reboot
ReadOnly化に向けた最適化をします。
# 要らないサービス無効化
sudo systemctl disable --now ModemManager.service bluetooth.service man-db.timer
# avahiを削除
sudo apt remove --purge avahi-daemon
# avahi削除に伴いライブラリが残ったままになっていたら autoremove
sudo apt autoremove
3. セットアップ
私のレポジトリをもとに設定します。
curl -1sLf \
'https://dl.cloudsmith.io/public/miutaku/stable-release/setup.deb.sh' \
| sudo -E bash
sudo apt install co2-sensor
sudo systemctl enable --now co2-sensor
テストします。
curl localhost:8080/co2
{"co2":761.0,"hostname":"nagi-kimishima","ip":["192.168.43.107"],"result":"ok","version":"0.1.6"}
4. ロガーの設定
https://github.com/miutaku/co2-sensor/blob/main/tips/cloud_logging.md に合わせて設定します。
- fluent-bit, jqのインストール
sudo sh -c 'curl https://packages.fluentbit.io/fluentbit.key | gpg --dearmor > /usr/share/keyrings/fluentbit-keyring.gpg'
codename=$(grep -oP '(?<=VERSION_CODENAME=).*' /etc/os-release 2>/dev/null || lsb_release -cs 2>/dev/null)
echo "deb [signed-by=/usr/share/keyrings/fluentbit-keyring.gpg] https://packages.fluentbit.io/debian/$codename $codename main" | sudo tee /etc/apt/sources.list.d/fluent-bit.list
sudo apt update
sudo apt install fluent-bit jq
- 「ログ書き込み」ロールが付与されたサービスアカウントをGCPから取得してキー(json)を保存。
sudo mkdir -p /etc/google/auth
sudo vim.tiny /etc/google/auth/application_default_credentials.json
sudo chmod 400 /etc/google/auth/application_default_credentials.json
- fluent-bitの設定を修正
sudo vim.tiny /etc/fluent-bit/fluent-bit.conf
[SERVICE]
daemon Off
log_level info
parsers_file parsers.conf
plugins_file plugins.conf
[INPUT]
Name exec
Tag co2.metrics
Command sh -c 'curl -sS localhost:8080/co2 | jq -c "."'
Parser json
Interval_Sec 60 # 例: 60秒ごとに実行
Mem_Buf_Limit 50MB
[OUTPUT]
Name stackdriver
Match *
location asia-northeast1
namespace datumstudio-yosugala
node_id 君島凪 # ここはお好みを入れて下さい。今回はラズパイごとにyosugalaメンバーの名前にしました。(他のノードは汐見まとい、未白ちあ、黒坂未来)
resource_labels type=co2_sensor,device=raspberry_pi,place=office
resource generic_node
google_service_credentials /etc/google/auth/application_default_credentials.json
- 設定を反映
sudo chmod 644 /etc/fluent-bit/fluent-bit.conf
sudo systemctl restart fluent-bit
sudo systemctl enable fluent-bit
これにより、CloudLoggingにログが流れます。

5. GCPの設定
Cloud Loggingの ログベースの指標 で、以下の設定を持つユーザー定義の数値分布指標を作成します。
5-1. ログベースの指標の作成
| 項目 | 設定値 | 備考 |
|---|---|---|
| 指標のタイプ | Distribution | 数値データの収集のための指標タイプ |
| 指標の名前 | co2_level | (任意の指標名) |
| 単位 | ppm | 指標の説明についてはお好みで |
| フィルタ | logName="projects/YOUR_PROJECT_ID/logs/co2.metrics" | Fluent Bitによって流れてきたログを指定 |
| フィールド名 | jsonPayload.co2 | CO2のppm値(数値)を抽出 |
5-2. ラベルの設定(ホスト名の識別)
「ラベル」セクションで、ホスト名を識別子として抽出するラベルを設定します。
以下は、fluent-bitで指定したnode_id(アイドル名)とhostnameを指定する例です。
必要に応じてipなどのラベルも追加で設定しましょう。
| ラベル名 | フィールド名 |
|---|---|
| idol_name | resource.labels.node_id |
| hostname | jsonPayload.hostname |

5-3. Cloud Monitoringでグラフを作成
Cloud Monitoring の Metrics Explorerで、作成した指標を選択します。
おそらく、 Generic Node > ログベースの指標 > logging/user/co2_level をたどると、
Logging/user/<1. ログベースの指標の作成 で作成した指標> があるはずなので選択します。

5-4. グループ化
- 集計:
平均(means)を選択。-
hostname,ip,versionなどで必要に応じてフィルタ - 今回は
hostnameとidol_nameを指定。
-
- ウィジェットタイプ: Line chart
- 分析モード:
標準モード - (オプション) Y軸のラベル: 「二酸化炭素濃度(ppm)」に変更。
- (オプション) 凡例のエイリアス:
${metric.labels.idol_name} (${metric.labels.hostname})を設定。
これにより、ラズパイ(アイドル)ごとに線が分かれたCO2レベルの推移グラフが完成します。

問題なければ、名前を付けて保存ボタンを押し、ダッシュボードグラフを選択しましょう。

5-5. グラフの保存とダッシュボード
Chart Titleはこのグラフのタイトルを入れます。
また、ダッシュボードが作られていないと、New Dashboardを選ぶことでダッシュボードを作成できます。
今回は、次のようにしました。
| 項目名 | 設定値 |
|---|---|
| Chart Title | CO₂ Level - DATUM STUDIO Office |
| Dashboard | New Dashboard |
| Dashboard Name | Office air conditioning |

「グラフの保存」を押下。
これにより、 ダッシュボード一覧 を開くと、 Office air conditioning があるはずなので開いてみましょう。うまくいっていると次のようになるはずです。

6. アラートの設定
アラート文言をアイドルごとに分けるため、アイドル人数分のアラートポリシーを作成します。
6-1. アラートポリシーの作成
アラートを飛ばすための設定をします。
ダッシュボード内に作成したグラフの、「アラートポリシーを作成します」を押下します。

ポリシーごとにアラート文言を分けるために、ラズパイ(アイドル)を指定したいため、node_id = アイドル名 でフィルタを設定します。

ローリングウィンドウは
- ローリングウィンドウ: 1分
- ローリングウィンドウ関数: delta
Across time seriesは、
- 時系列集計: mean
- 時系列のグループ化の基準: hostname, idol_name
のままで、Nextを押下します。
6-2. しきい値の設定
こちらを参考に、
しきい値は945ppmにしました。
Advanced Optionsの再テストウィンドウは1分。問題なければNextを押下します。

6-3. 通知の設定
-
通知チャンネル
好きな通知先を指定できます。今回はSlackを指定しました。
Manage Notification Channels を押すと、他にもPagerDutyとかも設定できることがわかるかと思います。

-
通知の件名
通知はラズパイ(アイドル)ごとに文言を変えてあげたいため、ここには「二酸化炭素が濃いことを教える◯◯ちゃん」のように指定しました。 -
Notify on incident closure
インシデントが解消時(=つまり、CO₂濃度がしきい値を下回った時)に、それを通知するかなので、チェックを入れました。
Incident autoclose duration は、まあ12時間後には人がいないはずだし、12時間にしました。

-
Documentationのテキストフィールド
通知内容ですね。例えば凪ちゃんなら「空気の状態があまり良くないかも!なぎの近くに座ってる人は、他の席に移動したほうがいいよー!」と通知させます。 -
アラートポリシー名
通知の件名と同じでいいやと思って一緒にしました。
ここまでの設定が問題なければNextを押下し、「アラートの確認」の画面で、正しいアラートポリシーになっていそうなら、「ポリシーを作成」を押下しましょう。

7. overlay rootfsの制御スクリプト
自動でセキュリティ系パッケージをアップグレードするようにしましょう。
週一実行します。
準備
sudo tee /etc/apt/preferences.d/99-security-only >/dev/null <<'EOF'
# security pocket だけをアップグレード対象にする
Package: *
Pin: release n=trixie-security
Pin-Priority: 990
# それ以外(通常/updates/raspi)は “インストール済み(100)” より低くして upgrade されないようにする
Package: *
Pin: release n=trixie
Pin-Priority: 1
Package: *
Pin: release n=trixie-updates
Pin-Priority: 1
Package: *
Pin: origin "archive.raspberrypi.com"
Pin-Priority: 1
EOF
アプデ実行
起動時にsecurity系のアップデートを実施するスクリプトをサービス化します。(完了したらoverlayrootfsを有効化)
スクリプト
sudo tee /usr/local/sbin/boot-security-upgrade-then-enable-overlay.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
LOG_TAG="boot-enable-overlayfs-safe"
log() { logger -t "$LOG_TAG" "$*"; echo "[$LOG_TAG] $*"; }
is_overlay_root() {
local fs
fs="$(df -P / | awk 'NR==2{print $1}')"
[[ "$fs" == "overlayroot" || "$fs" == overlay* ]]
}
main() {
if is_overlay_root; then
log "Already overlay root; nothing to do."
exit 0
fi
log "RW root detected. Enabling overlayfs (bootro NOT touched yet)."
# boot を確実に書けるように
mount -o remount,rw /boot/firmware || true
# mkinitramfs 安定化
sed -i 's/^MODULES=.*/MODULES=most/' /etc/initramfs-tools/initramfs.conf
# overlay 有効化
raspi-config nonint enable_overlayfs
# “有効化されたはず”の状態をファイルで検証
if [ -f /boot/firmware/cmdline.txt ]; then
if ! grep -q 'overlayroot=tmpfs' /boot/firmware/cmdline.txt; then
log "ERROR: overlayroot=tmpfs not found in /boot/firmware/cmdline.txt"
log "Refusing to reboot."
exit 1
fi
else
log "ERROR: /boot/firmware/cmdline.txt not found"
log "Refusing to reboot."
exit 1
fi
# initramfs 再生成
update-initramfs -u -k all
# initramfs のリンクができているか確認
ls -l /boot/firmware/initramfs8 >/dev/null 2>&1 || log "WARN: /boot/firmware/initramfs8 not found"
ls -l /boot/firmware/initramfs_2712 >/dev/null 2>&1 || log "WARN: /boot/firmware/initramfs_2712 not found"
sync
log "Rebooting now (expect overlayroot after reboot)."
reboot
}
main "$@"
EOF
sudo chmod +x /usr/local/sbin/boot-security-upgrade-then-enable-overlay.sh
SystemD
sudo tee /etc/systemd/system/boot-security-upgrade.service > /dev/null <<'EOF'
[Unit]
Description=Security-only upgrade on RW boot, then re-enable overlayfs
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# 120秒待つ。この間に systemctl stop boot-security-upgrade.service すれば自動でoverlayrootfsにならない。
ExecStartPre=/usr/bin/sleep 120
ExecStart=/usr/local/sbin/boot-security-upgrade-then-enable-overlay.sh
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable boot-security-upgrade.service
OverlayFSを無効化するサービスを登録します。(週一実行)
スクリプト
sudo tee /usr/local/sbin/weekly-disable-overlayfs.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
LOG_TAG="weekly-disable-overlayfs"
log() { logger -t "$LOG_TAG" "$*"; echo "[$LOG_TAG] $*"; }
is_overlay_root() {
local fs
fs="$(df -P / | awk 'NR==2{print $1}')"
[[ "$fs" == "overlayroot" || "$fs" == overlay* ]]
}
main() {
if ! is_overlay_root; then
log "Root is already RW. Nothing to do."
exit 0
fi
log "Overlay is active. Switching next boot to RW by editing persistent root and rebooting."
# boot は書けるようにしておく(bootro でも remount できる場合あり)
mount -o remount,rw /boot/firmware || true
# ★重要: 永続側(/media/root-ro)は通常 ro なので、RWでマウントし直す
if findmnt -no OPTIONS /media/root-ro | grep -qw ro; then
log "/media/root-ro is ro; remounting rw to modify persistent config."
mount -o remount,rw /media/root-ro || true
fi
# remount で rw にならないケースに備え、デバイス直指定で再マウント
if findmnt -no OPTIONS /media/root-ro | grep -qw ro; then
log "remount rw failed; re-mounting /dev/mmcblk0p2 to /media/root-ro as rw."
umount /media/root-ro
mount -o rw /dev/mmcblk0p2 /media/root-ro
fi
# 永続側に boot を見せる(cmdline.txt を確実に編集するため)
mkdir -p /media/root-ro/boot/firmware
mount --bind /boot/firmware /media/root-ro/boot/firmware
# 永続側へ chroot して設定を反映
chroot /media/root-ro /bin/bash -lc '
set -euo pipefail
# initramfs の安定化(mkinitramfs失敗防止)
sed -i "s/^MODULES=.*/MODULES=most/" /etc/initramfs-tools/initramfs.conf
# overlay 無効化(次回起動で RW root)
raspi-config nonint disable_overlayfs
# cmdline に overlayroot=tmpfs があれば除去(Bookworm: /boot/firmware/cmdline.txt)
if [ -f /boot/firmware/cmdline.txt ]; then
sed -i "s/overlayroot=tmpfs[[:space:]]*//" /boot/firmware/cmdline.txt || true
fi
update-initramfs -u -k all
sync
'
umount /media/root-ro/boot/firmware || true
log "Rebooting now; next boot should be RW (/dev/mmcblk0p2)."
reboot
}
main "$@"
EOF
sudo chmod +x /usr/local/sbin/weekly-disable-overlayfs.sh
SystemD
sudo tee /etc/systemd/system/weekly-disable-overlayfs.service >/dev/null <<'EOF'
[Unit]
Description=Weekly disable overlayfs and reboot to RW (maintenance window)
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/weekly-disable-overlayfs.sh
StandardOutput=journal
StandardError=journal
EOF
sudo tee /etc/systemd/system/weekly-disable-overlayfs.timer >/dev/null <<'EOF'
[Unit]
Description=Weekly maintenance window start (Mon 03:00)
[Timer]
OnCalendar=Mon *-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now weekly-disable-overlayfs.timer
今後の展望
残りのメトリクスの取得
今回は、二酸化炭素しか取得していませんが、
- 一酸化炭素
- 室温
- 外気温
- 相対湿度
も同様に可視化したいですね。
一酸化炭素をどうにかしてセンサ使ったりして取得できたらいいなぁ。
温度(室温, 外気温)については、室温さえ取れれば、外気温はどこかからAPIで採ってくれば良さそうです。
湿度も多分、センサで簡単に取れそうですよね。
あと、Wi-Fi APから接続台数をもとに出社人数をおおよそでカウントできると、これも社員にとって嬉しいですよね。
最適化
今回、法令停電などで停電してもいいように、OverlayRootFSを活用しました。
鋭い方は、unattended-upgrades とか使わないの?って思うかもしれません。
実は採用した RPi Zero 2W の性能的に、OOM killerにより不安定な挙動が度々見られたため断念しました…(なので泣く泣くスクリプトを即席で作りました…)
もっとうまいことできそうだよとかあったら教えてほしいです。
ただ、メモリが多いラズパイとか他のSBC用意したほうが…ってなりそうだなぁ…
コード化
今回のラズパイの設定や、GCPの設定を、実はまだコード化していないんですよね。
ラズパイならこの程度ならCloud-initとかでいいと思いますし、GCPはTerraformでしょうか。
今のお仕事が落ち着いたら、サクッと起こしたいですね。
分析基盤への応用
例えば、オフィスのWi-Fi APに接続されたPCから、MDMなどと連携すれば、誰がオフィスにいるか?とかも出せると思います。
これをもとに、社員のオフィス利活用状況の分析とかもできそうですよね。空調のデータと組み合わせて、オフィス快適度指数とか出せたら面白そうですよね。
そもそも、弊社 DATUM STUDIOは、データの利活用に長けたスペシャリストが揃っている会社です。
社員が自発的に、自分たちの行動分析をすることもまた、面白みがあるんじゃないかなと、今回このシステムを構築してみて思いました。
今回のメトリクスデータも、GCPのCloudLoggingを活用して可視化しているので、簡単にBIなどに活用できると思います。
あとがき
君島凪ちゃん…
会社のSlackで、yosugalaのメンバーたちがそれぞれ通知してくれるんですよね。
例えば、凪ちゃんだと、このスクショみたいに「空気が悪いよ〜」って教えてくれるんですね。
ナギチャンカワイイネ…好きや…

Discussion