サーバールームの異常を環境センサで監視する仕組み

# はじめに

この記事は[株式会社ビットキー 情シス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/bitkey-corp-eng) の7日目の投稿です。

初めまして、@y_okeです。

2020年に入社し、初めはスマートロックや周辺機器の組み込み開発を担当していましたが、様々な縁があって、今は情シス部門にて色々な社内システムの内製などを担当しています。

今回は私が内製したシステムの中から、サーバールームの環境情報を自動監視するシステムについて書いていきます。

# 背景

ビットキーはインフラをクラウドで統一しているため物理サーバーは存在しませんが、ルーターやスイッチを格納する部屋があり、ここを便宜上サーバールームと読んでいます。

今年の夏頃、社員から「サーバールーム前が異様に熱くて大きな音がする」という連絡が入り、駆け付けると室温がとても高い状態になっており、スイッチのファンが唸っていた、ということがありました。

幸いなことにドアを開放することでその場はしのげましたが、サーバールームの室温異常や機器の異常を検知するための仕組みが必要という共通認識ができ、センサ等に詳しそうな元組み込み開発者の私が開発することになりました。

# システム概要

![サーバールーム温度監視.drawio (8).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495455/89af7ea7-82c9-e1f7-b326-0c2d3f54df4f.png)

今回開発したシステムの概要です。環境センサとローカルPCは物理デバイスで、残りはサーバーレスで構成しています。

1. 環境データ取得・送信(Python/GAS)
    - タスクスケジューラーで30分に1回起動
    - 環境センサからデータを取得し、GoogleAppsScript(以下GAS)の実行可能APIへPOST
2. 環境データ保存・表示(GAS/GSS)
    - POSTされたデータをGoogleSpreadSheet(以下GSS)へ保存
    - グラフ化してNotionへ表示
3. 異常通知(GAS/Slack)
    - 閾値超えの値が来た場合はSlackへアラート
    - アラートが来たら、担当者が状況確認と環境調整を実施

# 要素技術

### 環境データ取得

環境データ取得側のシステム要件は以下3つがあり、これらを満たす製品として、OMRON社の[2JCIE-BU](https://components.omron.com/jp-ja/products/sensors/2JCIE-BU?partNumber=2JCIE-BU)を選定しました。

- サーバールームの室温を計測したい
- スイッチの異音を計測したい
- 計測結果をデータとして取得したい

環境センサはWindowsPCに取り付け、スイッチ等が設置されているラック内に置きました。

### 環境データ送信

ローカルPCからGASの実行可能APIに対して、環境データを送信しています。本当はメンテナンスのしやすさから、GASを定期実行させて環境データを取りに行きたかったのですが、GASからローカルPCへのアクセス経路がなかったため断念しました。

GASの開発・デプロイは[clasp](https://github.com/google/clasp)を使うことで、ほとんどをローカルで完結させています。claspでのデプロイは今回初めて使いましたが、GUIよりも管理がしやすく、気に入りました。（GASの開発環境周りについても、機会があれば書きたいと思います）

### 環境データの取得・送信プログラム

[OMRONの公式サンプルプログラム](https://github.com/nobrin/omron-2jcie-bu01/blob/master/README_ja.md)をベースに、GASの実行可能APIを呼び出す処理を追加しました。

今回の開発で一番ハマったのは、Google認証を突破して実行可能APIを呼び出す手段です。
原因はウェブアプリケーション用のクライアントIDを取得していたためで、デスクトップアプリ用のクライアントIDを取得し直すことで呼び出せるようになりました。

プログラム定期実行はWindowsのタスクスケジューラを利用しています。注意点としてはプログラムの実行環境が仮想環境のため、仮想環境のpython.exeを絶対パスで指定する必要がありました。

参考にしたサイト

https://qiita.com/sunymaeno/items/700399063d2a407d8f57

https://developers.google.com/sheets/api/quickstart/python?hl=ja

https://nnt339es.hatenablog.com/entry/2021/12/04/031251

```python:measure_serial_and_post.py
#!/usr/bin/env python3
import json
import os
import sys
import time
from datetime import datetime, timedelta, tzinfo
from decimal import Decimal
from typing import Any, Optional

from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient import discovery

from omron_2jcie_bu01 import Omron2JCIE_BU01

sys.path.insert(0, "../lib-ext")
sys.path.insert(0, "..")
executable_file_path = os.path.dirname(os.path.abspath(__file__))

# 環境データを取得する
def get_environment_data():
    CurrentTZ = type(
        time.tzname[0],
        (tzinfo,),
        {
            "tzname": lambda self, dt: time.tzname[0],
            "utcoffset": lambda self, dt: timedelta(seconds=-time.timezone),
            "dst": lambda self, dt: timedelta(seconds=time.timezone - time.altzone),
        },
    )()

    # USB経由で環境データを取得する
    s = Omron2JCIE_BU01.serial("COM3")
    info = s.latest_data_long()
    s.close()
    dt = datetime.now(CurrentTZ)

    # 取得日時をデータに付加する
    env_dict = {"env_date": dt.strftime("%Y/%m/%d %H:%M:%S")}
    env_dict.update(info._asdict())

    def decimal_to_float(obj):
        if isinstance(obj, Decimal):
            return float(obj)

    # 少数点付きのdecimalはJSON変換時にStringになってしまうためfloat型に変換
    # float型に処理すれば数値としてJSON変換できる
    env_data = json.dumps(env_dict, default=decimal_to_float)
    return env_data

# GCPで発行したOAuth2.0 クライアントIDの認証情報
# ローカルで実行させるのでデスクトップアプリを選択
CLIENT_SECRET_FILE = executable_file_path + "/client_secret.json"

# ローカルにキャッシュする認証トークン
TOKEN_FILE = executable_file_path + "/token.json"

# 用意した実行可能APIを呼び出すのに必要な権限
SCOPES = [
    "https://www.googleapis.com/auth/script.scriptapp",
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/script.external_request",
]

# 実行可能APIのデプロイID デプロイ時に取得する
SCRIPT_ID = "GAS_SCRIPT_DEPLOY_ID"

# 認証情報を取得し、ローカルに保存する
def get_credentials() -> Credentials:
    credentials: Optional[Credentials] = None

    # すでに認証情報があるときの処理
    if os.path.exists(TOKEN_FILE) and os.path.getsize(TOKEN_FILE) > 0:
        credentials = Credentials.from_authorized_user_file(TOKEN_FILE, scopes=SCOPES)
    # 認証情報が有効な場合
    if credentials and credentials.valid:
        return credentials
    # 認証情報が無効の場合、リフレッシュする
    if credentials and credentials.expired and credentials.refresh_token:
        credentials.refresh(Request())
        return credentials

    # ローカルで認証処理を行う
    flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE, scopes=SCOPES)
    credentials = flow.run_local_server(port=0)
    save_credentials(credentials=credentials)
    return credentials

# ローカルに認証情報を保持する
def save_credentials(credentials: Credentials) -> None:
    with open(TOKEN_FILE, "w") as f:
        f.write(credentials.to_json())

# GASの実行可能APIを呼ぶ
def call_function(func_name: str, parameters: Optional[list[str]] = None) -> Any:
    body: dict[str, Any] = {
        "function": func_name,
    }
    if parameters:
        body["parameters"] = parameters

    script_service = discovery.build("script", "v1", credentials=get_credentials())
    script_service.scripts().run(scriptId=SCRIPT_ID, body=body).execute()

# GASの実行可能APIとして公開している関数名と、parameterとして渡す環境データを渡す
call_function("doPost", get_environment_data())
```

### 環境データ保存

送信データはGSSに保存しています。BigQueryも検討しましたが、データ量を考えても30分に1回の保存で1日に48件、1年換算しても約18万件程度なので、GSSでも十分と判断しました。
なおGSSやGASは個人のマイドライブだと権限等に依存したリスクがあるため、共有ドライブへ配置しました。

下は実際に取ったデータのサンプルです。データは取れるだけ取っておきました。

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495455/61532ef7-6532-838c-4b87-74a962dc20dd.png)

### グラフ表示

社内では情報共有ツールとしてNotionを利用しており、ここにグラフを公開しています。
データを連携してNotion側でグラフを作成したかったのですが、Notionでは動的に変化するグラフを作成することはできませんでした。そこで、GSSでグラフを作成した後、グラフのみをウェブ公開する設定にし、そのリンクをNotionに埋め込むことで、Notionでも動的なグラフ表示を行えるようにしました。

GSSのグラフのメニューから[グラフの公開]を選択して公開設定を開き、そこから公開することでURLが取得できます。インタラクティブを選択すれば、ほぼリアルタイムで更新されます。

![スクリーンショット 2023-12-05 15.36.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495455/950a39c4-90ca-2be9-b14a-13d7dda77ff3.png)

### Slack通知

SlackBotを用意し、閾値を超えた値を受信する度に[chat.postMessage](https://api.slack.com/methods/chat.postMessage)でメッセージを出しています。
閾値は後から変更しやすいように、GSSに設定シートを用意しました。注意と警告の2段階の閾値を設け、また様々な環境情報それぞれで表示や単位系を変えたアラートを出せるように、閾値の他にもSlackへ通知したいアイコンや単位記号を入れる枠を用意しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495455/79f74a56-ff7d-13a3-f51f-d7eedb7a6375.png)

# 運用開始してわかったこと

環境データとしてeTVOC(総揮発性有機化合物濃度、空気の質を示す値のようなもの)も一緒に取得しているのですが、この値が平日日中に高くなり、高い間は室温が下がる傾向にあることがわかりました。
これはエアコンの稼働により空気の質が変化したのではないかと推測しており、エアコンの稼働状況も確認できそうだということがわかっています。

また、システムを稼働開始してしばらくしたある日、夜間から翌朝まで室温異常のアラートが出続けるという現象に遭遇しました。原因は閾値を低めに設定してしまったためで、翌朝見てもサーバールームに異常はありませんでした。

![スクリーンショット 2023-12-06 17.11.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/495455/5756f72b-a119-b838-4ba3-9dc309b9a08e.png)

このままではオオカミ少年になってしまうので、当面は閾値を高めにして、取得データの傾向を見つつ適切な閾値を決めることにしました。
約1年分のデータが溜まった来年の秋頃からは、きっと良い成果を出せるのではないかと期待しています。

# 今後やりたいこと

今は30分ごとの瞬間値を取得するやり方なので、間の29分ぶんのデータが抜けてしまっています。今後はこれらのデータも考慮した値にできないか検討中です。