ぴよぴよ日記

調べたことで有益そうなことを残してく

Google Cloud monitoringのアラートをGitHub Issueに通知する

タイトルの通り、Google Cloud monitoringのアラートをGitHub Issueに通知するシステムの構築方法を紹介します。

terrafromを使って作成します。

コードはGitHubリポジトリにまとまっています。

github.com

このコードをapplyすることで、Webサービス(EasyBuggy)、監視、アラートをIssueに持っていくパイプラインがデプロイされます。

システム図

このような構成をとっています。

main.tf

早速コードを紹介していきます。このファイルでは、EasyBuggyという脆弱なWebサービスをGCEにデプロイします。

terraform {
  required_providers {
    google = {
        source = "hashicorp/google"
        version = "5.39.0"
    }
  }
}

provider "google" {
  credentials = var.credential_file
  project     = var.project
  region      = var.region
}


resource "google_compute_instance" "easybuggy" {
  name         = "easybuggy-instance"
  machine_type = "n1-standard-1"
  zone         = var.zone

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-11"
    }
  }

  network_interface {
    network = "default"    
    access_config {}
  }

  metadata = {
    "enable-osconfig" = "true"
  }
   
  metadata_startup_script = <<EOF
#!/bin/bash
sudo apt-get update
for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do sudo apt-get remove $pkg; done
sudo apt-get install -y ca-certificates curl git 
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo git clone https://github.com/k-tamura/easybuggy.git
cd easybuggy
sudo docker build . -t easybuggy:local 
sudo docker run -p 8080:8080 easybuggy:local 
EOF
}

resource "google_compute_firewall" "allow-home-ip" {
  name    = "allow-home-ip"
  network = "default" 

  allow {
    protocol = "tcp"
    ports    = ["8080"]
  }

  source_ranges = [var.my_ip]
}

output "instance_ip" {
  value = google_compute_instance.easybuggy.network_interface[0].access_config[0].nat_ip
}

monitoring.tf

こちらのファイルでは監視、アラートをIssueに持っていくパイプラインをデプロイします。main.tfでデプロイしたインスタンスのCPU使用率が80%を超えるとアラートが発生します。

resource "google_pubsub_topic" "alerts_topic" {
  name = "alerts-topic"
}

resource "google_pubsub_subscription" "alerts_subscription" {
  name  = "alerts-subscription"
  topic = google_pubsub_topic.alerts_topic.name
}

resource "google_monitoring_notification_channel" "pubsub_channel" {
  display_name = "Pub/Sub to Cloud Function"
  type         = "pubsub"

  labels = {
    "topic" = google_pubsub_topic.alerts_topic.id
  }
}

resource "google_pubsub_topic_iam_binding" "alerts_topic_publisher" {
  topic = google_pubsub_topic.alerts_topic.name

  role    = "roles/pubsub.publisher"
  members = [
    "serviceAccount:service-${var.project_id}@gcp-sa-monitoring-notification.iam.gserviceaccount.com"
  ]
}


resource "google_storage_bucket" "easybuggy_monitoring_function_bucket" {
  name          = "easybubby_monitoring-functions-bucket"
  location      = "ASIA-NORTHEAST1"
  force_destroy = true
}

resource "google_storage_bucket_object" "function_source_object" {
  name   = "function-source.zip"
  bucket = google_storage_bucket.easybuggy_monitoring_function_bucket.name
  source = "function-source.zip"
}

resource "google_cloudfunctions_function" "issue_creator_function" {
  name        = "issue-creator-function"
  description = "Receive Pub/Sub message from Google Cloud Monitoring and create a GitHub issue"

  runtime    = "python39"
  source_archive_bucket = google_storage_bucket.easybuggy_monitoring_function_bucket.name
  source_archive_object = google_storage_bucket_object.function_source_object.name
  entry_point           = "main"
  region                = var.region

  environment_variables = {
    "GITHUB_API_TOKEN" = var.github_api_token
    "GITHUB_REPO"      = var.github_repo
    "GITHUB_OWNER"     = var.github_owner
  }

  event_trigger {
    event_type = "providers/cloud.pubsub/eventTypes/topic.publish"
    resource   = google_pubsub_topic.alerts_topic.id
  }
}

resource "google_monitoring_alert_policy" "cpu_usage_policy" {
  display_name = "High CPU Utilization Alert"
  combiner     = "OR"

  conditions {
    display_name  = "CPU usage over 80%"
    condition_threshold {
      filter          = "metric.type=\"compute.googleapis.com/instance/cpu/utilization\" AND resource.type=\"gce_instance\""
      duration        = "60s"
      comparison      = "COMPARISON_GT"
      threshold_value = 0.8  
    }
  }

  enabled = true

  notification_channels = [google_monitoring_notification_channel.pubsub_channel.id]
}

main.py

functionsで実行されるコードです。pub/subから受け取ったデータからアラートのtitleとbodyを抜き出してGithub Issueにポストします。

import base64
import json
import os
import logging
import requests
from flask import Flask, request

app = Flask(__name__)

GITHUB_API_TOKEN = os.environ.get('GITHUB_API_TOKEN')
GITHUB_REPO = os.environ.get('GITHUB_REPO')
GITHUB_OWNER = os.environ.get('GITHUB_OWNER')

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def create_github_issue(data):
    issue_title = f"Alert: {data['incident']['incident_id']}"
    issue_body = data['incident']['summary']

    logger.info(f"Creating issue with title: {issue_title} body: {issue_body}")

    response = requests.post(
        f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/issues",
        headers={
            "Authorization": f"token {GITHUB_API_TOKEN}",
            "Accept": "application/vnd.github.v3+json",
        },
        json={
            "title": issue_title,
            "body": issue_body,
        },
    )

    if response.status_code == 201:
        logger.info("Issue created successfully")
        return "Issue created successfully", 201
    else:
        logger.error(f"Failed to create issue: {response.content}")
        return f"Failed to create issue: {response.content}", response.status_code

@app.route('/', methods=['POST'])
def main(d, context): #Need to receive arguments
    envelope = request.get_json()
    
    if not envelope:
        logger.error("No envelope received")
        return "Bad Request", 400
    
    logger.info(f"envelope: {envelope}")

    pubsub_data = envelope.get('data', {})

    logger.info(f"pub_sub_data")

    if not pubsub_data:
        logger.error(f"No outside data received: ")
        return "Bad Request", 400

    try:
        data_base64 = pubsub_data.get('data', '')
        if not data_base64:
            raise ValueError("No data field in outside data")
        
        data = base64.b64decode(data_base64.encode('utf-8')).decode('utf-8')
        logger.info(f"Decoded data: {data}")
        data = json.loads(data)
        
        logger.info(f"Received data: {data}")
    except Exception as e:
        logger.error(f"Error processing message: {e}")
        return "Bad Request", 400
    
    return create_github_issue(data)

if __name__ == "__main__":
    app.run()

デプロイ

内容を理解したらterraform applyしましょう。アプライが成功したらインスタンスIPが表示されます。

動作確認

http://instance_ip:8080にブラウザでアクセスするとこのような画面になります。

「無限ループ」のリンクを押し、無限ループを発生させましょう。

CPU使用率が80%を超えたことを確認し、

GitHub Issueを確認すると、アラートが通知されています。

以上がGoogle Cloud monitoringのアラートをGitHub Issueに通知する流れとなります。

個人開発でWebアプリの開発とデプロイの流れ

個人でWebサービスを開発したいけど、どのような流れで作っていけばいいのかわからない方向けです。個人開発でWebアプリを開発、デプロイをしたのでその流れを共有したいと思います。

作ったもの

麻雀戦績管理アプリ名付けて「PungPals」。雀荘などのオフラインでの対戦結果を残し、個人成績やランキングを確認できます。

pungpals-service-xstpolfd4q-an.a.run.app

開発とデプロイの流れ

1.要件定義、設計

実装がスムーズに進むために、しっかりとしておきましょう。以前記事を書いたので、参考にしてください。

kechigon.hatenablog.com

2.技術選定

今回作ったアプリケーションはDjangoで開発し、Cloud Runにデプロイしています。

選定理由は、

  • Django: 経験があるから。
  • Cloud Run: Djangoアプリのデプロイ方法の公式ドキュメントがあった(後ほど説明します)、マネージドな部分とカスタムできる部分のバランスがちょうどよかったから。

でした。

以下これらの技術を使って、開発デプロイまでの流れを説明していきます。

3.Djangoを使ってアプリケーションを作成

Djangoにはチュートリアルがあり、

を読めば開発方法がわかると思います。

環境構築をし、実装し、ローカルで動作確認をしながら開発していきます。

4.Cloud run へのデプロイ

DjangoアプリのCloud runへのデプロイ方法は公式ドキュメントにまとめられているので、これを見ながら進めます。

cloud.google.com

Djangoアプリケーションを環境に合わせて設定した後コンテナ化し、Cloud Runに載せます。それに伴い、Cloud SQL(データベース)、Secret Manager(シークレット管理)、Cloud Storage(静的アセットの保存など)、Cloud Build(CI/CD)、Artifact Registry(コンテナレジストリ)の作成、設定も行います。ドキュメントではGCRを使っていますが、現在非推奨なので、Artifact Registryをコンテナレジストリとして使用します。

cloud.google.com

オプションですが、GCPへのリソースの作成はTerraformを利用すると、構成管理ができ便利です。

作成するインフラの図

以上のことを行った後のGitHubリポジトリ

PungPalsのコードは公開しているので、参考にしていただければと思います。

github.com

これから

今後は、運用面の課題解決や集客などを行っていく予定なので、ブログにしていくつもりです!

個人開発で要件定義、設計をした話

現在、個人開発で麻雀戦績管理アプリを作っていて、要件定義や設計について考えたことを共有したいと思います。

GitHub ↓ github.com

なぜやったのか

自分はWebエンジニアを目指している大学生ですが、まともなWebアプリを開発した経験がなく、フロントからインフラまでフルスタックで開発しようと思い立ちました。最初は何をするか手探りの状態でしたが、その「何をするのか」を定義するために要件定義、設計から始めました。

何をやったのか

GitHubにissueを作成し、やるべきことを明確化していきました。

要件定義

ここではアプリケーションの機能や、なぜそのような機能にするのかを箇条書きしていきます。この作業を通してやることとやらないことが明確化され、実装もうっすら浮かんできます。

実際の要件定義は以下のような感じになりました。

- ユーザーはまずサインアップする
   - ユーザー名、パスワードを設定する
      - ユーザー名は一意でないといけない
   - ユーザの削除機能はデータ整合性が複雑になるので作らない 
- サインアップ済みのユーザーはログインをする
   - ユーザー名、パスワードを入力
- セッション管理をし、セッションが張られていたらログインを省略し、ユーザーホーム画面に入る。
- 親ユーザーが部屋を作り、他のユーザーを登録していく
   - 作成できる部屋は10部屋まで
   - 親は参加のためのパスワードを設定する
   - 子は親に部屋IDとパスワードを共有してもらう
   - 3人以上いないと対局結果は登録できない、四麻は四人
   - 部屋の削除機能も必要
- 各部屋のホーム画面では各部屋での自分の戦績が表示される
- オフラインで対局した点数結果とそのユーザーと何家かをアプリに登録する
   - 点数結果だけでいいの?
      - 毎回上がり役とかを登録してると、面倒くさいと思う
   - 三麻も登録できるようにする。
   - 点数の合計点を計算し、ユーザーの入力をチェックする
   - 同点の場合は、東寄りが上位
- 取り消し機能も必要
   - 「対局」という粒度で削除できるようにする。これは点数とユーザを登録したひと塊。
      - 間違えてもその「対局」を消し、また新しい「対局」を作ればいい 
- 自分または同じ部屋のユーザーの成績を確認できるようにする 
   - 平均順位
   - 一位率
   - 二位率
   - 三位率
   - 四位率
   - とび率
   - 対局数
   - 平均得点
   - 各項目のランキングも出す
   - 「n局以上」で検索できるようにする
- 対局の登録、削除のたびに個人成績を計算しなおす

データベース設計

ER図を書きます。要件定義にあるように今回のアプリではユーザーのログイン機能や、そのユーザーが作成、参加する部屋、その部屋ごとの戦績など、テーブルが複雑にリレーションを張るので設計に入る前に整理することができます。ある程度機能を盛り込む予定の個人開発では必須でしょう。

画面遷移

画面遷移図を書きます。ページとその機能、ページ同士の遷移を定義します。ここで定義したことはすなわちユーザーアクションのすべてなので、ユーザーアクションごとのテストがしやすくなります。

実際の画面遷移図↓

以上のような要件定義、設計を行うことで、実装での手戻りが少なくなり、快適に実装ができました。

これから

アプリケーション自体はほとんど完成しているので、コンテナ化し、それをECSやCloud Runにデプロイし、運用していく予定です!

Renovateをローカルで動かす

Renovateには様々な実行方法がありますが。ここではローカルで動かす方法について説明します。

Renovateをクローンする

https://github.com/renovatebot/renovateからクローンしましょう。

これ以降はクローンしたリポジトリのルートディレクトリで作業します。

実行環境コンテナ

.devcontainer/Dockerfileをビルドします。

docker build -f .devcontainer/Dockerfile -t renovatebot_local .

Renovateの依存パッケージをインストール

docker run -it --rm -v "$PWD":/usr/src/app -w /usr/src/app renovatebot_local yarn

ローカル実行時のオプション

ドキュメントを参考に、引数を与えてください。 ログレベルdebugでGitLabリポジトリに対して実行する場合は、以下のようになります。

例:

docker run -it --rm -v "$PWD":/usr/src/app -w /usr/src/app -e LOG_LEVEL=debug -e GITHUB_COM_TOKEN=*** renovatebot_local yarn start --platform gitlab --token *** {リポジトリ}

※{リポジトリ}のところはユーザー名/リポジトリ名のような感じです。

おうちk8sクラスターを構築していて詰まったところ

おうち Kubernetes インターンを参考に機材調達->OSインストール->kubeadamでクラスター構築と一通りやってみたので、トラブったところと解決策を共有します。

USBメモリ

Raspberry PiにOSをインストールする際に、SDカードの性能が悪いと失敗します。私は安物で済ませようとした結果、三枚目でようやく成功しました。またインストール後も、ディスクの読み書き速度は全体のパフォーマンスに影響を与えるので、性能にはこだわるべきです。以下のサイトなどを参考に選びましょう。

cgroups の Memory Subsystem を有効化

私がインストールしたOSでは、cgroups の Memory Subsystem がデフォルトで無効化されているため、/boot/firmware/cmdline.txtに下記を追加する必要がありました。

cgroup_memory=1 cgroup_enable=memory

しかし、編集し再起動しても有効化されませんでした。原因は改行を入れて追加していたことでした。改行せず行末に追加するのが正しいです。

クロージャーのメモリ割り当てについて(Go言語)

A Tour of GoでGo言語に入門していて、クロージャーのメモリ割り当てについて疑問に思ったので調べた。

クロージャーとは

A Tour of Go での説明をまとめると、

  • 本体の外部から変数を参照する関数値

  • 関数は、参照した変数にアクセスして割り当てることができる

という特徴がある。

サンプルコード

package main

import "fmt"

func adder() func() int {
    sum := 0
    return func() int {
        sum++
        return sum
    }
}

func main() {
    f := adder()
    for i := 0; i < 10; i++ {
        fmt.Println(f())
    }
}

出力

1
2
3
4
5
6
7
8
9
10

adder 関数はクロージャーを返し、各クロージャーは、sum 変数にバインドされている。

疑問点

サンプルコードではクロージャーが、adder関数で定義されたsum変数を参照、割り当てしてる。しかし、関数呼び出しといえばスタックフレームを用いるイメージしかない私にとっては、sum変数の参照がどこに残っているのか疑問。おそらくヒープ領域に割り当てられてる?

GitHub issue でのやり取り

調べたところ、同じ疑問に答えているissueを見つけた。 質問者は、同じような処理をクロージャーを使用する場合と使用しない場合で試している。そして、クロージャーを使用した場合だとヒープ領域への割り当てが行われると言っている。

実際のコード

package main

import (
    "fmt"
    "sync"
    "testing"
)

type Object struct {
}

var p sync.Pool = sync.Pool{
    New: func() interface{} {
        return &Object{}
    },
}

type Func struct {
    ctx interface{}
}

func (this *Func) Run() {
    p.Put(this.ctx)  
}

func RunWithFunc() Func {
    ctx := p.Get()
    return Func{ctx: ctx}
}

func RunWithClosure() func() {
    ctx := p.Get()
    return func() { p.Put(ctx) }
}

func Test1() {
    cleanup := RunWithFunc()
    cleanup.Run()
}

func Test2() {
    cleanup := RunWithClosure()
    cleanup()
}

func main() {
    f1 := testing.AllocsPerRun(1000, Test1)
    f2 := testing.AllocsPerRun(1000, Test2)
    // 0
    fmt.Println(f1)
    // 1
    fmt.Println(f2)
}

コードの詳しい内容は、

  • クロージャーを使わないRunWithFuncと使用するRunWithClosureを実行する。

  • どちらも大雑把に言うと、空の構造体をsync.Poolから取り出したり戻したりする。

  • クロージャーを使うとヒープ領域への割り当てが行われることをtesting.AllocsPerRunが示す。

といった感じ。

回答者は以下のように言っている。

  • 問題は、RunWithClosure がクロージャーを返す必要があることです。関数が実行される前にスタック フレームがなくなるため、スタックに割り当てることができません。 可能な場合は、スタックにクロージャーを割り当てます。

  • スタック上にクロージャ(これらの2つのフィールドの匿名構造体)を割り当て、呼び出された関数にそれらへのポインタを渡すことができますし、実際に行っています。ここでの問題は、その構造体がRunWithClosureの内部で割り当てられ、RunWithClosureのフレームは、cleanupを呼び出すまでになくなってしまうことです。そのため、RunWithClosureのフレームでクロージャを割り当てることはできません。それは、ヒープ上に割り当てられなければなりません。 もし、RunWithClosureをその呼び出し元にインライン化すれば、そのスタック・フレームが十分に長く生きるので、呼び出し元でクロージャを割り当てることができるようになります。

クロージャーが実行される前に、参照先をもつスタックフレームがなくなってしまう場合、それをヒープ領域に割り当てるらしい。またそれを避けたい場合は、関数になっている部分をインライン化するといいらしい。

まとめ

Go言語に入門していて、クロージャーが参照している変数がどこに残っているか疑問に思ったが、GitHub issueのやり取りから、予想した通り、ヒープ領域への割り当てが行われていることがわかった。