ぽよメモ

レガシーシステム考古学専攻

ISUCON優勝したくてGrafana Cloudで分析基盤を整えた

去年のnonyleneくんのブログを読んだ僕の、一年越しの回答です。

nonylene.hatenablog.jp

poyo.hatenablog.jp

ISUCON13に参加し、最高107000点くらい106457点をマークしましたがfailしたチーム ツナ缶です。来年は優勝します。

公式から全チームのスコアおよび結果が出ました。failしていなければ最低でも16位くらいだったと思われ、大変無念です。 ISUCON13 受賞チームおよび全チームスコア : ISUCON公式Blog

今回は割とよい分析基盤が出来たかなと思ったので紹介します。

メンバー紹介:

  • pudding(自分):インフラ・ちょっとアプリ担当
  • taxio:アプリ担当
  • shanpu:アプリ担当

Grafana Cloudとは

詳しくは↓を参照

poyo.hatenablog.jp

2ヶ月ほど前に家の監視基盤をGrafana Cloudに移したのは、ISUCONに向けた素振りをするためでもありました。Grafana Cloudで基盤を作ると良さそうと感じたのは主に以下の点でした。

  • ログの閲覧ができる
  • ログをパースしてメトリクスとして集計して可視化できる
  • メトリクスを送信することでサーバの負荷状況を同じプラットフォームで可視化できる
  • Goのpprofに対応した継続的プロファイリングにより、フレームグラフを同じプラットフォームで閲覧できる
  • 上記を全て単一のAgentでアプリにほとんど手を入れずに実現できる←ここ重要

他にも仕事でGrafanaやPrometheus、Lokiとは仲良くしているので、これらを使った解析基盤は自分の知見が活かせるのでは(そして仕事でも使えるのでは)という思惑がありました。

できたもの

APIエンドポイントごとの解析

エンドポイントごとのレスポンスタイムの解析

alp相当の解析ができるダッシュボードです。nginxのアクセスログをパースしてAPIエンドポイント・メソッド・レスポンスタイムの情報を頑張って組み立てています。各テーブルヘッダをクリックすることでインタラクティブにソートできるので、見たい人が見たい順に並べ直すことができます。

URIはリンクになっており、各エンドポイントごとの詳細を別ダッシュボードで閲覧できるようになっています。

あるエンドポイントに関する詳細の解析

これらは全てグラフや右上の日時指定ボタンから指定日時のデータを見ることができるため、何時頃の結果、などを解析することも可能です*1

ログの閲覧・検索

アプリケーション・nginxのログの横断閲覧および検索

時間範囲を指定して、アプリケーションやnginxのログを一つの画面で横断検索できるようにしました。ページ上部のドロップダウンメニューから見るインスタンス(全てまとめてみることも可)を、検索ボックスに任意文字列を入れて絞り込みなどができるようになっています。これらは自分はベンチマークのfail時などによく確認していました。

またエンドポイントごとの詳細画面では、エンドポイントの正規表現を使ってログをフィルタし、特定のエンドポイントのアクセスログだけを引っ張って来るなどもしていました。

エンドポイントごとのログの閲覧

MySQLでスロークエリログを有効化した場合、それも収集するようにしていました。これの数はかなり膨大で結構大変な事になっていたと思います。

スロークエリログの閲覧および検索

指定した時間範囲で指定した秒数以上かかったクエリを表示することができます*2。ロック時間などで並び替えることも出来るようになっています。

インスタンスの負荷状況の可視化

インスタンスではNode Exporterが動作しており*3、各ホストの状況のメトリクスを送っていました。今回は15秒に一回送信することにしていましたが、もう少し解像度が高い方がISUCONという競技では使いやすかったなと思います。

Node Exporterのダッシュボード

フレームグラフの表示

Grafana LabsがPyroscopeを買収したことにより実現した機能です。Grafana Agentが定期的に /debug/pprof/profile などを叩くことで、Grafana Cloudへこれらのプロファイリングデータを送信して可視化しています。

Goアプリケーションのフレームグラフ

各関数にフォーカスして拡大することもでき、検索ボックスから特定の関数を調べることも可能です。go tool pprof を使って閲覧するのと変わらないような使い勝手で、しかもこちらが実施することは最初のGrafana Agentのセットアップと、Goアプリケーションでのpprof対応だけでした。セットアップはAnsibleで自動化済み、pprof対応はlabstack/echoでは依存足して1行書き足すだけだったので、ほぼ労力ゼロでこれが使えるのはかなり良いと思いました。

ログ・メトリクス・プロファイルが全て単一のプラットフォームで見れるため、他のツールの使い方を覚えたりログインを使い分ける必要がありません。また、当日に追加で行った変更は以下のような、Grafana Cloudを用いなくてもやっているいつものことだけです。

  • nginxのログ形式の変更
  • MySQLのスロークエリログの有効化
  • Goアプリケーションのpprof対応
  • APIエンドポイントを識別する正規表現の記述

これらの対応を全て終えてISUCON本番で完全に動作し始め、ベンチマーク結果を解析出来るようになったのはおおよそ競技開始から40分後くらいでした。その間もtaxioにはマニュアルの読み込みやUIを触って理解を深めてもらうなどをやってもらっており、初動としてはそこまで悪くないかなと思っています。

さらに、そこから先で解析基盤に手を入れることはほとんどありませんでした。撤退も単にGrafana Agentを消す、pprof対応を消す、ログを出さないようにするだけで良いので非常に簡単でした。

構築編

Grafana CloudはSaaSなので、自分でセルフホストする必要がありません。収集対象のインスタンスにエージェントを入れるだけで使えます。

Grafana Agent Flowのインストール

Grafana Cloudへのログやメトリクス、プロファイルデータの送信にはGrafana Agentというエージェントを利用します。これはstatic modeYAMLで設定を書ける)とflow mode(HCLみたいなriverという設定言語で書ける)の2種類のモードがありますが、プロファイルデータを送れるのがflow modeだけなので今回はflow modeを使います。

GitHub Releasesでdebパッケージが公開されているので単にこれをダウンロードしてきて各ホストに入れます。自分はAnsibleで全ホストにサクッと入れられるようにしておきました。

また、送信に必要なのでGrafana CloudのAPIキーを各ホストに撒いておきます。適当なところにファイルに書いて置いておきました。Grafana CloudではAPIキーのExpire dateを設定できるので、翌日とかにしておくと安心です。

メトリクスを収集する

flow modeではriverで設定ファイルを書くのですが、これはモジュール化出来るようになっています。今回は以下の様なモジュールを書いていました。

// このモジュールが取る引数を宣言できる。
argument "write_to" {
    comment = "remote write target"
}

// Node Exporterの設定をする
prometheus.exporter.unix "default" {
    include_exporter_metrics = false
    // ISUCONではそこまで細かいメトリクスは要らないので適当に
    set_collectors = [
        "cpu", "diskstats", "filefd", "filesystem", "loadavg", "meminfo", "netclass", "netdev", "vmstat", "sockstat",
    ]
    filesystem {
        fs_types_exclude =  "^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|tmpfs|fusectl|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$"
    }
    netclass {
        ignored_devices = "^(veth.*|cali.*|[a-f0-9]{15})$"
    }
    netdev {
        device_exclude = "^(veth.*|cali.*|[a-f0-9]{15})$"
    }
}

// 設定したNode Exporterからメトリクスをスクレイプする設定
prometheus.scrape "unix" {
    targets = prometheus.exporter.unix.default.targets
    // このモジュールの引数で渡された書き込み先に渡す
    forward_to = [argument.write_to.value]
    scrape_interval = "15s"
}

このモジュールを使う側では、以下の様に宣言するだけです。

// Grafana CloudのAPI Keyを書いておくファイル
local.file "api_key" {
    filename = "/etc/grafana-agent-api-key"
    is_secret = true
}

// Grafana Cloudの書き込み先の設定
prometheus.remote_write "grafanacloud" {
    endpoint {
        url = "REMOTE WRITE URL"
        basic_auth {
            username = "BASIC AUTH USER"
            password = local.file.api_key.content
        }
    }
}

// ラベルを書き換える
prometheus.relabel "replace_instance" {
    forward_to = [prometheus.remote_write.grafanacloud.receiver]
    rule {
        action = "replace"
        target_label = "instance"
        // Ansibleでここを各ホストごとの識別名に置き換えていた
        replacement = "{{ inventory_hostname }}"
    }
}

module.file "nodeexporter" {
    // 先ほどのモジュールまでのファイルパス
    filename = "/etc/grafana-agent/rivers/nodeexporter.river"
    arguments {
        write_to = prometheus.relabel.replace_instance.receiver
    }
}

ログを収集する

ISUCONではログの収集パターンは主に2つだと考えていました。

  1. journaldのログ(journalctl -u ~~ などで見る物)
  2. ローカルにあるファイルに書かれるログ

Grafana Agentではどちらも対応可能です。まずはjournal logの収集から。

argument "write_to" {
    comment = "Loki remote write url"
}

loki.relabel "journallog" {
    // forward_toはloki.source.journal側で指定するので記述しない
    forward_to = []
    // 書き換えのルールの指定。
    // `__` から始まるラベルは送信時にドロップされるので必要なものを抽出する。
    rule {
        source_labels = ["__journal__systemd_unit"]
        target_label = "app"
    }
    rule {
        source_labels = ["__journal_priority_keyword"]
        target_label = "level"
    }
}

// Journaldをソースとしてログを収集する
loki.source.journal "journallog" {
    forward_to = [argument.write_to.value]
    // 最大どれくらい前のログまで収集するか。過去のログは要らないので回収しない。
    max_age = "10m"
    // ラベルの書き換え設定
    relabel_rules = loki.relabel.journallog.rules
    // 静的に設定するラベルの指定
    labels = {
        job = "loki.source.journal",
    }
}

このモジュールを使ってGrafana Cloudに送信する設定は以下

// Grafana Cloudのログのリモート書き込み先の設定
loki.write "grafanacloud" {
    endpoint {
        url = "REMOTE WRITE URL"
        basic_auth {
            username = "BASIC AUTH USER"
            password = local.file.api_key.content
        }
    }
}

// Grafana Cloudのハードリミットに引っかからないよう、大きすぎる/古すぎるログを落とす
// Ref: https://grafana.com/docs/grafana-cloud/cost-management-and-billing/understand-your-invoice/usage-limits/#logs-usage-limits
loki.process "drop_abnormals" {
    forward_to = [loki.write.grafanacloud.receiver]
    stage.drop {
        longer_than = "255KB"
        drop_counter_reason = "too long"
    }
    stage.drop {
        older_than = "1h"
        drop_counter_reason = "too old"
    }
}

// メトリクスと同様にinstance名にホストの識別子を入れる
loki.relabel "replace_instance" {
    forward_to = [loki.process.drop_abnormals.receiver]
    rule {
        action = "replace"
        target_label = "instance"
        replacement = "{{ inventory_hostname }}"
    }
}

// 今回はisupipe-goのログがあれば良かったのでこう書いていますが、
// regexを工夫することでpdns.serviceのログも収集できます。
loki.relabel "filter_app" {
    forward_to = [loki.relabel.replace_instance.receiver]
    rule {
        source_labels = ["app"]
        regex = "^isupipe-go.*$"
        action = "keep"
    }
}

module.file "journallog" {
    // 先ほどのモジュールまでのファイルパス
    filename = "/etc/grafana-agent/rivers/journallog.river"
    arguments {
        write_to = loki.relabel.filter_app.receiver
    }
}

次にファイルからのログを収集します。単純なログであれば以下の様に書けば収集できます。

argument "write_to" {
    comment = "Loki remote write target"
}

// ラベル足したり要らないラベル落としたりとか
loki.process "error" {
    forward_to = [argument.write_to.value]

    stage.static_labels {
        values = {
            app = "mysql",
            type = "error",
        }
    }
    stage.label_drop {
        values = ["filename"]
    }
}

// MySQLのエラーログを探索させ、存在する場合ターゲットに加える
local.file_match "errorlog" {
    path_targets = [{"__path__" = "/var/log/mysql/error.log.*"}]
}

// 発見されたエラーログをソースとしてログを送信する
loki.source.file "error" {
    targets = local.file_match.errorlog.targets
    forward_to = [loki.process.error.receiver]
    // 過去のデータは要らないので、起動時は常に末尾から読み取っていく。
    tail_from_end = true
}

先述したようにriverでは様々なモジュール同士の接続を自由に書けるので、このモジュールとjournallogモジュールはログの処理フェーズを共有できます。

module.file "mysql_error_log" {
    filename = "/etc/grafana-agent/rivers/mysql_error_log.river"
    arguments {
        // ラベルの書き換え設定から繋げることで、そこから先を共通化できる
        write_to = loki.relabel.replace_instance.receiver
    }
}

アクセスログの収集

nginxのアクセスログを元にダッシュボードを表示したいのですが、通常はパスにパラメータなどが入っており統計処理に向いていません。同様のことにnonyleneくんも気付いており、収集時に正規化するという方法を取っていました。

nonylene.hatenablog.jp

Grafana Agentにおいても同様の処理を行います。前提としてnginxのログ形式は以下の様になっているとします。

    log_format main "time:$time_local"
                    "\thost:$remote_addr"
                    "\tforwardedfor:$http_x_forwarded_for"
                    "\treq:$request"
                    "\tstatus:$status"
                    "\tmethod:$request_method"
                    "\turi:$request_uri"
                    "\tsize:$body_bytes_sent"
                    "\treferer:$http_referer"
                    "\tua:$http_user_agent"
                    "\treqtime:$request_time"
                    "\tcache:$upstream_http_x_cache"
                    "\truntime:$upstream_http_x_runtime"
                    "\tapptime:$upstream_response_time"
                    "\tvhost:$host"
                    "\trequest_id:$request_id";
    access_log  /var/log/nginx/access.log  main;

まずは前処理としてnginxのログをパースします。

// nginxのアクセスログをパースして各パラメータをラベルに加える
loki.process "access" {
    // 後段の処理を指定
    forward_to = [loki.relabel.access.receiver]

    stage.static_labels {
        values = {
            app = "nginx",
            type = "access",
        }
    }
    stage.label_drop {
        values = ["filename"]
    }
    // LTSV形式のログ行をパースする。正規表現でバックスラッシュを使いたい時は2つ重ねる。
    // 後から気付いたけどこれはjsonにしておけばもっと楽にパースできたので、
    // 皆さんはこれをパクらずにnginxにjson形式でログを吐かせてください。
    stage.regex {
        expression = "^time:(?P<time>.*)\\thost:(?P<host>.*)\\tforwardedfor:(?P<forwardedfor>.*)\\treq:(?P<request>.*)\\tstatus:(?P<status>.*)\\tmethod:(?P<method>.*)\\turi:(?P<uri>.*)\\tsize:(?P<body_bytes_sent>.*)\\treferer:(?P<http_referer>.*)\\tua:(?P<http_user_agent>.*)\\treqtime:(?P<request_time>.*)\\tcache:(?P<upstream_http_x_cache>.*)\\truntime:(?P<upstream_http_x_runtime>.*)\\tapptime:(?P<upstream_response_time>.*)\\tvhost:(?P<vhost>.*)\\trequest_id:(?P<request_id>.*)$"
    }
    // ログ行からアクセス日時をとりだしてログの記録日時とする
    stage.timestamp {
        source = "time"
        format = "02/Jan/2006:15:04:05 -0700"
    }
    // 正規化したいパラメータを抽出する
    stage.labels {
        values = {
            status_normalized = "status",
            uri_normalized = "uri",
        }
    }
}

local.file_match "accesslog" {
    path_targets = [{"__path__" = "/var/log/nginx/access.log.*"}]
}

loki.source.file "access" {
    targets = local.file_match.accesslog.targets
    forward_to = [loki.process.access.receiver]
    tail_from_end = true
}

後は実際に本番で実装されているエンドポイントに合わせて設定ファイルを書いて正規化させるだけです。

loki.relabel "access" {
    forward_to = [argument.write_to.value]
    // alpが 2XX や 5XX のように表示するのが分かりやすかったので同じ事をする
    rule {
        source_labels = ["status_normalized"]
        regex = "^([0-9])[0-9]+$"
        replacement = "${1}XX"
        target_label = "status_normalized"
    }

    // クエリパラメータを消す
    rule {
        source_labels = ["uri_normalized"]
        regex = "^(.*)\\?(.+)$"
        replacement = "$1"
        target_label = "uri_normalized"
    }

    // 以下の様なルールをエンドポイントの数だけ書く。マッチするラベルだけが置き換えられる。
    // 本番ではAnsibleでテンプレートを書いておいて自動生成した
    rule {
        source_labels = ["uri_normalized"]
        regex = "^/api/user/[a-zA-Z0-9]+$"
        replacement = "/api/user/[a-zA-Z0-9]+"
        target_label = "uri_normalized"
    }
}

Grafanaのダッシュボードではこの送信されたログを再度同じ正規表現でパースし直してレスポンスタイムを取りだし、ラベルとして付与された uri_normalized などを元に行数をカウントしたり最悪レスポンス時間を取り出したりしています。

スロークエリログの収集

MySQLのスロークエリログは、一行ごとに結果が出るのではなく複数行にわたっています。こういったログも設定を書くことで収集できます。

loki.process "slowquery" {
    forward_to = [argument.write_to.value]

    stage.static_labels {
        values = {
            app = "mysql",
            type = "slowquery",
        }
    }
    stage.label_drop {
        values = ["filename"]
    }
    // この行から次のこの行までを一つのログとして扱う
    stage.multiline {
        firstline = "^#\\sTime:.*$"
    }
    // ログをパースする正規表現
    stage.regex {
        expression = "#\\s*Time:\\s*(?P<timestamp>.*)\\n#\\s*User@Host:\\s*(?P<user>[^\\[]+).*@\\s*(?P<host>.*]).*Id:\\s*(?P<id>\\d+)\\n#\\s*Query_time:\\s*(?P<query_time>\\d+\\.\\d+)\\s*Lock_time:\\s*(?P<lock_time>\\d+\\.\\d+)\\s*Rows_sent:\\s*(?P<rows_sent>\\d+)\\s*Rows_examined:\\s*(?P<rows_examined>\\d+)\\n(?P<query>(?s:.*))$"
    }
    // 時間をパースしてそのログの時間として設定する
    stage.timestamp {
        source = "timestamp"
        format = "RFC3339Nano"
    }
}

local.file_match "slowquery" {
    path_targets = [{"__path__" = "/tmp/slow_query.log*"}]
}

loki.source.file "slowquery" {
    targets = local.file_match.slowquery.targets
    forward_to = [loki.process.slowquery.receiver]
    tail_from_end = true
}

これは生データのみしか収集できないので、pt-query-digest等も組み合わせることを検討すべきかも知れません。自分たちのチームではpt-query-digestはそれは別途ベンチの度に走らせてリポジトリのIssueに結果を書いており、それで済ませていました。

ベンチマーク実行ごとに投稿されるコメント

プロファイルデータの取得

Goアプリケーション自体が /debug/pprof エンドポイントを実装していれば、Grafana Agentが書くことはたったこれだけです。実際にはインスタンス名やサービス名、アクセス先のポート番号などは全てAnsibleで動的に埋めています。

pyroscope.write "grafanacloud" {
    endpoint {
        url = "REMOTE WRITE URL"
        basic_auth {
            username = "BASIC AUTH USER"
            password = local.file.api_key.content
        }
    }
}

pyroscope.scrape "pprof" {
    targets = [
        {"__address__" = "localhost:8080", "instance" = "{{ inventory_hostname }}", "service_name" = "isupipe"},
    ]
    forward_to = [pyroscope.write.grafanacloud.receiver]
    scrape_interval = "15s"
}

ハマったところ

何でもログのラベルに付与すると死ぬ

Grafana Cloudのログ収集機能には max_global_streams_per_user というハードリミットがかかっています。値は5000です。

grafana.com

これは送信するログに付与するラベルの値の組み合わせの数が相当します。つまり、インスタンスの数が5つあり、それぞれ3種類の値を取るラベルが2つあるとすると、5 × 2 × 3 = 30 のactive streamとなります。

当初、nginxのアクセスログをパースして全てラベルとして付与していましたが、練習の段階でGrafana Agentがこのリミットに引っかかったというエラーを吐いていました。当然です。レスポンスタイムの値、正規化していないURLのパスなどがそのまま入ると、あっという間に5000通りくらいの組み合わせに到達します。

一度に全ログが取得できるわけではない

GrafanaのダッシュボードではTransformという取得したメトリクスを加工する機能が存在しています。そこでGROUP BYしてカウントしたりも可能です。最初期のダッシュボードではLogQLを使ってnginxのアクセスログを全行取ってきてTransform機能でカウントしようとしていましたが、デフォルトでは1000行まで、最大でも5000行までしか一度に取得できず不完全な解析となってしまいました。

最終的には以下の様にLogQLでメトリクスを取ってきて、Transformは最低限になっています。

エンドポイントごとの解析に使われているクエリ

エンドポイントごとの解析画面のTransform

メトリクスのDPM

送信するログ量自体は競技時間全てを合わせても無料枠を超えませんが、メトリクスをどの頻度で取得するかによって課金額に影響するDPMというパラメータがあります。

grafana.com

これは Data points per minute の略で、Grafana Agentのscrape intervalのデフォルト60秒が1DPMです。これがGrafana Cloud FreeおよびProアカウントのデフォルトです。これを超えた場合、課金額に影響があります。ISUCONでは60秒ごとでは遅すぎるので、競技中のみ15秒に一回スクレイプするようにしました。おそらくこれくらいなら大丈夫ですが、長期間使うインスタンスでは辞めておくべきでしょう。

競技終盤はアクセス数が増えて解析が遅い

これは正規表現アクセスログをパースしていたことによる可能性を拭えないのですが、競技終盤になるとエンドポイントごとの解析画面は結果の表示に数十秒かかるようになってしまいました。フレームグラフは見れるので、とりあえず開いてクエリを走らせている間にフレームグラフを見るといた感じにしていました。

一位のNaruseJunのチームだとおそらく中盤当たりで使い物にならなくなっていた可能性があります。とはいえほとんどのチームでは自分たちが捌いていたくらいのアクセス数に収まっていると思うので、十分実用的と言えるのではないでしょうか。

完全に同じログ行は重複排除で消える

今回前提としたnginxのログの形式は実はalpのREADMEに書かれているLTSV形式そのままではなく、末尾にrequest_idを追加しています。これはGrafana CloudというかおそらくそのバックエンドになっているLokiのQuerierの制約を回避するためです。

QuerierはLogQLを元に、データを問い合わせて返すコンポーネントです。
https://grafana.com/docs/loki/latest/get-started/components/#querier

the querier internally deduplicates data that has the same nanosecond timestamp, label set, and log message.

これには冗長化のために重複しているログを排除するという機能が実装されています。nginxのログのタイムスタンプの精度は秒単位のため、アクセス数の多いエンドポイントでは容易に全く同じログが吐かれてしまうことがあり、alpの結果とGrafanaダッシュボード上での結果が一致しないことが起きていました。

ログ行自体が完全に一致しなければ異なるログ行として扱われるため、request_id(リクエストごとに一意な値)を出力することで、重複排除によって消えないようにしています。

本番でやったこと・感想

自分が本番でやったことです。

  • ~10:40:環境構築・解析基盤の準備
  • ~12:00:icon画像をDBから剥がしてX-Accel-Redirectでnginxにサーブさせ、sha256のチェックサムをオンメモリにキャッシュ
  • ~13:30:ミドルウェアのチューニングや、moderateHandlerの高速化
  • ~15:30:DNS水責め対策に奮闘したが失敗
  • ~17:00:DBのレプリケーションをしてリードレプリカを使うようにしたが整合性チェックが通らず失敗
  • ~17:30:最終的な複数台構成にした。(nginx, app, pdns)、(DB for app)、(DB for DNS)という構成。
  • ~18:00:全ての解析系を止めて再起動試験をし、キャッシュ実装がダメでfailすることに気付く

途中ベンチマークの不具合で大きな変更がちゃんと通るかどうかを検証できず、競技時間伸びないかなとちょっと期待していました。それは叶わず残念。

taxio、shanpuがあらゆるものをメモリに載せようと奮闘している間に自分はDNS水責めの対策をしていました。正直に言ってDNSの知見があまりに乏しく、PowerDNSの設定を変えてDBの向き先を変更してアプリのDBの負荷を減らすのが限界でした。しかし、これも最善手だったとは思っていません。DNSを正しく捌いてしまうことでむしろアプリの負荷が増えていた可能性があります。DNSサーバを自前実装する可能性を一瞬検討し、理解と実装に半日はかかると踏んでDNSを切り捨てる判断をしました。チャレンジすべきだったのかなぁと後悔はつきません。

今回のアプリケーションはread heavyなワークロードなのでリードレプリカが効くかと思ったのですが、自分が途中でミスってレプリケーションそのものに時間がかかったこと、組んだ構成が整合性チェックでどうしても通らなかったことで諦めたのも時間的には痛かったです。後から思えば、もっとオンメモリで処理できました。これは仕事ではないのでSREとしての自分を捨てるべきでした。

あとはキャッシュ実装が速さを優先するあまり初期化や再起動後の状態への意識を失っており、おそらく参加して初のfailでの終了となりました。これはあまりにも痛いです。いくら高スコアを記録しても、failしていては改善しないのと同じです。

良かったところを言うと、自分の役目は基本的に解析基盤を整え、アプリ担当が1コマンドで本番に変更を反映出来る状況にした時点で終わっていると言えなくもないです。後はX-Accel-Redirectでnginxに静的ファイルを捌く処理を移す改善を午前で終わらせられ、午後の余裕がなかったタイミングで取り組む必要が無かったのは心理的に非常に効きました。自分の経験値がまだまだだということがよく分かったので、参加して良かったです。運営の方々、今年もありがとうございました。

まとめ

Grafana Cloudはアプリケーションにできるだけ手を入れず、いつも通りの構成のままインフラ担当が裏から手を回して基盤を構築出来る便利なSaaSです。アプリ本体に手を入れれば、OpenTelemetryで分散トレーシングも可能です。無料枠も大きいので事前の練習などで使うのもおすすめです。

ISUCON12で初の本選出場を果たし、ISUCON13は逆にfailして失格しました。ラノベのセオリーとしては次は優勝のはずなので、来年も頑張ります。

*1:なおGrafana Cloudは直近ingestしたデータのみがキャッシュに載っていて早いのか、古いログは結果の表示に少し時間がかかってしまうことが分かっていました。競技中は直近のデータを主に見るだろうということで今回はヨシとしました。

*2:謎の__stream_shard__というのがありますが、練習の時は無かったので自分もよく分かっていません。

*3:実際にはGrafana Agentの内部で自動的に動作しているので、おそらくnode-exporterがそのまま動いているわけでは無いと思います。

おうちサーバ無料監視 by Grafana Cloud

TL; DR

  • Grafana Cloudは無料枠が大きく個人でも使いやすい
    • メトリクス・ログ・アラートに加えトレースまで扱える
  • Grafana Agentをnon-rootで動かしていると埋め込みのcAdvisorでは正しくメトリックを拾えない事があるので別途立てると良い
  • みんな構造化ログにしてくれ

おうちサーバも監視したい

昨今は安価なミニPCでも十分な性能が出るようになってきており、特に中国製のミニPCがコスパの良さから注目されています。これにより自宅におうちサーバとして置いたり、複数購入してKubernetesクラスタを組むなどが安価でできるようになり、私の周りではにわかに盛り上がりを見せています。

ある程度サーバを運用していると死活監視やディスク容量の警告、ログの検索などが欲しくなります。昨今ではOSSでこれらの機能を簡単にデプロイできるようになってきてはいますが、そもそもおうちサーバに監視基盤を載せてしまうとまるごとダウンしたときには検知出来ません。外部サービスに監視を任せたいというのが正直なところです。ただ、ミニPCをサーバにせざるを得ないほどの貧民であるため、当然富豪的SaaSを使うことは出来ません。

この記事ではいくつかのSaaSを簡単に検討し、最終的に選んだGrafana Cloudについての簡単な解説をしたいと思います。必須の要件は以下の3つです。

  • メトリクスの可視化:最低1週間程度はサーバの状態を可視化ができる
  • ログ:最低1週間程度はサーバのログを保存・検索できる
  • アラート:メトリクスに基づく死活監視ができる

SaaS 比較検討

記載されている価格・機能は2023年9月ベースのものです。

Mackerel

元々はこのMackerelを使っていました。このブログの運営元であるはてな株式会社が提供している純国産のモニタリングSaaSです。ホストベースでの課金になりますが、無料枠があります。

  • 1スタンダードホストあたり200のメトリクス。1日だけ表示可能。5ホストまで無料。
  • ログの扱いは無し
  • アラートは10個まで

機能的にもう少し欲しいというのが正直なところでした。今回はこの乗り換え先を検討していく形になります。

Datadog

モニタリングのSaaSとして古くからあり、業務でも触れているためその信頼性の高さや使い勝手はよく知っています。ただ無料枠は大きくなく、料金も高額であるため今回は見送りとしました。

  • メトリクス
    • 無料枠では5ホストまで、1日以内のデータが閲覧可能。
    • 有料枠は18.75ドル/ホストで15ヶ月以内のデータが閲覧可能(Proプラン)。高い。
  • ログ
    • 無料枠無し。取り込みに0.13ドル/GB/month、保持に2.13ドル/100万ログイベント/month(15日間の保持期限の場合)の料金がかかる。
  • アラート
    • メトリクスに含まれている。無料枠には記載なし。Pro以上は無制限。

Elastic Cloud

Elasticsearchで有名なElastic社のSaaSです。以前はELKスタックと呼ばれていた、Elasticsearch、Logstash、Kibanaをベースとした基盤を提供しています。インスタンスの構成をユーザが選んでプロビジョニングするのが特徴で、ある程度小規模な環境を組めます。

構築した環境の構成に関わらず、基本的な機能はElastic Cloudのプランによります。最も安価なStandardでもサーバメトリクスの収集、ログの保存・検索、アラートは一通り揃っていそうでした*1。保存可能な容量などは構築した環境の構成に寄ります。

いくつか試して最も安い構成は以下でした(全通り試したわけではないのでもっと安価にできるかも)。

0.0234ドル/hourなので、月額に換算すると

$0.0234 * 24 hour * 30 days = $16.848

となり、およそ月額17ドルほどとなりました。30GBまでのログ・メトリクスが保存・検索できます。課金の単位がこのインスタンスに対するものなので、おうちサーバ台数が増えても料金は変わらないところはメリットです。

悪くはないですが、この構成では画像の警告にも出ているとおり容易にデータロスが発生する可能性があります。冗長度を持たせるために2 zoneにすると単純に料金2倍です。今回は見送りとしました。

NewRelic

これもオブザーバビリティ界隈では著名なSaaSの一つです。自社のアプリケーションにNewRelicのAPMが組み込まれているという方も多いのではないでしょうか。実は無料枠がそこそこ大きく、個人でも使いやすくなっています。

いくつか制限がありますが、これらがどれくらい通常利用に影響してくるかまでは検討できていません。

https://docs.newrelic.com/jp/docs/accounts/accounts-billing/new-relic-one-pricing-billing/new-relic-one-pricing-billing/#free

今回APMの利用予定がなく、NewRelicの強みを活かせそうには思えなかったので見送りとしました。

Grafana Cloud

Grafana CloudはGrafanaが提供しているSaaSでGrafana製品が内部では利用されています。

他にもTempoによるトレーシング、k6による負荷試験の実施や、Grafana Pyroscopeを使ったパフォーマンスプロファイルの取得まで様々な機能があります。

様々なデータソースとの接続が可能ですが、基本的にはPrometheus+Lokiのマネージドサービスであると捉えています。実際にセットアップしていくとPrometheusやLokiのRemote Write用のURLが用意されており、そこに指定されたユーザ名と生成したAPIトークンで接続して書き込みます。そのため、必ずしも指定されたAgentを使わなければならないわけではなく、最終的にオンプレでPrometheusやLokiをホストすればスムーズに移行できるのも良いところだと思いました。

総合的に判断し、今回はGrafana Cloudを採用してみることにしました。

セットアップ

Grafana Cloudのアカウント作成

grafana.com

Grafana CloudのトップページからCreate free accountしてアカウントを作成します。

https://grafana.com/orgs/{{ 作成したorg名 }}/access-policies なURLにアクセスして、 -write で終わる書き込み可能なAccess Policyに対して Add Token してアクセストークンを発行します。名前は適当で良いです。

作成したトークンの文字列はメモっておく必要があります。

Grafana Agentのインストールとセットアップ

まずstaticモードflowモードという2つのモードがあり、提供されるAgentのバイナリも両方違いますが、今回はstaticモードでデプロイします。理由はflowモードの説明が全く頭に入ってこなかったからです。

セットアップにはAnsibleを使っていますが、ほとんど生の設定を書くのとAnsibleのconfigを書くのに大きな違いは無いので適宜読み替えてください。また、Ansibleはインストール済み・対象サーバへの接続等はセットアップ済みであるとして進めます。

grafana.com

まず、Grafana関連のAnsible RoleはGrafanaが公式で公開しているのでこれをインストールします。

ansible-galaxy collection install grafana.grafana

これを使ってインストールするplaybookを書きます。人によってこの辺は色々やりかたがあると思うので、あくまでこれは一例と考えて下さい。

main.yaml:

---
- name: Install Grafana Agent
  hosts: all
  become: true
  tasks:
    - name: Install Grafana Agent
      ansible.builtin.include_role:
        name: grafana_agent
    # grafana_agent roleは変更後のconfigで正しく立ち上がっていることをチェックしない
    # REST APIを叩いてチェックする
    - name: Ensure that the grafana-agent is ready
      uri:
        url: http://localhost:12345/-/ready
        method: GET
        status_code: 200
      # 成功するまで1秒間隔で5回までリトライする
      register: check_grafana_agent_readiness
      until: check_grafana_agent_readiness is not failed
      retries: 5
      delay: 1

inventory fileなりhost_varsなりgroup_varsなりにconfigの変数を書きます。prometheusやLokiのUser、URLはStackの設定から見ることができます。 `https://grafana.com/orgs/{{ 作成したorg名 }}` のページからそれぞれのサービスの詳細に飛ぶことで設定を見ることができます。

group_vars/all.yaml:

---
# この値は実際にはansible-vaultで暗号化しておいた方が良い
grafana_cloud_api_key: 'メモしておいたアクセストークン'
# Prometheusのremote writeのBASIC認証に使うユーザ名(人によって違う)
grafana_cloud_prometheus_user: '999999'
# Prometheusのremote write URL(人によって違う)
grafana_cloud_prometheus_url: 'https://prometheus-prod-XX-prod-YYYY.grafana.net/api/prom/push'
# Lokiへのpush時のBASIC認証に使うユーザ名(人によって違う)
grafana_cloud_loki_user: '999999'
# Lokiのpush先URL(人によって違う)
grafana_cloud_loki_url: 'https://logs-prod-ZZZ.grafana.net/loki/api/v1/push'

# grafana-agentユーザがLinuxサーバ上に作られるが、
# 所属するグループはここで宣言しなければ全てパージされる
grafana_agent_user_groups:
  # For journal logs
  - adm
  - systemd-journal
  # For container logs
  - docker

grafana_agent_metrics_config:
  configs:
    - name: integrations
      remote_write:
        - basic_auth:
            password: '{{ grafana_cloud_api_key }}'
            username: '{{ grafana_cloud_prometheus_user }}'
          url: '{{ grafana_cloud_prometheus_url }}'

  global:
    scrape_interval: 60s
  wal_directory: /tmp/grafana-agent-wal

grafana_agent_logs_config:
  configs:
    - name: default
      clients:
        - basic_auth:
            password: '{{ grafana_cloud_api_key }}'
            username: '{{ grafana_cloud_loki_user }}'
          url: '{{ grafana_cloud_loki_url }}'
      positions:
        filename: /tmp/positions.yaml
      target_config:
        sync_period: 10s
      scrape_configs:
        # 単一のファイルを指定して送信する例
        - job_name: authlog
          static_configs:
          - targets:
            - localhost
            labels:
              instance: '${HOSTNAME:-default}'
              __path__: /var/log/auth.log
              job: auth.log
        # systemd-journalのログを送信する例
        - job_name: integrations/node_exporter_journal_scrape
          journal:
            # 過去のログをどれくらい遡って送信するか
            max_age: 1h
            labels:
              job: systemd-journal
              instance: '${HOSTNAME:-default}'
          relabel_configs:
            - source_labels: ['__journal__systemd_unit']
              target_label: 'service'
            - source_labels: ['__journal_priority_keyword']
              target_label: 'level'
            # 余計なログ転送を抑えるためにsessionやuserなどのjournalログを送らず捨てる
            - source_labels: [__journal__systemd_unit]
              regex: ^session-\d+.scope$
              action: drop
            - source_labels: [__journal__systemd_unit]
              regex: ^user@\d+.service$
              action: drop
        # Dockerコンテナのログを自動的に見つけて送信する例。大量のコンテナがある場合には注意。
        # また、長く運用しているコンテナがあり過去のログが残っている場合、先に削除しておくこと。
        - job_name: integrations/docker
          docker_sd_configs:
            - host: unix:///var/run/docker.sock
              refresh_interval: 10s
          relabel_configs:
          - action: replace
            replacement: integrations/docker
            target_label: job
          - action: replace
            replacement: ${HOSTNAME:-default}
            target_label: instance
          - source_labels:
              - __meta_docker_container_name
            regex: '/(.*)'
            target_label: service
          - source_labels: 
              - __meta_docker_container_log_stream
            target_label: stream

grafana_agent_integrations_config:
  # node-exporterを起動してそのホストのメトリクスを取る
  node_exporter:
    enabled: true
    instance: ${HOSTNAME:-default}
    # disable unused collectors
    disable_collectors:
      - ipvs
      - btrfs
      - infiniband
      - xfs
      - zfs
      - nfs
      # エラーログが出るので無効にした
      - thermal_zone
    # exclude dynamic interfaces
    netclass_ignored_devices: "^(veth.*|cali.*|[a-f0-9]{15})$"
    netdev_device_exclude: "^(veth.*|cali.*|[a-f0-9]{15})$"
    # disable tmpfs
    filesystem_fs_types_exclude: "^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|tmpfs|fusectl|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$"
    # drop extensive scrape statistics
    relabel_configs:
    - replacement: ${HOSTNAME:-default}
      target_label: instance
    metric_relabel_configs:
    - action: drop
      regex: node_scrape_collector_.+
      source_labels:
      - __name__
    # デフォルトでは無駄に多いのでメトリクスを削る。もっと削っても良いかも。
    - action: keep
      regex: node_time_seconds|node_arp_entries|node_boot_time_seconds|node_cpu_seconds_total|node_disk_io_time_seconds_total|node_disk_io_time_weighted_seconds_total|node_disk_read_bytes_total|node_disk_read_time_seconds_total|node_disk_reads_completed_total|node_disk_write_time_seconds_total|node_disk_writes_completed_total|node_disk_written_bytes_total|node_filefd_allocated|node_filefd_maximum|node_filesystem_avail_bytes|node_filesystem_device_error|node_filesystem_files_free|node_filesystem_readonly|node_filesystem_size_bytes|node_load1|node_load15|node_load5|node_md_disks|node_md_disks_required|node_memory_Active_anon_bytes|node_memory_Active_bytes|node_memory_Active_file_bytes|node_memory_AnonHugePages_bytes|node_memory_AnonPages_bytes|node_memory_Bounce_bytes|node_memory_Buffers_bytes|node_memory_Cached_bytes|node_memory_CommitLimit_bytes|node_memory_Committed_AS_bytes|node_memory_Dirty_bytes|node_memory_Inactive_anon_bytes|node_memory_Inactive_bytes|node_memory_Inactive_file_bytes|node_memory_Mapped_bytes|node_memory_MemAvailable_bytes|node_memory_MemFree_bytes|node_memory_MemTotal_bytes|node_memory_SReclaimable_bytes|node_memory_SUnreclaim_bytes|node_memory_ShmemHugePages_bytes|node_memory_ShmemPmdMapped_bytes|node_memory_Shmem_bytes|node_memory_Slab_bytes|node_memory_SwapTotal_bytes|node_memory_VmallocChunk_bytes|node_memory_VmallocTotal_bytes|node_memory_VmallocUsed_bytes|node_memory_WritebackTmp_bytes|node_memory_Writeback_bytes|node_netstat_Icmp6_InErrors|node_netstat_Icmp6_InMsgs|node_netstat_Icmp6_OutMsgs|node_netstat_Icmp_InErrors|node_netstat_Icmp_InMsgs|node_netstat_Icmp_OutMsgs|node_netstat_IpExt_InOctets|node_netstat_IpExt_OutOctets|node_netstat_TcpExt_ListenDrops|node_netstat_TcpExt_ListenOverflows|node_netstat_TcpExt_TCPSynRetrans|node_netstat_Tcp_InErrs|node_netstat_Tcp_InSegs|node_netstat_Tcp_OutRsts|node_netstat_Tcp_OutSegs|node_netstat_Tcp_RetransSegs|node_netstat_Udp6_InDatagrams|node_netstat_Udp6_InErrors|node_netstat_Udp6_NoPorts|node_netstat_Udp6_OutDatagrams|node_netstat_Udp6_RcvbufErrors|node_netstat_Udp6_SndbufErrors|node_netstat_UdpLite_InErrors|node_netstat_Udp_InDatagrams|node_netstat_Udp_InErrors|node_netstat_Udp_NoPorts|node_netstat_Udp_OutDatagrams|node_netstat_Udp_RcvbufErrors|node_netstat_Udp_SndbufErrors|node_network_carrier|node_network_info|node_network_mtu_bytes|node_network_receive_bytes_total|node_network_receive_compressed_total|node_network_receive_drop_total|node_network_receive_errs_total|node_network_receive_fifo_total|node_network_receive_multicast_total|node_network_receive_packets_total|node_network_speed_bytes|node_network_transmit_bytes_total|node_network_transmit_compressed_total|node_network_transmit_drop_total|node_network_transmit_errs_total|node_network_transmit_fifo_total|node_network_transmit_multicast_total|node_network_transmit_packets_total|node_network_transmit_queue_length|node_network_up|node_nf_conntrack_entries|node_nf_conntrack_entries_limit|node_os_info|node_sockstat_FRAG6_inuse|node_sockstat_FRAG_inuse|node_sockstat_RAW6_inuse|node_sockstat_RAW_inuse|node_sockstat_TCP6_inuse|node_sockstat_TCP_alloc|node_sockstat_TCP_inuse|node_sockstat_TCP_mem|node_sockstat_TCP_mem_bytes|node_sockstat_TCP_orphan|node_sockstat_TCP_tw|node_sockstat_UDP6_inuse|node_sockstat_UDPLITE6_inuse|node_sockstat_UDPLITE_inuse|node_sockstat_UDP_inuse|node_sockstat_UDP_mem|node_sockstat_UDP_mem_bytes|node_sockstat_sockets_used|node_systemd_unit_state|node_textfile_scrape_error|node_uname_info|node_vmstat_oom_kill|node_vmstat_pgfault|node_vmstat_pgmajfault|node_vmstat_pgpgin|node_vmstat_pgpgout|node_vmstat_pswpin|node_vmstat_pswpout|process_max_fds|process_open_fds
      source_labels:
      - __name__
  prometheus_remote_write:
    - basic_auth:
        password: '{{ grafana_cloud_api_key }}'
        username: '{{ grafana_cloud_prometheus_user }}'
      url: '{{ grafana_cloud_prometheus_url }}'

grafana_agent_env_vars:
  HOSTNAME: '%H'

この設定をデプロイします。

# -K を付けるとSSH先で使うパスワードを実行時に聞かれる
ansible-playbook -K main.yaml

コケずに走りきったらセットアップ終了です。

cAdvisorを建てる

grafana-agentにはcAdvisorを内部で起動する機能がありますが、この方法で建てたgrafana-agentでは、cAdvisorもgrafana-agentユーザで起動してしまいます。すると /var/lib/docker 以下などへのアクセスでエラーが出まくるので嬉しくありません。cAdvisorだけroot権限を付与して別に起動します。詳細は↓を参照。

github.com

これもAnsibleで建てても良いでしょう。

cAdvisorのメトリクスを参照する

先ほど適用した設定にcAdvisorのメトリクスを取りに行く設定を入れます。

grafana_agent_metrics_config:
  configs:
    # ... 省略 ...
    - name: cadvisor
      scrape_configs:
        - job_name: cadvisor
          static_configs:
            # リモートホスト上でcAdvisorがリッスンしているアドレスを指定する
            - targets: ['localhost:8080']
              labels:
                instance: '${HOSTNAME:-default}'
          relabel_configs:
            - source_labels: ['name']
              target_label: 'service'
          # デフォルトでは多すぎるので適当にメトリックを削る
          metric_relabel_configs:
          - action: keep
            regex: container_cpu_usage_seconds_total|container_fs_inodes_free|container_fs_inodes_total|container_fs_limit_bytes|container_fs_usage_bytes|container_last_seen|container_memory_usage_bytes|container_network_receive_bytes_total|container_network_tcp_usage_total|container_network_transmit_bytes_total|container_spec_memory_reservation_limit_bytes|machine_memory_bytes|machine_scrape_error
            source_labels:
            - __name__
      remote_write:
        - basic_auth:
            password: '{{ grafana_cloud_api_key }}'
            username: '{{ grafana_cloud_prometheus_user }}'
          url: '{{ grafana_cloud_prometheus_url }}'

これを再び適用して走りきればOK。

Grafana Cloudで表示する

ダッシュボードを作る

最初はNode Exporterで取得したメトリクスをまずは表示してみます。

GrafanaでPrometheusをdatasourceとしたときのダッシュボードの作り方は他に無限に情報があるのでここでは詳しく解説しません。今回は既に公開されているものをimportして利用します。

https://grafana.com/grafana/dashboards/1860-node-exporter-full/

https://{{ 作成したorg名 }}.grafana.net/ にアクセスし、右上の + から import dashboardを開くと上記のリンクのダッシュボードをimportできるUIが出てくるので適当にimportします。

Node Exporterのダッシュボード

こんな感じで見えるダッシュボードが生成されればOK。

色々設定すると、自前でもこんな感じのダッシュボードが作れます。

録画システムのダッシュボード

ログを検索する

ログをダッシュボードに表示する方法なども他の記事に譲るとして、アドホックにログを検索するやり方だけ書いておきます。ハンバーガーメニューから Explore をクリックするとログやメトリクスを指定して表示する機能が出てきます。データソースを grafanacloud-{{ 作成したorg名 }}-logs にして適用な検索内容を入れると表示されることが分かります。

無料枠を超えないために

Grafana Cloudにはハードリミットを用意してそれを超えたら一切受け付けないようにして課金されないようにするような仕組みはありません。そのため、予期しないログのバーストなどで課金されないようにするにはユーザが頑張るしかないようです。

Promtailの設定にリミットを入れる

一行当たりのサイズや送信レートにリミットをかけ、そもそも大量に送られないようにします。

grafana.com

より詳細に、例えば特定のログストリームだけ異なる設定を適用する場合はpipelineを使います。

grafana.com

scraping intervalを広めにとる

細かくメトリクスが欲しくなってしまいますが、DPMという概念があるため1分より早い頻度でのデータの送信は無料枠のアカウントには含まれていません。Grafana Agentなどのデフォルトは60sになっているので、これ以上縮めないようにしましょう。

grafana_agent_metrics_config:
  configs:
  # ... 省略 ...
  global:
    scrape_interval: 60s

不要なメトリクスやlabelをdropする

見ることがないメトリクスは取っていても意味が無いため、そもそも送らないようにします。また、不要なlabelについてもdropすることでbillable seriesを減らせます。

prometheus.io

droplabeldrop を使って不要なメトリック・ラベルはどんどん削っておくとサーバ台数が増えても無料枠に収まるはずです。

アラートを仕掛ける

まずは無料枠の上限に近づいてきたらアラートを出すようにします。

公式のドキュメントにメトリクスの課金アラートに関する設定例が載っていますが、これは課金する前提の場合であり、実際に課金されるような使用量に至るまで検知出来ません。

grafana.com

監視するだけならそれほど必要ではないのですが、各リソースの利用量の計算の仕方と、無料枠で許されている範囲を把握しておいた方がよいです。今回はログとメトリクスに関してだけ考えますが、他のリソースでもドキュメントに計算方法は書いていますのでそれを参考にして下さい。

https://grafana.com/docs/grafana-cloud/account-management/billing-and-usage/active-series-and-dpm/

https://grafana.com/docs/grafana-cloud/account-management/billing-and-usage/logs-billing/

Metricであれば grafanacloud_org_metrics_billable_series という、課金対象となるmetricsのseriesを表すメトリックが現在いくつかなのかを監視します。ただし、ユーザには最初の10k seriesまで無料という無料枠( grafanacloud_org_metrics_included_series というメトリックで取得できる )が用意されているので、これにどれくらい近接したかを監視します。

Logでもほぼ同様で、 grafanacloud_org_logs_usage というメトリックを監視します。このメトリックの単位はGBです。 grafanacloud_org_logs_included_usage に無料枠の上限(50GB。無料トライアルの間は100GB)が入っているので、その差分がいくつになったらアラートを鳴らすかを記述します。

また、 grafanacloud_org_logs_overage というメトリックに現在の超過料金が入っているのでこれが0以上になったらという監視を入れたり、ログの流量 grafanacloud_logs_instance_bytes_received_per_second が一定以上の状態が30分以上続いたら、などの条件でアラートを入れ最終的に自分の環境では以下の様になりました(伝わらない)。

サポートからはGrafana Machine Learningなどを活用して異常検知するなども提案されましたが、Free accountにはMachine Learningは含まれていないので今のところ検討していません。

2023-09-23 追記 grafanacloud-usage datasourceが1日に1回くらいの割合でNoDataになってアラートを飛ばしてくるので、NoDataになったときの挙動をOKにしてしまうのがおすすめです。どうせNoDataになってもユーザ側で対応できることは何もないので、それで十分だと思います。

注意点

Grafana Cloudのログの無料枠とは保存容量ではなく、送信量に対してかかります。そのため、何らかの理由でGrafana Cloudのサーバ側からリジェクトされた場合でもその容量は課金対象です。例えばログのタイムスタンプがある程度以上古かったり未来を指している場合にエラーになることがあり、実際には拒否されているにもかかわらずBillable Usageだけは増加することがあります。

これについてはもう少し詳しく別記事で解説できればと思っています。

まとめ

  • Grafana と Prometheus、Lokiのマネージドサービスという感じで使い慣れている人には色々使い勝手が良い
    • ただしあまり親切とは言い難いので、料金を抑えるために使う側で色々な工夫が必要
  • ちょっとした自宅サーバのログやメトリックを保存しておく分には十分な無料枠
  • 無料枠に感謝し、色々触ってみて業務に活かしていきたい

*1:あまりにも機能比較のチェックリストが長い https://www.elastic.co/jp/subscriptions/cloud

Go 1.20で入ったexec.CommandのCancelとWaitDelayで外部コマンドを正しく終了させる

背景

Goでは外部コマンドの実行時に os/exec パッケージの CommandCommandContext を利用します。特に CommandContext を利用することで、 Goのcontextの流儀に従ってコマンドのタイムアウトや中断が可能であり大変便利です。

一方で、LinuxmacOSなどにおいてContextによるタイムアウト・中断時には外部コマンドに即座に SIGKILL が送られてキルされてしまうため、一部のケースでは孫プロセスが孤児プロセスとして残ってしまったり、後始末を正しく出来ないままコマンドが終了してしまうという問題が知られていました。

より安全にコマンドを終了させる方法として、まずは SIGINTSIGTERM を送り、一定時間内に終了しなければ SIGKILL で終了させるなどの方法が知られています。しかし、 CommandContext を使わずにContextによる中断のハンドリングを自分で行う必要があるなど初学者には難しい状況になっていました。

Go 1.20で導入されたCancelとWaitDelay

ひっそりとリリースノートに記載されている内容なので、あまり気にしておられない方も多いかと思います。 exec.Cmd 構造体に新たに二つのフィールドが追加されました。

Cancel の型は func() errorWaitDelay の型は time.Duration です。元々は下記のプロポーザルから実装されたもののようです。当初は KILL されるまでの時間と、ContextがDoneになったときに送信するシグナルを指定できるようにするはずだったようですが、途中で任意の実装を挟み込めるようになったようです。

Cancel

まず Cancel は、 CommandContext で渡したContextがDoneになったときに呼び出される関数で、デフォルトでは cmd.Process.Kill() を呼び出す関数が設定されるようです。

代わりに SIGTERM を送ったりする関数を与えることで、Contextをキャンセルしたりタイムアウトさせた際に送信するシグナルを指定できる他、任意の処理が出来るので標準入力を閉じたりネットワーク越しにリクエストを送ったりすることもできるようです。要するにこれまで手動でコンテキストをハンドリングして行っていたようなものをここに書いておくだけでよくなるということです。

WaitDelay

ContextがDoneになってから、 cmd.Process.Kill() されるまでの猶予時間です。この間に Cancel の処理を終わらせないと SIGKILL で終了させられてしまいます。

デフォルトは0になっているため、 Cancel を使う際は WaitDelay を手動設定しておかないとうまく処理できません。

使い方

SIGINTを送って死ななければSIGKILLで終了させる

まずは以前までの書き方をおさらいします。まず、 exec.CommandContext を使うとContextがDoneになったときにSIGKILLが送られてしまうのでこれは使えません。 exec.Command を使い、Contextのハンドリングは自分で行わなければいけません。

package main

import (
    "context"
    "fmt"
    "os"
    "os/exec"
    "os/signal"
    "time"
)

func run() error {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()
    cmd := exec.Command("bash", "-c", "trap 'echo \"signal received\"; sleep 10; echo \"done\"' SIGINT; sleep 120")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    if err := cmd.Start(); err != nil {
        return err
    }
    errCh := make(chan error, 1)
    go func() {
        defer close(errCh)
        errCh <- cmd.Wait()
    }()
    for {
        select {
        case exitErr := <-errCh:
            return exitErr
        case <-ctx.Done():
            fmt.Println("Send SIGINT")
            cmd.Process.Signal(os.Interrupt)
            select {
            case exitErr := <-errCh:
                return exitErr
            case <-time.After(5 * time.Second):
                fmt.Println("Send SIGKILL")
                cmd.Process.Kill()
                return <-errCh
            }
        }
    }
}

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

(適当に書いたので穴があるかも知れない)

ここではシグナルを受け取るとContextをキャンセルし、子プロセスに SIGINT を送って5秒待機、まだ終了しなければ SIGKILL を送って待ちます。

CancelWaitDelay を使うと以下の様に書けます。

package main

import (
    "context"
    "fmt"
    "os"
    "os/exec"
    "os/signal"
    "time"
)

func run() error {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()
    cmd := exec.CommandContext(ctx, "bash", "-c", "trap 'echo \"signal received\"; sleep 1; echo \"done\"' SIGINT; sleep 120")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    cmd.Cancel = func() error {
        return cmd.Process.Signal(os.Interrupt)
    }
    cmd.WaitDelay = 5 * time.Second
    return cmd.Run()
}

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

実行してみると下記の様にシグナルを受け取っていることがわかります。

❯ go run main.go                                  
^Csignal received
done
Error: exit status 130
exit status 1

シグナルを受け取ってからのスリープの時間を10秒などに延ばすと、 SIGKILL が送られていることがわかります。

❯ go run main.go
^Csignal received
Error: signal: killed
exit status 1

注意点

Windowsでは cmd.Process.Signal が実装されておらず、今回の方法で正しくプロセスが終了できるとは限らないことに注意が必要です。実際Windowsでのプロセスの正しい終了の仕方を全然知らない……知っている方がおられれば是非教えて頂きたいですね。