Hateburo: kazeburo hatenablog

SRE / 運用系小姑 / Goを書くPerl Monger

ISUCON10予選問題で EC2 c6g.2xlarge vs c5.2xlarge をやってみる

少し前にISUCON10予選問題のスコアアップに取り組んでいたので、その成果を使い、EC2 Graviton2 インスタンスIntel CPUのインスタンスでスコアを比較してみます。

ISUCON10予選問題チャレンジをしていた時のスレはこちら

ここではISUCON10予選時のように複数台用意するのではなく、1台のサーバでGoのアプリケーションとMySQLを動作させ、同じサーバ上からベンチマークを実行しています。

準備

初期データを作る際のdockerコンテナでarm64対応していないものがあるため、Intel CPUのインスタンスで作成してscpする方法を取りました。

まず、 c5.2xlargeインスタンスを起動して準備していきます

# 依存のインストール
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg-agent software-properties-common python3-pip git

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

# Dockerをいれる
sudo add-apt-repository \
   "deb [arch=$(dpkg --print-architecture)] https://download.docker.com/linux/ubuntu \
   $(lsb_release -cs) \
   stable"

sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io

sudo gpasswd -a $USER docker

# 一度logout & login

# docker-compose を pip でインストール
sudo pip3 install -U docker-compose

# Goのインストール
curl -fsSL https://golang.org/dl/go1.15.6.linux-$(dpkg --print-architecture).tar.gz |  sudo tar -C /usr/local -xzf -
export PATH=$PATH:/usr/local/go/bin
echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee /etc/profile.d/go.sh

# 初期データ作成で使うコマンドのインストール
go get github.com/orisano/wayt

export PATH=$PATH:$HOME/go/bin
echo 'export PATH=PATH=$PATH:$HOME/go/bin' | tee -a ~/.bashrc

# ISUCON10 予選問題をgit cloneして初期データ作成
git clone https://github.com/isucon/isucon10-qualify.git
cd isucon10-qualify/initial-data
pip3 install -r requirements.txt
make

初期データ作成時に一度止まってしまいましたが、Ctrl-Cしてもう一度実行したらうまく動きました。

MySQLをインストールして、ユーザ作成をします。簡単なパスワードになるので気をつけましょう。

sudo apt install mysql-server

sudo mysql
> CREATE USER 'isucon'@'%' IDENTIFIED BY 'isucon';
> GRANT ALL ON *.* TO `isucon`@`%`;
> CREATE DATABASE isuumo;

ここまでできたら c6g.2xlarge 側の準備をします。

c6g.2xlargeインスタンスを起動し、

sudo apt-get update
sudo apt-get install -y mysql-server git make

export PATH=$PATH:/usr/local/go/bin
echo 'export PATH=$PATH:/usr/local/go/bin' | sudo tee /etc/profile.d/go.sh

sudo mysql
> CREATE USER 'isucon'@'%' IDENTIFIED BY 'isucon';
> GRANT ALL ON *.* TO `isucon`@`%`;
> CREATE DATABASE isuumo;

このあと、Intel CPUのインスタンスから、isucon10-qualify ディレクトリを scpで Graviton2 のインスタンスにコピーすれば準備完了です。

初期状態でのスコア比較

まず、Intel CPUである c5.2xlarge での初期スコア

isuumoアプリを make して起動

cd isucon10-qualify/webapp/go
make
./isuumo

別ターミナルを開き、ベンチマークを実行します。

cd isucon10-qualify/bench
make
./bench

初期スコア

初期状態ではタイムアウトも多くますが、最終的に以下のスコアになりました。

c5.2xlarge での初期スコア

2020/12/23 13:26:06 bench.go:102: 最終的な負荷レベル: 8
{"pass":true,"score":1335,"messages":[{"text":"POST /api/estate/nazotte: リクエストに失敗しました (タイムアウトしました)","count":34}],"reason":"OK","language":"go"}

c6g.2xlarge でも同じようにターミナルを2つ開いてアプリケーションとベンチマークを実行します。

c6g.2xlarge での初期スコア

2020/12/23 14:18:14 bench.go:102: 最終的な負荷レベル: 9
{"pass":true,"score":1552,"messages":[{"text":"POST /api/estate/nazotte: リクエストに失敗しました (タイムアウトしました)","count":48}],"reason":"OK","language":"go"}

初期状態では、c6g.2xlarge の方が 16% ほどスコアがよいという結果になりました。

ベンチマーク時の負荷の様子を top コマンドでみると次のようになっておりました。

c5.2xlarge

top - 14:15:39 up 10 min,  3 users,  load average: 7.18, 2.64, 0.96
Tasks: 146 total,   2 running, 144 sleeping,   0 stopped,   0 zombie
%Cpu(s): 85.6 us,  3.2 sy,  0.0 ni, 10.3 id,  0.5 wa,  0.0 hi,  0.4 si,  0.0 st
MiB Mem :  15549.2 total,  12448.3 free,    929.9 used,   2171.0 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.  14411.4 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
    777 mysql     20   0 2989500 506064  37208 S 638.9   3.2   9:38.23 mysqld
   1331 ubuntu    20   0 1526204  34796   7164 S  44.9   0.2   0:36.05 isuumo
   1486 ubuntu    20   0 1521292 139536   7692 S  30.0   0.9   0:07.15 bench

c6g.2xlarge

top - 14:18:03 up 16 min,  3 users,  load average: 5.98, 2.22, 1.13
Tasks: 189 total,   2 running, 187 sleeping,   0 stopped,   0 zombie
%Cpu(s): 92.9 us,  4.0 sy,  0.0 ni,  1.7 id,  0.3 wa,  0.0 hi,  1.1 si,  0.0 st
MiB Mem :  15709.3 total,  11525.9 free,    880.3 used,   3303.1 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.  14661.9 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
   3516 mysql     20   0 3049328 490156  34860 S 707.0   3.0  11:29.75 /usr/sbin/mysqld
   4896 ubuntu    20   0 1598624  40468   6208 S  39.5   0.3   0:46.90 ./isuumo
   5193 ubuntu    20   0 1661656 153140   6860 S  33.9   1.0   0:13.45 ./bench

明らかにMySQLボトルネックですが、c6g.2xlarge の方がidleが少なくCPUをよく使えているようにみえます。

感想戦ブランチでのスコア比較

次に、私のブランチへ切り替えてスコア比較をしてみます。

ブランチはこちら

github.com

効果があまりなかったものもありますが、主な変更点は次のようになります。

  • nazotte検索に空間インデックスを利用
  • 検索用テーブルと、データ用テーブルを分離してMySQLの負荷を削減
  • 検索用インデックスの追加。
  • 検索時にrangeクエリにならないようrangeIDカラムを作成しtriggerにてデータ投入
  • 検索時にSQL_CALC_FOUND_ROWSを利用。結果をキャッシュしておき、同条件での検索時はSQL_CALC_FOUND_ROWSをつけずにキャッシュを利用する
  • chairとestateは全てインメモリキャッシュ。検索時は SELECT id をする
  • 検索結果やchairとestateをキャッシュする際は、JSON化したものをキャッシュする
  • bot判定を最適化
  • fiber (fasthttp) へのフレームワーク変更
  • MySQLに秘伝のタレを入れる

いれたMySQLに秘伝のタレ

disable-log-bin
innodb_doublewrite = 0
innodb_flush_log_at_trx_commit = 0
innodb_flush_method = O_DIRECT
innodb_adaptive_hash_index = 0

感想戦ブランチでのスコア

c5.2xlarge での感想戦ブランチスコア

$ ./bench
2020/12/23 14:34:27 bench.go:78: === initialize ===
2020/12/23 14:34:32 bench.go:90: === verify === 
2020/12/23 14:34:32 bench.go:100: === validation ===
2020/12/23 14:34:34 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:34:36 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:34:37 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:34:37 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:34:37 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:34:38 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:34:38 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:34:38 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:34:38 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:34:39 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:34:39 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:35:32 bench.go:102: 最終的な負荷レベル: 11
{"pass":true,"score":41462,"messages":[],"reason":"OK","language":"go"}

初期状態では負荷レベルが最大の11までには届きませんでしたが、ここでは7秒ほどで最大に達し、最終スコアは4万を超えてきました。これはMacbook Pro上でやっていたときより若干良い結果

c6g.2xlarge での感想戦ブランチスコア

ubuntu@ip-10-0-11-141:~/isucon10-qualify/bench$ ./bench
2020/12/23 14:36:20 bench.go:78: === initialize ===
2020/12/23 14:36:30 bench.go:90: === verify ===
2020/12/23 14:36:31 bench.go:100: === validation ===
2020/12/23 14:36:33 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:36:34 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:36:35 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:36:36 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:36:36 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:36:36 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:36:37 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:36:37 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:36:37 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:36:37 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:36:38 load.go:181: 負荷レベルが上昇しました。
2020/12/23 14:37:31 bench.go:102: 最終的な負荷レベル: 11
{"pass":true,"score":39460,"messages":[],"reason":"OK","language":"go"}

Intel CPUに比べると5%ほど低いスコアです。何回か実行してみましたが、4万に届くことはありませんでした。

ベンチマーク実行中の負荷をtopコマンドでみると次のようになっていました。

c5.2xlarge

top - 14:45:24 up 39 min,  3 users,  load average: 3.68, 2.66, 2.15
Tasks: 147 total,   2 running, 145 sleeping,   0 stopped,   0 zombie
%Cpu(s): 57.6 us,  4.3 sy,  0.0 ni, 36.8 id,  0.0 wa,  0.0 hi,  1.3 si,  0.0 st
MiB Mem :  15549.2 total,  10447.3 free,   2096.7 used,   3005.3 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.  13346.3 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
   3879 ubuntu    20   0 1663700 163836   7724 S 277.3   1.0   1:00.39 bench
   1691 mysql     20   0 3335408 651500  37268 S 189.7   4.1  22:25.35 mysqld
   3650 ubuntu    20   0 2572288   1.0g  15200 R  45.0   6.7   2:13.37 isuumo 

c6g.2xlarge

top - 14:40:43 up 39 min,  3 users,  load average: 1.44, 1.86, 1.69
Tasks: 185 total,   1 running, 184 sleeping,   0 stopped,   0 zombie
%Cpu(s): 58.2 us,  3.6 sy,  0.0 ni, 37.0 id,  0.0 wa,  0.0 hi,  1.2 si,  0.0 st
MiB Mem :  15709.3 total,   9707.4 free,   2011.2 used,   3990.7 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.  13669.2 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
   7423 ubuntu    20   0 1810080 205848   6788 S 268.8   1.3   0:59.13 ./bench
   5417 mysql     20   0 3724652 652440  35008 S 203.0   4.1  18:10.73 /usr/sbin/mysqld
   7352 ubuntu    20   0 2499408 986528  14492 S  38.9   6.1   0:50.62 ./isuumo

感想戦ブランチでは全体のCPU使用率が下がっています。大きな差ではありませんが、Intel CPUの方がMySQLのCPU使用率が小さくベンチマークとisuumoアプリのCPU使用率が大きいようにみえます。

感想など

tasksetコマンドを使い、benchコマンドが使うCPUを1個に絞った結果も合わせたグラフが次ようになります。

f:id:kazeburo:20201224103305p:plain

Graviton2はIntel CPUのインスタンスに比べてシングルスレッドのパフォーマンスが低めというベンチマーク結果もありますが、

www.anandtech.com

この結果からも初期状態ではMySQLでのマルチスレッド性能が、感想戦ブランチではベンチマーカーのシングルスレッド性能がスコアに影響しているのかなと考えております。

感想戦ブランチではGraviton2のインスタンスの方がスコアが低いという結果になってしまいましたが、インスタンスのコストはGraviton2の方が20%ほど低く、スコアをコストで割った数値ではGraviton2の方が高くなるでしょう。

armなインスタンスの利用ではミドルウェアのパッケージやdockerのイメージが用意されていないことがあるなど、いくつか困難にぶつかることはありますが、パフォーマンスがよくコストメリットも大きいのでやはり使っていくといいんじゃないかと思いますね~

Re: 結局、Go言語をやめる理由はなかった件

すみません、ISUCONのアレに火がついてしまったので..。 Advent Calendarとはとくに関係がありません。

qiita.com

qiita.com

こちらの記事をみて、気になってしまったので interpolateParams の追加とMySQLチューニングをしてベンチマークを回してみました。

ベンチマークの環境が公開されているのは素晴らしいですね。

github.com

8vCPU/16GB Memのc5a.2xlargeなEC2のインスタンスを起動して、以下の手順にしたがってdockerとdocker-composeをインストールしました。OSはUbuntu 20.04を使いました。

docs.docker.com

docs.docker.com

上記のbenchmarkのrepositoryをgit cloneし、compose build && compose upします。

$ git clone https://github.com/okdyy75/bench-docker.git
$ sudo docker-compose build
$ sudo docker-compose up -d

まず何も変更しない状態でのスコア結果は、

Python

$ sudo docker-compose run --rm python sh -c 'cd python; pip install -r ./requirements.txt && time python bench.py'
平均秒数:9.670429
real    1m 36.73s
user    0m 6.25s
sys     0m 2.79s

Go言語

$ sudo docker-compose run --rm golang sh -c 'cd go; go build . && time ./go'
平均秒数:11.70815524 (0x0,0x0)
real    1m 57.08s
user    0m 3.84s
sys     0m 10.83s

たしかに、Go言語の結果の方が少し悪くなっています。

ここで元記事にてmethaneさんから指摘のあった interpolateParams の設定をいれます。

diff --git a/www/go/bench.go b/www/go/bench.go
index 5c56714..a4b86d0 100644
--- a/www/go/bench.go
+++ b/www/go/bench.go
@@ -37,7 +37,7 @@ func getDB() *sql.DB {
        }
 
        dsn := fmt.Sprintf(
-               "%s%s@tcp(%s:%s)/%s?charset=utf8&parseTime=true",
+               "%s%s@tcp(%s:%s)/%s?charset=utf8&parseTime=true&interpolateParams=true",
                user, password, host, port, name)
 
        db, err := sql.Open("mysql", dsn)

そうすると、結果は

平均秒数:10.540287
real    1m 45.40s
user    0m 2.99s
sys     0m 6.26s

となり、pythonに追いつかないまでも、改善はしています。

ところで、ベンチマークを何回か動かしながら気になったのは、スコアが振れが大きく、iowaitがそれなりでているとところです。(Maki-Daisukeさんはioの影響を抑えるためNVMe SSDのついたインスタンスを使われておりました)

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 0  1      0 11964048 140004 3405756    0    0     0  7872 8115 13468  1  2 95  2  0
 0  0      0 11964048 140004 3405932    0    0     0  7656 6939 11857  1  2 93  4  0
 0  1      0 11956236 140004 3414672    0    0     0 16284 7068 12074  1  1 94  4  0
 0  1      0 11956236 140004 3414672    0    0     0  7768 8375 14349  1  2 94  3  0
 1  0      0 11956236 140004 3414672    0    0     0  7464 7463 12275  2  1 96  2  0
 0  1      0 11955480 140004 3415720    0    0     0  8780 7971 12701  2  1 97  0  0
 0  1      0 11955480 140004 3415720    0    0     0  7712 8561 13845  1  2 97  0  0
 0  1      0 11955480 140004 3415720    0    0     0  7780 7966 13032  1  1 96  1  0
 0  1      0 11954472 140004 3416496    0    0     0  8788 7051 12944  2  1 90  7  0
 0  1      0 11964536 140004 3405576    0    0     0  9592 9176 16006  2  1 92  4  0
 2  0      0 11964284 140004 3405832    0    0     0  8220 7737 13731  1  1 92  5  0
 1  0      0 11964252 140004 3405928    0    0     0  8056 8078 12893  1  1 97  0  0
 0  1      0 11955416 140004 3414756    0    0     0 16132 6565 11829  1  1 91  7  0
 2  0      0 11955416 140004 3414756    0    0     0  7056 7333 12482  1  1 94  3  0

また、CPUも全然使えてません。

これはMySQL側がボトルネックになっている可能性が高いとみて、対ISUCON用秘伝のタレを投入して、再度実行してみました。

my.cnf を作成。4つのパラメータを追加

$ cat docker/mysql/my.cnf
[mysqld]
innodb_doublewrite = 0
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT_NO_FSYNC
innodb_adaptive_hash_index = 0

compose.ymlに追記して、mysqlを再起動

$ git diff
diff --git a/docker-compose.yml b/docker-compose.yml
index 94b3dc7..e39de9b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,6 +15,7 @@ services:
     volumes:
       - "./.data/mysql:/var/lib/mysql"
       - "./log/mysql:/var/log/mysql"
+      - "./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf"
     ports:
       - "3306:3306"
   # php-fpm ################################################

$ sudo docker-compose restart mysql
Restarting bench-docker_mysql_1 ... done

結果は

Python

平均秒数:1.518821
real    0m 15.21s
user    0m 6.12s
sys     0m 1.69s

Go言語(interpolateParams=false)

平均秒数:1.770360
real    0m 17.70s
user    0m 3.04s
sys     0m 7.85s

Go言語(interpolateParams=true)

平均秒数:1.196632
real    0m 11.96s
user    0m 2.26s
sys     0m 4.95s

interpolateParamsが有効のGo言語が最速になりました。最初の10倍近い高速化ですね。

MySQLが高速化したことで順位が入れ替わるところはもう少し深堀してもいいかと思いますが、Pythonにしたってかなり速くなってますので、これくらいなら正直どの言語でやってもいいのではないかと思います。仲良くしましょう

Go言語を実行中のvmstatは以下のようになりました。

 vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0      0 12075152 140028 3363636    0    0    10   453  441   22  1  1 98  1  0
 0  0      0 12075152 140028 3363636    0    0     0     0  139  257  0  0 100  0  0
 0  1      0 12015176 140028 3387952    0    0     0 25772 1950 2773  5  1 93  1  0
 1  0      0 11968016 140028 3391408    0    0     0 12732 31447 60372  9  7 84  1  0
 1  0      0 11967732 140028 3391376    0    0     0  4660 42484 86361  7  8 85  0  0
 1  0      0 11967748 140028 3391360    0    0     0 14592 35933 73675  6  6 87  1  0
 1  0      0 11967256 140028 3391228    0    0     0  6964 38245 77879  7  7 86  0  0
 2  0      0 11966928 140028 3391416    0    0     0 13028 36928 75262  6  7 87  0  0
 2  0      0 11966816 140028 3391440    0    0     0  7352 37506 76638  7  7 86  0  0
 1  0      0 11968984 140028 3391440    0    0     0 14120 32657 71858  8  7 85  0  0
 1  0      0 11969976 140028 3391468    0    0     0 15564 33490 69433  7  7 86  1  0
 2  0      0 11969976 140028 3391468    0    0     0  3392 40593 82689  7  8 85  0  0
 1  1      0 11971740 140028 3391440    0    0     0 15568 35473 72960  6  7 86  1  0
 2  0      0 11973520 140028 3391476    0    0     0  5420 36698 76628  8  7 85  0  0
 1  0      0 11973344 140028 3391476    0    0     0 13000 37315 77527  6  7 87  0  0
 1  0      0 11973392 140028 3391536    0    0     0  8284 34950 71476  6  8 86  0  0
 2  0      0 11973408 140028 3391536    0    0     0 13840 38778 79834  6  7 86  1  0
 1  0      0 11974132 140028 3391544    0    0     0  8692 37283 74883  7  7 86  0  0
 3  0      0 11974132 140028 3391544    0    0     0 10444 37224 77928  6  8 86  0  0
 2  0      0 11973108 140028 3391608    0    0     0 15688 34599 71052  6  7 86  1  0
 0  0      0 11980788 140028 3391728    0    0     0  6868 33311 72294  7  8 84  0  0
 0  0      0 12069692 140028 3363668    0    0     0  2588 2979 4393  1  1 98  0  0

iowaitが小さくなり、user, systemが以前より高くあがるようになりました。

topだと

top - 07:43:15 up  2:16,  2 users,  load average: 0.43, 0.11, 0.05
Tasks: 169 total,   1 running, 168 sleeping,   0 stopped,   0 zombie
%Cpu(s):  6.2 us,  3.9 sy,  0.0 ni, 85.9 id,  0.4 wa,  0.0 hi,  3.6 si,  0.0 st
MiB Mem :  15827.2 total,  11689.1 free,    689.4 used,   3448.7 buff/cache
MiB Swap:      0.0 total,      0.0 free,      0.0 used.  14833.8 avail Mem 

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND                                                                                                          
  44962 systemd+  20   0 1377796 216128  20612 S  77.4   1.3   1:29.73 mysqld                                                                                                           
  47046 root      20   0  710064  10828   3836 S  60.5   0.1   0:06.25 go   

ようになり、1vCPU 使い切りそうなぐらいまできてました。

ちなみに、MySQLのデータディレクトリをみんな大好きtmpfsにおくと、

Go言語(interpolateParams=true)

平均秒数:1.106469
real    0m 11.06s
user    0m 2.45s
sys     0m 5.07s

Python

平均秒数:1.394543
real    0m 13.97s
user    0m 6.15s
sys     0m 1.70s

もう少しだけ速くなります。

まとめ

ミドルウェア含めたベンチマークを行う時は、ミドルウェアやOSの設定なども気を配るのがいいですね。広い視点を保つためもにもISUCONは役に立つ。

コマンドを実行しその終了ステータスコードメトリクス化するMackerelカスタムプラグイン

Mackerel Advent Calendar 2020 15日目の参加記事です。

キャッシュの更新やなんらかの同期を行うコマンドなどをcronにて実行している場合、メンテナンスや一時的なネットワークの不調によりコマンドがエラーになっても問題ないが、一定時間以上エラーが続くと困る、みたいなことはあると思われます。

mackerelでは、cronのコマンドがエラーになった際に、通知する方法としては mkr wrap が利用できます。

mackerel.io

crontabに

* * * * * mkr wrap -- /path/to/your-batch ...

とすることで、コマンドが失敗した際に、アラートをあげることができます。ただmkr wrapでは1度でもエラーとなるとアラートとなってしまうため、前述したような一定時間エラーになっても問題ないものでもアラートが上がってしまいます。

そこで、mackerel-agentをcrondのように利用し、コマンドが終了ステータスコードをメトリクス化するカスタムプラグインを作成し、ホストのメトリック監視にてエラーが続いている状況がないかを確認する方法を考えました。

mackerel-plugin-command-status

できたのが、こちらのmackerelカスタムプラグインです

github.com

元々Perlでさっと作ったものでしたが、この記事を書くためにGoで書き直しました。mkr plugin install で導入が可能です。

使い方

メトリクスの名前を指定し、ハイフン2つのあとに実行するコマンドを書きます。

mackerel-agent.conf で実行する場合はこんな形になります。

[plugin.metrics.update-cache]
command = "/path/to/mackerel-plugin-command-status --name update-cache --timeout 10s -- /path/to/cmd-fetch-cache"

そして、5回以上(5分以上失敗し続けている場合)アラートをあげたいという場合は

f:id:kazeburo:20201214102920p:plain

こんな形で閾値を設けることで、監視ができるのではないかと思います。

あとがき

Mackerelで監視されていないとあるサーバがサービスに投入されてしまっていたことで障害に気づくのが遅れた問題に対して、1分毎にcronでホストのステータスをmackerelのapiから取得しておいて、consulサービスのチェックスクリプトからそれを確認し、workingでなかったらサービスから自動で外れるという対策を行っておりました。

ところが、mackerelの障害・メンテナンス時にcronのエラー通知が大量にきてあたふた対応が必要になってしまっていたので、なんとかそれを避けるために考えた仕組みです。ただし、これでもmackerelがメンテナンス終了するタイミングでアラートが来てしまうのが悩ましいところ。

作ってみたもののこれ以外の用途には使ってない感じですが、いい使い道があればどうぞー。

cgoを利用した Go project を GitHub Actions でクロスコンパイル&リリースを行う

check-lastlogというmackerel監視pluginを作ってるのですが、

github.com

こちらは /var/log/lastlog を読むために一部cgoを使っています。mackerelのpluginなので一つ前の記事で紹介した方法で

kazeburo.hatenablog.com

リリースしたいところなのですが、GoReleaserでは、cgoへの依存があるプロジェクトのクロスコンパイルがサポートされていないため、ひと工夫必要になります。

goreleaser.com

github.com

check-lastlogではこちらのxgoを使う方法でクロスコンパイルし、GoReleaserでGitHub Releaseにアップロードをしています。

出来上がったGitHub Actionsのワークフローはこちら

https://github.com/kazeburo/check-lastlog/blob/master/.github/workflows/release.yml

xgoでのクロスコンパイル

xgo はGoのクロスコンパイルの環境がはいったDockerのイメージと各環境をターゲットとしたビルドを行うコマンドを提供しています。

github.com

check-lastlogではGitHub Actionが用意されているこちら

github.com

を使いました。こちらはforkしたxgoを使っているようです。

check-lastlogのワークフロー、.github/workflows/release.yml の前半ではxgoを使ってクロスコンパイルしています。

name: release
on:
  push:
    tags:
    - "v[0-9]+.[0-9]+.[0-9]+"
jobs:
  goreleaser:
    runs-on: ubuntu-latest
    env:
      BIN_NAME: check-lastlog
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          fetch-depth: 0

      - name: Set tag to environment variable
        id: set-tag
        run: echo ::set-output name=version::${GITHUB_REF#refs/*/}

      - name: Build with xgo
        uses: crazy-max/ghaction-xgo@v1
        with:
          xgo_version: latest
          go_version: 1.15.x
          dest: build
          prefix: ${{ env.BIN_NAME }}
          targets: linux/amd64,linux/arm64
          v: true
          x: false
          ldflags: -s -w -X main.version=${{ steps.set-tag.outputs.version }}

      - name: Check build dir
        run: ls -lR build

ghaction-xgo のパラメータは次のようになってます。

  • dest: ビルドしたバイナリを格納するディレクト
  • prefix: バイナリのファイル名。後ろに -${OS}-${Arch} がつきます
  • targets: クロスコンパイルするターゲット。カンマで区切る
  • v: ビルドしたファイル名の表示
  • x: ビルドの際のコマンドなどの表示

また、GoReleaser ではldflagsをデフォルトでいくつか設定してくれますが、xgoはそうではないので、-w -s-X main.version を追加しています。

これでビルドが完了すると、dest で指定したディレクトリにバイナリができます。

> Run ls -lR build
build:
total 3428
-rwxr-xr-x 1 runner docker 1806024 Dec  2 09:12 check-lastlog-linux-amd64
-rwxr-xr-x 1 runner docker 1703448 Dec  2 09:12 check-lastlog-linux-arm64

アーカイブ作成とGitHub Releaseへのアップロード

あとは、GitHub Releaseへのアップロードだけではあるのですが、check-lastlog はmackerel監視プラグインなので、mkr plugin install 対応をさせます。

mkr plugin install 対応するためにはファイル名が {{ .ProjectName }}_{{ .Os }}_{{ .Arch }}.zip でzipでアーカイブする必要があります。なのでShellで頑張ってzipをつくりました。 

- name: Zip binaries
  run: |
    cd build
    for file in ./* ; do
      mkdir $(echo ${file}|awk -F- '{print "${{ env.BIN_NAME }}_"$(NF-1)"_"$(NF)}') &&
      cp ${file} $(echo ${file}|awk -F- '{print "${{ env.BIN_NAME }}_"$(NF-1)"_"$(NF)}')/${{ env.BIN_NAME }} &&
      zip $(echo ${file}|awk -F- '{print "${{ env.BIN_NAME }}_"$(NF-1)"_"$(NF)}').zip -j $(echo ${file}|awk -F- '{print "${{ env.BIN_NAME }}_"$(NF-1)"_"$(NF)}')/${{ env.BIN_NAME }} ../README.md ../LICENSE;
    done
    shasum -a 256 *.zip > ${{ env.BIN_NAME }}_${{ steps.set-tag.outputs.version }}_checksums.txt
    ls -lR ./

バイナリだけではなく、README.mdやLICENSEファイルもアーカイブに同梱しています。また、checksum用のファイルも作っています。GoReleaserのchecksumがsha256なので、同じようにshasumコマンドを使ってみました。

そして最後にGitHub Releaseにアップロードするのですが、CHANGESも作ってくれるGoReleaserを使います。

まず、GoReleaserの設定ファイル .goreleaser.yml

# Use for generating CHANGELOG
builds:
  - binary: check-lastlog
    skip: true
release:
  github:
    owner: kazeburo
    name: check-lastlog
  extra_files:
    - glob: "build/*.zip"
    - glob: "build/*_checksums.txt"

ビルド(builds)は skip:true で止めることができました。成果物がない場合、archiveの作成も行われないようでした。GoReleaserで作ったパッケージのかわりに、xgoとShellで作ったzipとchecksumファイルをextra_filesに指定してアップロードしてもらいます。

GitHub Actionsのワークフローは goreleaser/goreleaser-action を使いました。

- name: Run GoReleaser
  uses: goreleaser/goreleaser-action@v2
  with:
    version: latest
    args: release --rm-dist
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

GoReleaser 便利

まとめ

xgoを使うとGoReleaserのみでクロスコンパイルする場合にくらべて実行時間は伸びてしまいますが、cgoを使っているGo Projectでもlinuxamd64, arm64に対応したパッケージを作ることができました。

GitHub ActionsとGoReleaserでmkr plugin install対応mackerel pluginをリリースする

Mackerel Advent Calendar 2020 9日目の参加記事です。隙間で書きました。

最近 mackerel pluginをリリースするときは、ほぼ全てGitHub ActionsとGoReleaserを使ってビルドとリリースしています。

GoReleaserはこちら

goreleaser.com

GoReleaserはGoのprojectをクロスプラットフォームでビルドし、GitHub Releaseなどにアップロードしてくれるツールです。これのおかげでかなりリリース作業が楽になりました。

準備

mackerel pluginのrepositoryに .goreleaser.yml というファイルを作ります。

builds:
  - binary: mackerel-plugin-linux-process-status
    goos:
      - linux
    goarch:
      - amd64
      - arm64
archives:
  - format: zip
    name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
release:
  github:
    owner: kazeburo
    name: mackerel-plugin-linux-process-status

こちらは mackerel-plugin-linux-process-status という plugin の設定ファイルです。linuxのみサポートなので、linux/amd64およびlinux/arm64用にバイナリをビルドします。

mkr plugin install に対応したpluginはアーカイブのファイル名が(REPOSITORY_NAME)(GOOS)(GOARCH).zipという命名規則になっていることが必要です。

mackerel.io

なので、archivesの name_templateに {{ .ProjectName }}_{{ .Os }}_{{ .Arch }} を指定します。

これで、GoReleaserの準備は完了で、手元で、goreleaser --rm-dist を実行すると、最新のタグを取得してビルドとGitHub Releaseへのアップロードを行ってくれます。楽。

また、GoReleaserは以下のような簡単なCHANGESを作ってくれるのもとても気に入っています。

f:id:kazeburo:20201209162731p:plain

GitHub Acitons を使う

ここまでできていたら、tagがつけられたらGitHub Acitonsでビルドとリリースを自動化するだけ。

.github/workflows/release.yml などファイルを作成し

name: release
on:
  push:
    tags:
    - "v[0-9]+.[0-9]+.[0-9]+"
jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2
        with:
          fetch-depth: 0

      - name: Setup Go
        uses: actions/setup-go@v2
        with:
          go-version: 1.15

      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@v2
        with:
          version: latest
          args: release --rm-dist
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

GoReleaser用のActionが用意されているので、これを使うだけ

github.com

上記はサンプルそのままですな。

これで、

git tag v${VERSION}
git push origin v${VERSION}

するだけで自動でパッケージの作成とリリースができるようになりました。簡単。

後書き

最近、公開してあったたくさんのmackerel pluginを一気にGitHub Actionsを利用するようにしたり、arm64での動作確認を行ってました。 その過程でtag追加を楽にするコマンドを作ったり、cgo含むprojectのGitHub Actionsでのクロスコンパイルをやってたりした話はまた今度できたらと思います。

GoでMySQLの監視コマンドを作成するのが楽になるライブラリ

本エントリはMySQL Advent Calendar 2020 の9日目の記事になります。

これまでいくつものMySQLの監視コマンドをつくってきたのですが、毎回同じような処理を書いているので、それらをまとめるライブラリを作ってみました

github.com

主な機能は

  • go-flags形式でのコマンドオプションとデータベースへの接続
  • SHOW STATUS/VARIABLES/SLAVE(REPLICA) STATUSステートメントの構造体へのマッピング

の2つです。

使い方 (コマンドラインオプション)

まずコマンドオプションの処理

オプションを定義した構造体に mysqlflags.MyOpts を追加します

type opts struct {
    mysqlflags.MyOpts
    Timeout time.Duration `long:"timeout" default:"10s" description:"Timeout to connect mysql"`
    Version bool          `short:"v" long:"version" description:"Show version"`
}

opts := opts{}
psr := flags.NewParser(&opts, flags.HelpFlag|flags.PassDoubleDash)
_, err := psr.Parse()

これだけで、helpを表示させると以下のようになります。

Application Options:
      --defaults-extra-file= path to defaults-extra-file
      --mysql-socket=        path to mysql listen sock
  -H, --host=                Hostname (default: localhost)
  -p, --port=                Port (default: 3306)
  -u, --user=                Username (default: root)
  -P, --password=            Password
      --database=            database name connect to
      --timeout=             Timeout to connect mysql (default: 10s)
  -v, --version              Show version

MySQLの defaults-extra-file、socket、ホスト、ポート、ユーザなどのコマンドオプションが追加されてますね。

この構造体を利用してデータベースに接続するには次のようにします。

db, err := mysqlflags.OpenDB(opts.MyOpts, opts.Timeout, debug)
if err != nil {
    log.Printf("couldn't connect DB: %v", err)
    return 1
}
defer db.Close()

DSNだけ欲しい場合は、mysqlflags.CreateDSN()` を使います。

mysqlflagsはDSNを作る際に、my_print_defaults -s client コマンドを実行して my.cnf などに書かれた値を取得します。

% my_print_defaults -s client
--host=127.0.0.1
--user=xxx
--password=xxx

これらをデフォルト値として利用するので、$HOME/.my.cnf が設定されていればコマンドラインオプションが未指定でもコマンドが動きます。

また、接続する際のparameterを追加したい場合は、MySQLDSNParams にmapを渡します。

params := map[string]string{
    "readTimeout": timeout.String(),
}
opts.MySQLDSNParams = params

使い方 (SHOWステートメントの構造体へのマッピング)

MySQLのSHOW STATUSやVARIABLESでは列方向に情報が並べられています。行によってstringやint、boolっぽいものが混じります。

mysql> SHOW VARIABLES;
+-----------------------------+-------------------------------+
| Variable_name               | Value                         |
+-----------------------------+-------------------------------+
| activate_all_roles_on_login | OFF                           |
| admin_address               |                               |
| admin_port                  | 33062                         |
| admin_tls_version           | TLSv1,TLSv1.1,TLSv1.2,TLSv1.3 |
..

これらを良い感じにintやstringに自動で変換して構造体にマッピングしてくれると、監視コマンドのコードは短くなり、とても楽になります

また、SHOW SLAVE(REPLICA) STATUSではカラムが増え、行方向に情報が追加されています。情報が縦方向なのか横方向なのかあまり気にせず同じコードで必要とするカラムだけ良い感じにとれると嬉しいところです。

これをやるのが、mysqlflags.Query().Scan() です。内部では mapstructure というライブラリを利用しています。

github.com

thread関連の値をSHOW VARIABLES/SHOW GLOBAL STATUSから取得するのは以下のように書けます

type threads struct {
    Running   int64 `mysqlvar:"Threads_running"`
    Connected int64 `mysqlvar:"Threads_connected"`
    Cached    int64 `mysqlvar:"Threads_cached"`
}

type connections struct {
    Max       int64 `mysqlvar:"max_connections"`
    CacheSize int64 `mysqlvar:"thread_cache_size"`
}

var threads threads
err = mysqlflags.Query(db, "SHOW GLOBAL STATUS").Scan(&threads)
if err != nil {
    log.Printf("couldn't fetch status: %v", err)
    return 1
}
var connections connections
err = mysqlflags.Query(db, "SHOW VARIABLES").Scan(&connections)
if err != nil {
    log.Printf("couldn't fetch variables: %v", err)
    return 1
}

構造体のメタ情報のタグに、mysqlvar:"取得する変数名" を入れると自動的にマッピングを行います。

レプリケーションの情報を取得する際は次のようにします。

type replica struct {
    IORunning   bool `mysqlvar:"Slave_IO_Running"` // Replica_IO_Running
    SQLRunning  bool `mysqlvar:"Slave_SQL_Running"` // Replica_SQL_Running
    LastSQLError string `mysqlvar:"Last_SQL_Error"`
}

var replica replica
err := mysqlflags.Query(db, "SHOW SLAVE(REPLICA) STATUS").Scan(&replica)

f !replica.IORunning || !replica.SQLRunning {
    fmt.Errorf("something wrong in replication with %s", replica.LastSQLError);
}

bool へのマッピングは、値が Yes や On だった場合に true になり、それ以外は false になります。 Slave_IO_Running(Replica_IO_Running) は Connecting という文字列にもなりますが、こちらは false 扱いです。

もし、真偽値判定を楽にしたいけど、元の文字列も取得したいときは、mysqlflags.Bool 型が使えます。

type replica struct {
    IORunning   mysqlflags.Bool `mysqlvar:"Slave_IO_Running"`
    SQLRunning  mysqlflags.Bool `mysqlvar:"Slave_SQL_Running"`
    ChannelName *string         `mysqlvar:"Channel_Name"` // ポインタ型はオプショナルになる
    Behind      int64           `mysqlvar:"Seconds_Behind_Master"`
}

var replicas []replica
err := mysqlflags.Query(db, "SHOW SLAVE(REPLICA) STATUS").Scan(&replicas)

for _, replica := replicas {
    f !replica.IORunning.Yes() || !replica.SQLRunning.Yes() {
        if replica.ChannelName == nil {
            *replica.ChannelName = "-"
        }
        fmt.Errorf("something wrong in replication Channel:%s IO:%s SQL:%s Error:%s",
            *replica.ChannelName,
            replica.IORunning, replica.SQLRunning,
            replica.LastSQLError);
    }
}

Yes() メソッドがbool値を返し、String() で元の文字列を返します。

使用例

いくつかのMackerelプラグイン、checkプラグインはこのライブラリを使って実装しています。

mkr plugin installでインストールできるようになってますので、ニーズが合えばご利用ください。

ライブラリや監視コマンドの機能追加要望などありましたらお気軽にどうぞ〜

mackerel plugin で瞬間的な負荷を捉える mackerel-plugin-maxcpu

とあるAPIに対するアクセスが、

   回数  時間
   1833 time:27/Oct/2020:21:32:25
   1657 time:27/Oct/2020:21:32:26
   1616 time:27/Oct/2020:21:32:27
   2584 time:27/Oct/2020:21:32:28
  14958 time:27/Oct/2020:21:32:29
   6619 time:27/Oct/2020:21:32:30
   1725 time:27/Oct/2020:21:32:31
   1796 time:27/Oct/2020:21:32:32
   1713 time:27/Oct/2020:21:32:33
   1932 time:27/Oct/2020:21:32:34
   1797 time:27/Oct/2020:21:32:35
   1841 time:27/Oct/2020:21:32:36

という感じで、一瞬だけ急増し、1秒後にはほぼ戻るようなことがあり、この瞬間にCPUが尽きることで全体のレスポンスタイムに影響してしまうようなことがありました。

とりあえず台数を増やしてスケールアウトで対応してるわけですが、大元の原因の解決するまで、CPUの使用率が100%に近くなる瞬間がないか監視しておきたいということで、新しく mackerel plugin を作成しました。

github.com

CPU使用率の計算

Linuxの場合、よくあるツールではCPUの使用率を /proc/stat のファイルを読んで計測します。

$ cat /proc/stat |head
cpu  4102976 0 548407 3258934935 6723 0 115819 0 0 0
cpu0 115620 0 14370 81452406 84 0 2527 0 0 0
cpu1 123341 0 12898 81453921 748 0 2527 0 0 0
cpu2 109211 0 16473 81465621 82 0 2505 0 0 0
cpu3 109706 0 14273 81467250 216 0 2513 0 0 0

cpuの行の数値は、user, nice, system, idle, iowait, irq, softirq.. と各モードに入っていた時間(USER_HZ)、CPU使用率を出すツールは一定周期でこのファイルを確認し、それぞれの数値の増分を使ってCPU使用率を計算しています。

通常のmackerel pluginの場合、1分周期でpluginが実行されるため、1分間の平均CPU使用率しか計算ができません。1分のあいだの最大値は取得できません。そこで、maxcpuでは初回plugin実行時に、1秒毎にCPU使用率を計測するbackgroundプロセスを作成し、次の起動タイミングからはbackgroundプロセスに前回取得タイミングからのCPU使用率の最大・最小・平均・パーセンタイル値を取得しています。

f:id:kazeburo:20201109133143p:plain

backgroudプロセスとの通信はgRPC over UNIXドメインソケットで行ってますが、gRPC(http含む)ではバイナリサイズが大きくなってしまうので変更するべきかもしれません。

maxcpuの使い方

gRPC over UNIXドメインソケットに使う --socket だけ指定します。

[plugin.metrics.maxcpu]
command = "/usr/local/bin/mackerel-plugin-maxcpu --socket /var/run/maxcpu.sock"

maxcpuでは運用がしやすいように、バイナリが入れ替わった際にbackgroundプロセスは自動で再起動(新しいプロセスを作成し、自プロセスを停止)します。このためpluginのアップデート時に手動にて再起動する必要はありません。この機能を実現するため、SO_REUSEPORT を使っています。

また、一定時間gRPCの通信がないと停止して、余計なリソースを使い続けることはないようにしています。

作成されたグラフは以下のようになります。何回かCPUが跳ねているのがわかります。

f:id:kazeburo:20201109133335p:plain

ニッチなpluginですが、ニーズが合えばお使いください。