Upgrade to Pro — share decks privately, control downloads, hide ads and more …

「災害安否確認ちゃん」を作って学ぶGoogle Cloud Run

「災害安否確認ちゃん」を作って学ぶGoogle Cloud Run

クロスマート株式会社 Dev2 EM ぽうひろ こと たけじいです。
https://xmart.co.jp/

この度、社内開発チームで定期的に行なっている10分勉強会にて発表したスライドを共有します。

社内の労務の「簡単なやつでお金かけなくていいから災害安否確認ができる仕組みが欲しい」との相談をきっかけに、GCPとSlackを活用した社内システムを構築してみました。(かかったのは1日くらい)
CloudRunは初めて触りましたが、こんなに簡単にシステムを公開できるのか!と嬉しい驚きでした。
このスライドを通じて、皆さんが気軽にサービスを開発しよう!公開しよう!というきっかけになれたら嬉しいです。

コンテンツ
- なぜ「災害安否確認ちゃん」を「GCP」で作ろうと思ったのか
- システム全体像
- Google Cloud Runとは
- システム実装のポイント
- コンテナ化とデプロイ
- Cloud Schedulerによる定期実行
- 気象庁XMLフィードの活用
- Firestoreによるデータ管理
- SlackAPIによる通知機能
- 学んだこと・今後の展望

Avatar for ぽうひろ

ぽうひろ

March 06, 2025
Tweet

More Decks by ぽうひろ

Other Decks in Technology

Transcript

  1. システム実装:Dockerコンテナ化 Dockerfile FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN

    pip install --no-cache-dir -r requirements.txt COPY . . # コマンドを明示的に指定 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
  2. システム実装:利用ライブラリ python3.12でfastapi,pydanticなどが使えるよ! 環境変数でfirebase用のアカウントjsonを読み込む必要があったのでpydantic-settings も利用。 (後述) pyproject.toml python = "^3.12" fastapi

    = "^0.115.8" uvicorn = "^0.34.0" feedparser = "^6.0.11" xmltodict = "^0.14.2" pydantic-settings = "^2.8.0" google-cloud-firestore = "^2.20.0" めんどくさかったので poetry export -f requirements.txt --output requirements.txt で requirements.txtを生成。
  3. システム実装:APIエンドポイント app = FastAPI() # 設定 RSS_URL = "https://www.data.jma.go.jp/developer/xml/feed/eqvol.xml" #

    気象庁の地震・火山情報フィード EMOJI_LIST = ["o", "x"] CHECK_SINDO = 3 # この震度以上の地震を通知 @app.get("/") def root(): fetch_rss() return {"message": "RSS Monitoring Service is running"} 普通にFastAPI使えちゃう!
  4. システム実装:気象庁の地震火山情報Feed https://www.data.jma.go.jp/developer/xml/feed/eqvol.xml 抜粋(火山の降灰予報がめっちゃ多い。震度速報は震度3以上じゃないと飛んでこない ようで、今回は震度速報を拾うことにした。 ) 一応、ここ(https://xml.kishou.go.jp/tec_material.html)にドキュメントがあるがさっぱり わからんのでxmlみて雰囲気で作ることにした。 <feed xmlns="http://www.w3.org/2005/Atom" lang="ja">

    <title>高頻度(地震火山)</title> <updated>2025-03-02T14:01:10+09:00</updated> <entry> <title>降灰予報(定時)</title> <id>https://www.data.jma.go.jp/developer/xml/data/20250302050050_0_VFVO53_010000.xml</id> <updated>2025-03-02T05:00:00Z</updated> <link type="application/xml" href="https://www.data.jma.go.jp/developer/xml/data/20250302050050_0_VFVO53_010000.xml"/> <content type="text">【火山名 諏訪之瀬島 降灰予報(定時)】 現在、諏訪之瀬島は噴火警戒レベル2(火口周辺規制)です。諏訪之瀬島で噴火が発生した場合には、2日21時から24時までは火口から北東方向に降灰が予想されます。</content> </entry> <entry> <title>震度速報</title> <id>https://www.data.jma.go.jp/developer/xml/data/20250301192647_0_VXSE53_010000.xml</id> <updated>2025-03-01T19:26:47Z</updated> <link type="application/xml" href="https://www.data.jma.go.jp/developer/xml/data/20250301192647_0_VXSE53_010000.xml"/> <content type="text">【震源・震度情報】 2日04時23分ころ、地震がありました。</content> </entry> </feed> feed自体には震度の詳細はなくて、entryのlinkにあるxmlを見にいく必要がある。
  5. システム実装:気象庁の地震火山情報Feed 詳細 https://www.data.jma.go.jp/developer/xml/data/20250301192647_0_VXSE53_010000.x ml Body Intensity Observation Prefあたりに最大震度や地域名が出ている。 <Report> <Head

    xmlns="http://xml.kishou.go.jp/jmaxml1/informationBasis1/"> <Title>震源・震度情報</Title> <ReportDateTime>2025-03-02T04:26:00+09:00</ReportDateTime> <Headline><Text> 2日04時23分ころ、地震がありました。</Text></Headline> </Head> <Body> <Intensity> <Observation> <MaxInt>2</MaxInt> <Pref> <Name>岩手県</Name><Code>03</Code><MaxInt>2</MaxInt> <Area> <Name>岩手県内陸南部</Name><Code>213</Code><MaxInt>2</MaxInt> <City><Name>一関市</Name><Code>0320900</Code><MaxInt>2</MaxInt></City> </Area> </Pref> </Observation> </Intensity> </Body> </Report>
  6. Firestoreを選択した理由とコスト利点(再掲) 従量課金制: 使った分だけ支払い 無料枠: 月間50,000回の読み取り、20,000回の書き込み、1GBのストレージが無料 読み取り/書き込み操作: 10万回あたり約$0.06 保存データ: 1GBあたり月$0.18 サーバーレス:

    インフラ管理コスト削減 自動スケーリング: トラフィック増加時も追加設定不要 高可用性: マルチリージョンレプリケーションによる災害時の信頼性確保 ちょっとしたデータの保存だったら、無料枠で済む。 https://firebase.google.com/docs/firestore/quotas?hl=ja#free-quota
  7. バックエンドからのFirestoreアクセス実装 Firestoreユーティリティの実装 from google.cloud import firestore class Firestore: def __init__(self):

    """Firestoreクライアントを初期化""" self.db = firestore.Client() def get_document(self, collection_name: str, document_id: str): doc_ref = self.db.collection(collection_name).document(document_id) doc = doc_ref.get() if doc.exists: return doc.to_dict() return None def update_document(self, collection_name: str, document_id: str, data: dict): doc_ref = self.db.collection(collection_name).document(document_id) doc_ref.set(data, merge=True) # 既存データを保持しつつ更新
  8. Firestoreユーティリティを利用する側の実装 def get_latest_updated_at(): firestore = Firestore() data = firestore.get_document("eq", "latest_updated")

    if data: val = data.get("value") if val == "": return None return val else: return None def save_latest_updated_at(updated_at): firestore = Firestore() data = { "value": updated_at } firestore.update_document("eq", "latest_updated", data)
  9. 機密情報はSecret Managerを使おう2 コンテナ側では、コンテナ定義の「変数とシークレット」の欄で、SecretManagerから 取りたいシークレット名を指定し、環境変数としてコンテナ起動時に渡すことができ ます。 先ほどのSecretManagerにファイルとして定義したアカウントjsonは、 「ボリュームのマ ウント」のタブから、 どこのファイルパスにファイルを置くかという感じで指定します。ここで は

    /service_account/firestore_service_account_json を指定しました。 なので、環境変数GOOGLE_APPLICATION_CREDENTIALSには値として、マウントした パス /service_account/firestore_service_account_json を定義したというわけで す。 ローカル環境でやっていた内容と同じことがCloudRun上でも再現できました。
  10. SlackAPIで、初期安否OK, NG スタンプ付きのメッセ ージを送る(本文) メインの投稿となる部分ではchannelメンション(<!channel>)をつけてチャンネル参加 者にメンションします。(chat.postMessage) EMOJI_LIST = ["o", "x"]

    def post_message(title: str, text: str): # メンションを付ける text = "<!channel>\n" + text text += "\n" + "問題ない方は:o:を、安全上問題がある方は:x:を押して、詳しい状況をこのチャンネルに投稿してください。" # Slackに投稿 data = { "channel": settings.CHANNEL_ID, "username": title, "text": text } headers = {"Authorization": "Bearer " + settings.SLACK_TOKEN} r = requests.post("https://slack.com/api/chat.postMessage", headers=headers, data=data)
  11. SlackAPIで、初期安否OK, NG スタンプ付きのメッセ ージを送る(スタンプ付ける) 続き。肝になるのが、◯と×の初期スタンプの付け方です。スタンプはつけるpostのts をターゲットにreactions.addでつけることができます。oとxのスタンプをつけたいの でEMOJI_LISTをループで回して2回APIを呼び出しています。 EMOJI_LIST = ["o",

    "x"] def post_message(title: str, text: str): # 1. 前ページ # 2. 投稿のtsを取得 ts = r.json()["ts"] # 3. 2種類のスタンプを追加 for emoji in EMOJI_LIST: data = { "name": emoji, "timestamp": ts, } url = "https://slack.com/api/reactions.add?" + "name=" + emoji + "&timestamp=" + ts + "&channel=" + settings.CHANNEL_ID r = requests.post(url, headers=headers, data=data)