💗

会社の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
/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. セットアップ

私のレポジトリをもとに設定します。
https://github.com/miutaku/co2-sensor

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 に合わせて設定します。

  1. 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
  1. 「ログ書き込み」ロールが付与されたサービスアカウントを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
  1. fluent-bitの設定を修正

sudo vim.tiny /etc/fluent-bit/fluent-bit.conf

/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
  1. 設定を反映
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などに活用できると思います。

あとがき

君島凪ちゃん…
https://x.com/nagi_ysgl
会社のSlackで、yosugalaのメンバーたちがそれぞれ通知してくれるんですよね。
例えば、凪ちゃんだと、このスクショみたいに「空気が悪いよ〜」って教えてくれるんですね。

ナギチャンカワイイネ…好きや…

DATUM STUDIO

Discussion