sardine - mackerel plugin のメトリクスを CloudWatch で集約する agent を書いた

OSS紹介 Advent Calendar 2017 - Qiita 22日目の記事です。

f:id:sfujiwara:20171222113112j:plain

最近、監視を Zabbix から Mackerel に切り替えていっています。それと並行して、新規プロジェクトは Amazon ECS でコンテナで運用するようにもしていっています。そこで考えどころなのが、コンテナで動作するプロセスのモニタリングをどうするかです。

たとえば、コンテナで動作する nginx を mackerel-plugin-nginx で監視する場合、普通にやるとこんな感じになるのですが…

  • nginx と mackerel-agent を同一タスク (ECS用語) に定義する
  • mackerel-agent.conf で cloud_platform = "none"設定をして、コンテナがホストの EC2 とは切り離された状態でホストとして認識されるようにする

すべてのタスクを Mackerel 上のホストとして認識させることになると、いくつか困ることがあります。

  • 課金対象が増える
    • タスクは EC2 のホストなどよりかなり多くなるため、インパクトが大きい
    • 今後の Fargate 化を考えると、Fargate は最大4コアなので、多数のタスクを配置することになり EC2 で大きなインスタンスを動作させるよりもかなり数が増えそう
  • タスクはデプロイごとに入れ替わるので、ホストの入れ替わりイベントが大量に発生する
    • 自動退役すればみなくてよいとはいえ…

複数コンテナのメトリクスは、集約した状態で一つの値がみられれば用が足りることが多いため、何らかの形で集約メトリクスを実現したかったのですが、現状の Mackerel では同一時刻に登録したサービスメトリックの値は上書きされてしまいます。

そこで、CloudWatch で集約することを考えました。

CloudWatch では同一時刻に複数回送信した値を、合計、最大、最低、平均などの統計(statistics)を指定して取得することができるため、nginx でいうと RequestCount の合計なら単位時間内の総クエスト数、平均なら1コンテナあたりの平均リクエスト数、のように、目的に合わせて値を読み取れます。

sardine

github.com

mackerel-plugin (互換) のコマンドを実行し、得たメトリクスを CloudWatch へ投稿する agent として、sardine を書きました。 Go で実装されたシンプルな agent です。

命名は、mackerel(鯖)より小さいのが群れているので sardine(鰯) ということで。

[plugin.metrics.nginx]
command = "mackerel-plugin-nginx --host nginx --port 80 --path /nginx_status"
dimensions = ["Service=staging", "Service=staging,Task=app"]

このような、mackerel-agent.conf に類似した設定ファイルで定義をします。各 ECS タスクに同梱して動作させる想定です。

デフォルトでは60秒ごとにプラグインをコマンドとして実行し、たとえば得られた出力が以下だった場合に

nginx.requests.requests 123.0 1512057958

CloudWatch のメトリクスとしては以下の値を投稿します。

  • Namespace: nginx/requests
  • MetricName: requests
  • Value: 123.0
  • Timestamp: 2017-12-01T16:05:58Z

これで複数の同一種類のコンテナ、タスクから投稿された値を、大づかみにして把握できます。

f:id:sfujiwara:20171222121835p:plain

この集約されたメトリクスを、Fluentd を用いて (plugin-cloudwatch + plugin-macakrel) Mackerel に値を持っていくことで、Mackerel 上でグラフを並べてみることもできます。

f:id:sfujiwara:20171222121956p:plain

まとめ

  • コンテナ、タスク単位で Mackerel にホストを作らず、集約メトリクスを実現するために sardine を作りました
  • Mackerel 本体だけで、このような集約メトリクスが実現できると嬉しいです

Amazon CloudSearch にドキュメントを取りこむ Lambda 関数 s32cs のご紹介

このエントリは OSS紹介 Advent Calendar 2017 - Qiita 16日目の記事です。穴が空いていたので拙作の紹介で穴埋めを。

s32cs という Amazon CloudSearch に対して S3 からのイベントドリブンでドキュメントを投入する Lambda を書いたのでご紹介します。

github.com


3行で

  • Amazon CloudSearch にドキュメントを登録する Lambda を作った
  • アプリケーションからは Fluentd にログを送るだけ
  • Kinesis Firehose + S3 を利用することで、ストリームデータを 一定時間/一定サイズ に区切っての継続的なバッチ処理が容易かつ堅牢に

基礎知識

Amazon CloudSearch は AWS が提供している、フルマネージドな検索エンジンです。ドキュメント数が多くなったりすると勝手にスケール (アップ|アウト) してくれるので、キャパシティプランニングにそれほど気を遣うことなく全文検索を実装できます。

業務では数億(5より大きい)レコードを投入しているものもありますが、おおむね元気に動いてくれています。

CloudSearch へのデータの投入方法あれこれ

Amazon CloudSearch ドメインにデータをアップロード - Amazon CloudSearch

ここがちょっと癖があるところで、逐次レコードを登録するような、いわゆるストリーミングアップロード的なことはできず、投入するレコードが複数まとまった 5MBまでの JSON / XML ファイル (SDF) を用意してバッチ的に投げ込む API になっています。

素朴な方法

最初はものすごく素朴に、「5分ごとに DB から直近 5分のレコードを取得し、JSON に加工した上でアップロードする cron 処理」などで投入していました。

しかし、このような頻度が高い定期処理は、いろいろ面倒なことがあるのでできれば避けたいものです。

  • 前回の処理が何らかの原因で終わってない場合の排他処理
    • 特に流量が多くなると、並列処理で分散させるのが面倒になります
  • 失敗した場合のリカバリ
    • 前回処理が終わった最後のレコードを覚えていないといけない

また、レコードが削除されたというドキュメントも CloudSearch 側に反映しないといけないため、DBから物理削除してしまうと何らかの削除済みフラグを他に保存しておく必要があり、処理が複雑になります。

ken39arg/fluent-plugin-cloudsearch

そこで同僚の ken39arg が開発したのが fluent-plugin-cloudsearch です。Fluentd の output plugin として動作し、指定した時間/サイズごとにバッファを CloudSearch にアップロードするものです。

github.com

これによって、アプリケーションからはドキュメントの情報をログとして Fluentd に送信するだけでインデックスの更新ができるようになりました。ストリーミング処理ですね!

ただ、これもいくつか問題がありました。

  • 複数台で動作させると、ある1レコードの作成と削除が別々の Fluentd のバッファに保存される可能性がある
    • バッファのフラッシュタイミングによっては、作成→削除 ではなく 削除(空振り)→作成 という順で処理され、削除されたはずのレコードが消えてくれない
  • Fluentd のホストがダウンすると、バッファが失われる可能性がある
    • コンテナで動作させると、落ちた瞬間に失われるのでロストするリスクが大
  • 投入した SDF はどこにも保存されずに消えてしまうので、問題があった場合のリカバリが難しい

s32cs

ということで開発したのが s32cs です。名前はまったくいいキラキラネームが思いつかなかったので、「S3 to CloudSearch」という意味です。 Go + Apex で実装されています。

S3 にオブジェクトが配置された時のトリガで Lambda を実行し、

  • S3 からオブジェクトを取得
  • 以下のような 1行 1レコードの JSON をアップロード用の SDF 形式に加工
{"id":"123","type":"add","fields":{"foo":"bar","bar":["A","B"]}}
{"id":"123","type":"delete"}

[
  {"id":"123","type":"add","fields":{"foo":"bar","bar":["A","B"]}},
  {"id":"123","type":"delete"}
]
  • CloudSearch にアップロード

という動作を行います。

S3 に JSON を配置するときに fluent-plugin-s3 で行うと、fluent-plugin-cloudsearch 同様に作成と削除が入れ替わる可能性があるため、Firehose を経由することでレコードの発生順序を(できるだけ)直列化するのがお薦めです。

  1. アプリケーションは Fluentd に送信
  2. Fluentd は Kinesis Firehose に逐次送信
  3. Firehose は 5分 / 5MB 単位などで S3 にオブジェクトを保存
  4. s32cs が S3 のオブジェクトを加工してアップロード

これによって、fluent-plugin-cloudsearch であったいくつかの問題も解消しました。

  • 複数台で動作させると、ある1レコードの作成と削除が別々の Fluentd のバッファに保存される可能性がある
    • → Firehose へなるべく間隔をおかずに送信することでイベントの順序を保つ
  • Fluentd のホストがダウンすると、バッファが失われる可能性がある
    • → 短時間で Firehose へ送り出されるので、ダウン時の影響が小さい
  • 投入した SDF はどこにも保存されずに消えてしまうので、問題があった場合のリカバリが難しい
    • → S3 に元データが残っているので、再度別の箇所に投入したり、内容を確認することが容易

まとめ

Kinesis Firehose + S3 を利用することで、ストリームデータを 一定時間/一定サイズ に区切っての継続的なバッチ処理が Lambda で容易かつ堅牢に行えるようになります。

使いでのあるアーキテクチャパターンなので、今後も多用していきたいと思います。

fluent-logger-golang の実戦的な使いかたまとめ

OSS紹介アドベントカレンダー の14日目の記事です。

Fluentd の 公式 Go 版 Logger である fluent-logger-golang はこのように使うのがよさそう、という使い方をまとめてみました。 元々社内で書いておいたドキュメントを編集したものです。

github.com

前提のユースケース

Webアプリケーション(APIサーバ) を Go で書いていて、そこから何らかのログを Fluentd に送信したい。

config のお勧めオプション

  • Timeout : Connect に対するタイムアウト。デフォルト3秒なのでそのままでよさそう
  • WriteTimeout : 書き込みのタイムアウト。デフォルトだとずっと待ってしまうので 3 秒とか?
  • BufferLimit : デフォルト 8MB これを超えると捨てられてしまう。送る流量によって調整が必要
  • MaxRetry : この回数だけリトライして失敗すると panic する。Exponential back off でリトライ間隔が延び続ける(上限なし)ので、リトライを繰り返し続けると間隔が広がりすぎてつらいが、panic されるよりはマシなので大きめに設定しておく
  • AsyncConnect : fluent.New() するときに connect できなくても error を返さず、裏でリトライする。 起動時に接続先の Fluentd がちゃんと動いてない場合に気がつけない可能性があるので、false (default) がよさそう

fluent.New() 時に接続できない場合

  • 特にコンテナで動作させる場合など、アプリケーション起動時に Fluentd がまだ Listen できていない状態があり得る。 fluent.New() 時に接続できないと error が返るので、そこで失敗扱いにしてしまうとアプリケーションの起動が失敗することになる
  • config.AsyncConnect = true にすると、設定ミスなどで Fluentd がいつまでも起動してこない場合に困る

アプリケーションで、適切にリトライ処理を行うほうがよい。

import "github.com/Songmu/Retry"

var client FluentClient
err := retry.Retry(3, time.Second, func() (err error) { // 3回試行。失敗したら1秒待つ
    client, err = fluent.New(fluent.Config{})
    return err
})

Post() がエラーを返してきたらどうすべきか

Post()net.Conn.Write() が正常に返ってくれば成功するので、この時点で Fluentd へ接続しているソケットに書き込みは成功している。本当に受け取れているかはまだ分からない。

error が返ってくるのは以下の場合。

  • オンメモリバッファが溢れた → ログは捨てられてしまう
  • 再接続試行中 errors.New("fluent#send: can't send logs, client is reconnecting")
  • net.Conn.Write() が error を返した
    • 自動的に net.Conn.Close() が行われ、再接続が裏 (別 goroutine) で走る

error が返ってきたとしてもバッファが溢れない限りは積まれて再送されるが、その再送が最終的に成功するかどうかは不明なため、この時点で対処する方がよい。

また、バッファが溢れた場合はその時点で対処しないとリカバリする方法はない。

ごくごく単純に書くとこう。

if err := logger.Post(tag, x); err != nil {
    log.Println(tag, x) // stderr にだしちゃう
}

実際はリカバリを考えて、再処理しやすい format で別の場所に書き出しましょう。

struct を投げるときの留意点

struct については何もしなくても msgpack でエンコードしてくれるが、public field が大文字はじまりなのでログの key も大文字になってしまう。

msgp を使って struct に対して MarshalMsg() を生成しておくと、それが使われる。

import "github.com/tinylib/msgp/msgp"

//go:generate msgp
type Foo struct {
    Foo int    `msg:"foo"`
    Bar string `msg:"bar"`
}

アプリケーションを shutdown する処理を書くときの留意点

Close() を呼ぶと、その時点で内部に持っているバッファがあればそれは送信を試みた上で終了する。ただし、そこで失敗した場合にリカバリする方法はない。 (バッファ自体は private なので、アプリケーションから触ることができない)

Post() で送信失敗してバッファに積まれるということは接続先が落ちている状況なので、その状況で Close() を呼んだとしても、再送に成功する可能性は高くない。

そのため、ロストしては困るログについては、Post() 失敗時点で他の箇所に書き込むなどの対応が必須。そうしていれば Close() は必ずしも呼ぶ必要はない。 (呼んで困ることはないので呼べるなら呼びましょう)


この元の文書を書いた時点では、go-fluent-client は同期書き込み (ソケットに書き込めてからアプリケーションに処理を戻す) がサポートされていなかったため、要件に合わずに採用しませんでしたが、最近同期モードもサポートされたようなので、go-fluent-logger も検討してみたいですね。

medium.com

aswrap - ~/.aws/(config|credentials) で定義した AssumeRole 定義から一時キーを取得してコマンドを起動してくれる wrapper を書いた

業務では多数の AWS アカウントを運用しています (現時点でアクティブなのは100行かないぐらい)。

AWS アカウントごとに利用者それぞれに IAM User や IAM key を発行するのは権限管理が煩雑になるため、以下のようなアカウント運用をしています。

  1. ログインを集約する専用の AWS アカウントを用意 (account A とします)
    • このアカウントに IAM User を作成し、IAM key を発行する
  2. 各案件用の AWS アカウント(account B) に IAM Role を定義し、Assume Role によって権限を付与

(最近 AWS Organization が Single Sign On に対応したので、今後は不要になるのかもしれませんが…)

こうしておくことで、AWS console には account A でログインした上で、各アカウントにロールの切り替えで遷移することができます。

また、aws-cli では以下のような定義を ~/.aws/credentials 等に記述しておくことで、aws --profile service1 ... として実行すれば自動的に一時キーを取得して account B に対してアクセスすることができます。

# account A
[kayac-iam]
aws_access_key_id=XXXXXXX
aws_secret_access_key=YYYYYYY

# account B
[service1]
role_arn=arn:aws:iam::9999999999:role/InfraTeam
source_profile=kayac-iam

dev.classmethod.jp

aws-cli 以外での問題

Terraform 等、aws-cli ではない AWS SDK を利用したアプリケーションを実行する際には、上記の記述での一時キー解決が動作しません。

そのため、Insntance Profile が付与された EC2 上で実行するか、IAM User を account B に作成してそのキーを使う必要がありました。 しかし、各アカウントで個別に IAM User を作成してしまうと管理が面倒ですし、キーを複数人で共有したりする問題が起きやすくなります。

ということで、思い立って aswrap という wrapper コマンドを書きました。

github.com

このコマンドは、~/.aws/(config|credentials) に定義されてる profile を元に aws sts assume-role で一時キーを取得し、それを環境変数 AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN に設定した上で引数に渡したコマンドを exec する、というものです。

$ AWS_PROFILE=service1 aswrap terraform plan

(terraform plan を一時キー環境変数設定済みの状態で実行する)

また、引数なしで起動すると環境変数を export する shell command を出力するので、eval することで現在の shell に環境変数を設定できます。

$ eval "$(AWS_PROFILE=service1 aswrap)"
$ aws sts get-caller-identity   # service1 の credential で実行される

ということで、aswrap を利用すると、aws-cli と同様の profile 管理で多数のコマンドが実行できるようになります。便利!

Perl 製ですが、fatpack してあるので 5.14.0 以降(または JSON::PP がインストールされていればそれ以下でも) なら、特に依存はなくファイル単体で利用できるはずです。

alp と Plack::Middleware::QueryCounter を合わせて使うと捗る

OSS紹介 Advent Calendar 2017 - Qiita 4日目の記事です。

@tkuchiki 氏が作っている alp (Access Log Profiler) は、もはや ISUCON 競技者必須ツールとなった、LTSV 形式のアクセスログをいい感じに集計してくれるツールです。 github.com

通常は alp では reqtime, apptime など、リクエストの処理に要した時間を集計するこのでパフォーマンスチューニングするのですが、実は集計対象は引数 --apptime-label を指定することで、集計時に自由に決めることができます。パフォーマンスに影響するなんらかの数値をログに出力しておけば、それを key にして集計できるということですね。

そこでもうひとつ、@acidlemon 氏作の便利 Plack::Middleware を組み合わせてみました。

metacpan.org

Plack::Middleware::QueryCounter::DBI は enable するだけで、DBIx::Tracer を使ってそのリクエスト中の DBI でのクエリ発行数を計測し、レスポンスヘッダに出力してくれます。

    log_format ltsv 'host:$remote_addr\t'
    # (snip)
                    'query_total:$upstream_http_x_querylog_total\t'
                    'query_read:$upstream_http_x_querylog_read\t'
                    'query_write:$upstream_http_x_querylog_write\t'
                    'query_other:$upstream_http_x_querylog_other\t'

ということで、nginx ではそのヘッダを LTSV 形式でログに残すようにすれば…

$ alp -r --avg --apptime-label="query_total" < access.log
+-------+--------+---------+----------+--------+-----------+-----------+------------+-----------+--------+-------------------------------------------------------+
| COUNT |  MIN   |   MAX   |   SUM    |  AVG   | MAX(BODY) | MIN(BODY) | SUM(BODY)  | AVG(BODY) | METHOD |                          URI                          |
+-------+--------+---------+----------+--------+-----------+-----------+------------+-----------+--------+-------------------------------------------------------+
| 5     | 35.000 |  35.000 |  175.000 | 35.000 |  2119.000 |  2430.000 |  11479.000 |  2295.800 | POST   | /api/xxxxxxx/top                                      |
| 51    |  6.000 | 474.000 | 1765.000 | 34.608 |    38.000 |   656.000 |   3741.000 |    73.353 | POST   | /api/yyyyyy/action                                    |
| 1     | 19.000 |  19.000 |   19.000 | 19.000 |  2825.000 |  2825.000 |   2825.000 |  2825.000 | GET    | /api/user                                             |
| 5     | 17.000 |  18.000 |   87.000 | 17.400 |  1527.000 |  1588.000 |   7807.000 |  1561.400 | POST   | /api/zzzzzz/info                                 |
....

どこの path の処理中にクエリを大量に発行しているのかが一目瞭然ですね。

ベンチマーク中に大変便利だったのでご紹介でした。

ISUCON 7 本選で負けてきました

毎年恒例の ISUCON 7 本選に参加して、惨敗してきました。

競技中の最終スコアが 19600 程度、これが再現できていれば7位だったのですが、実際の結果は fail で 0 点、過去7大会の予選と本選で一度も経験したことがないスコアなしでの終戦でした。

今回の問題はインフラ、ミドルウェア的にいじるところはほぼなく、nginx の設定は初期状態からいじらず (APIで返す websocket の endpoint を Go の TCP 5000 にしたので)、MySQL で最低限の設定をしたのみでした。チームメイトの @acidlemon, @handlename のコード修正を見守るばかりで、もっと能動的に動けたらなあ、という後悔もあり。

やったこと

  • m_items をメモリに持つ (handlename)
  • getStatusをtickerのときのみキャッシュする (acidlemon)
  • getCurrentTime のタイムスタンプをメモリにのせる (acidlemon)
  • roomごとに接続先ホストを round robin で決定 (handlename)
  • adding ON DUPLICATE KEY UPDATE をやめる (acidlemon, fujiwara)
    • pkey を auto increment で別に作成し、同一 time での INSERT をロックなしに可能にする
  • GetPriceとGetPowerをキャッシュする (handlename)

15時ぐらいに一時 24000 程度で暫定1位を取ったものの、ここからたまに通るけどたまに事後検証で fail する、というのが解消しきれず。

途中、math/big.Int の計算がとにかくボトルネックなのはプロファイリングで把握していたので、正確な計算を諦めて DB には float で持つ (そうすると SELECT sum(isu) で大量に転送しないでも合計の概算が得られる) というブランチは自分が並行で実装していて、これも 24000 程度。ただしこれも稀に fail するので最終的には採用せず。

acidlemon が確定値を cache することで adding の SELECT を減らす、というコードを1時間ほど書いていったものの、これの微妙なバグが全員で最後まで取り切れなかったので(取り切れていたらスコアももう少し伸びて至ろうなという雰囲気)、その後あれこれ細かいところを詰めたもののブレイクスルーはなく、終戦、という感じでした。

最後はスコア 0 で終わりたくないので直前に3回連続で通過していたコードで最終提出したのですが、結果的には fail でした。過去すべての大会でスコアを残せていた自分としてはそれがとにかく残念ですが、まあ仕方ないですね。

出場チームの皆様、ありがとうございました。優勝したチーム MSA (会社の同僚チーム) 、おめでとうございます!

ISUCON 7 予選2日目を3位で通過しました

まずは出題と運営チームの皆様にお礼を。予選から1チーム3台、合計1200台のサーバを用意するという空前の規模で、快適な競技環境を用意していただいてありがとうございました。

isucon.net

今回は ISUCON 4 の時の fujiwara組 (@fujiwara, @acidlemon, @handlename) を再結成して、自称社内最強チームで望むことに。1日目には同じくカヤックから参戦のチーム MSA が1位を取っていて、これは予選通過はもちろん、スコアでもできれば負けたくないという戦いでした。

f:id:sfujiwara:20171023114230p:plain

最終的には 48万点越え、両日通してのスコアでも3位ということで、まずまずの結果が残せたと思います。

やったこと

  • あらかじめ用意しておいた Chef recipe で各種ツールや各人のアカウント作成、公開鍵設定
    • さくらのクラウドで用意されている Ubuntu から使われるとすれば今回は 16.04 LTS だろう、ということで決め打ちできたので楽だった
  • Go 実装で行くことに決定
    • 前日1位の MSA は (聞いてないけど面子的に) Go だろう、変にハマることもなさそう、という判断
    • (とかあるので、なんだかんだで2日目の方が毎回微妙に有利なんですよね。運営もトラブルが少ないし…)
  • dstat をみながらベンチを掛け、どう見ても画像が DB に入ってるのがまずいのでそれを外出しすることに
    • @handlename が Perl でさくっとやってくれた
  • my.cnf を軽く調整 (@fujiwara)
    • Slowlog出力、innodb_flush_log_at_trx_commit = 2InnoDB Buffer Pool Warmup の設定のみ
    • 結局これ以外いじらなかった
  • css, js, fonts を gzip_static で配信 (@fujiwara)
    • expires max を追加してここは 304 が返せるようになった
  • 画像がアップロードされたタイミングでファイルに書き出し、それがあったらDBをみないで返す (@acidlemon)
  • index 追加 (@handlename)
  • channel テーブルに messages_count カラムを追加して count(*) をやめる (@acidlemon)

ここまで app 1台でやっていて、16時ぐらい。ボトルネックは /icons のファイル配信での帯域で、ここをクリアしないとどうしようもない。

Expires と Cache-Control max-age では、css 等は 304 を返せるんだけど、なぜか icons では効かない…としばらく悩んで、でもここは絶対 Cache-Control でクリアできるはず。@acidlemon が Cache-Control: public を付けてみては、と閃いたので付けたら効く。10万点ぐらい。

ここで css等と icons でベンチマーカーのキャッシュ挙動が異なったのが、だいぶ難しい上にここを突破できないと先に進めないという一番の難関だった感じがします。

  • GET /message, GET /history のループクエリ解消 (@handlename)
  • pt-query-digest の結果から、prepare, execute が2クエリに分かれているので interpolateParams=true の設定を入れてクライアントサイド prepare に (@acidlemon, @fujiwara)

そろそろ複数台構成を伺いたい。となるとファイルの共有なり分散配置が必要になるので、以下のような戦略を決定 (@fujiwara)

  • アップロードされたホストが自分のホスト名を元にディレクトリを切って icons/01/xxxxxxx.png というファイル名で保存、DB にも 01/xxxxxxx.png を入れる
  • nginx(01)
    • /icons/01/ はローカルファイルを見る (そこに保存されているので必ずある)
    • /icons/02/ は nginx(02) へローカルのネットワークで proxy_pass
  • nginx(02)
    • /icons/02/ はローカルファイルを見る
    • /icons/01/ は nginx(01) へローカルのネットワークで proxy_pass
  • nginx(03)
    • /icons/01, 02 はそれぞれ nginx(01, 02) に振る
    • それ以外は app(01, 02) に均等に振る

このように一カ所にファイルを保管しないでローカルに配置し、表からそれぞれ持っているはずのホストに Proxy するという手法は、 ISUCON 3 の出題をしたときに想定解答の一つとして考案していたもの。ファイルは一カ所にしかないので各種メタデータもずれないし、特定のホストに負荷が集中することもないのでお気に入りの手法です。

そういえば、先日の Fastly Yamagoya meetup でも、Fastly は Cache を持っているホストに内部で Proxy することでヒット率を上げている、という話がありましたね。

これで複数台の帯域を使えるようになり、予選ボーダーの20万点を大きく超えてきたのが18時過ぎ。19時前に最高スコア 54万を記録。

このあたりから、Go のアプリを再起動しないで複数回ベンチを掛けるとアプリが太って swap を使いだし、極端にパフォーマンスが落ちるのを発見。

  • 環境変数 GOGC=50 を設定してメモリ消費を抑える (@fujiwara)
    • 1回のベンチ走行後が 800MB → 450MB ぐらいになって、仮に最後の確認で複数回走行されても swap しづらいので安定する
    • ピーク性能は落ちるので、最終的に 50万を超えられなかったのは多分このため
  • ファイル名を決めるのに SHA1 を全部舐めて求めているのが無駄なので先頭 1024 byte だけで決める (@acidlemon)
    • これは先頭 16KB までで判断(したつもりだったけど多分実装ミスでなってなさそう)、初期チェックは通るもののなぜかベンチ中の整合性チェックで死ぬので revert
  • POST /profile で2発飛ぶ update 分を1個にまとめる (@acidlemon)
  • Session に user 情報を全部入れて DB を引かない (@handlename)
    • この二つはいまいち効果がみられず。ただし revert することもないのでそのまま

最後30分は再起動試験をして、再起動後の一発目のベンチで 48万がでたのでそこで打ち止め。

最後は †空中庭園†《ガーデンプレイス》には及ばなそうと覚悟はしていたものの、スギャブロエックスに逆転されたのがちょっと悔しいですね!

今回もコードを書くのは @acidlemon, @handlename に任せて、それぞれ並行して有効手を打ち込めたのでよかったですね。あと Cache-Control: public が早い段階で突破できたのがだいぶ余裕を生んだ感じ。

ともあれ 3位でフィニッシュできて、晴れて本選に進めることになったので、本選も頑張りたいと思います。