その手の平は尻もつかめるさ

ギジュツ的な事をメーンで書く予定です

書評: ISUCONを「ゴール」で終わらせない。『達人が教えるWebパフォーマンスチューニング ~ISUCONから学ぶ高速化の実践』

著者のid:catatsuyさんよりご恵投いただきました。ありがとうございます。実は著者の方から本を頂戴するのってはじめてです。

さて、この書籍のタイトルをはじめて見たときは「オッ、ついにISUCONの攻略本が来ましたね、これでワシも優勝間違いなしや!!」と思ったものですが実際に手に取ってみると必ずしもそうではないことに気付きました。むしろ「ISUCONで勝つための小手先のテクニック」のような話題は極力排除されており、高速かつ高可用なWebアプリケーションをどのように構築・運用していくか、というような実戦的な内容がその多くを占めています。

まず書籍の冒頭では「『Webアプリケーションのパフォーマンス』の定義」から始まり、「なぜWebアプリケーションが高速かつ安定して動作する必要があるのか」という目指すべき方向に対する動機付けが成されています。そういったハイパフォーマンスWebアプリケーションの構築・運用を模したコンテストがISUCONであり、「そのISUCONにどのようなアプローチを以って攻略していくか」というところから逆説的に「ハイパフォーマンスWebアプリケーションの構築・運用」を学ぶという筋書になっています。


「パフォーマンスに問題がある際は必ず計測をし、正しくデータを比較し、そこで明かになったボトルネックにのみ対応していく。勘や憶測で直す部分を決めるのは無駄なのでやらない」といったパフォーマンスチューニングの王道を下敷きとし、パフォーマンスを計測するための負荷試験のプランニングと実施方法、負荷に対するモニタリングの手法、その計測したデータをファクトとしたボトルネックの特定、そしてそのボトルネックに対する具体的な対応、といった内容が体系的に書かれています。

この中でもモニタリングの項が象徴的で、「ログのモニタリング」や「エージェントを使ったモニタリング」などといった継続して長期的にモニタリングを行なうための実践的な内容がおさえられており、ぶっちゃけこのへんってISUCONではやらないというか個人的にはISUCON競技中は目視でdstatの内容とかhtopの内容とか見て済ませがちなんですけど、こういった「実務で使える『ちゃんとした』モニタリング」に多くの紙面が割かれているのはISUCONに使えるテクニックの紹介だけを目的としているのではなく、その先にある「実際のWebアプリケーションを良くしていく」という部分をしかりと見据えているように感じました。


また、負荷試験のプランニングや実施については案外体系的・網羅的な情報が巷に無いように感じていたところ、本書籍ではそのへんが上手く言語化されていて非常に参考になりました。一方で、この書籍でもそうだったんですが負荷試験の項目ってアプリケーションの内容とアクセス特性を知った上でホワイトボックス的に作られがちですよね。ただ実際にアプリケーションを運用していると予期しないアクセスパターンがあったり意図せぬパフォーマンスの秘孔みたいなものがあったりするというのがままあると思っていて、そういうのをこう、探索的に負荷試験のプランニングをしていく方法みたいなのってあるんでしょうか? 毎度頭を悩ませております……


あと特筆すべきはデータベースの章で、著者のid:kazeburoさんのDBのパフォーマンス問題に対する試行・思考が文章としてトレースされている大変貴重な内容だと思います。常日頃から my.conf kazeburo 最強 などとGoogleで検索してはkazeburoさんの秘伝のタレをパクってきてカスタムして使っている身としては非常にありがたいものです、いつもお世話になっております。この章は声に出して読みましょう。
それはそうとしてこの章では言及されていあんかったのですが、ISUCONでよくボトルネック爆弾として設置されているさまじい多重JOINだとか、恐るべき量のサブクエリとかってどうやってほどいて (解決して) いますか? ああいうのを見ると本能的に脳が理解を拒んでしまうのですが、やはり気合と根性でやるしかないのでしょうか……あとこの手のやつってDBレベルでなんとか解決するべきなのか、あるいは別の方法 (例えばRedisとか) でやるべきなのか、といった見切りを付けるのもなかなか難しいところですよね。

キャッシュの章については拙スライドを多く引用・参照していただいてありがとうございました。こういった書籍に自分が書いた内容が載っているというのはありがたいというか、面映ゆいというか、なんだか不思議な感情になりますね。スライドはコレです、もう5年前になるのかこれ……:

また、CDNを活用するパターンの話があったり (これもISUCONというよりは実戦寄りの内容という印象)、Linux Kernel・Kernel Parameterの基礎的な内容があったりと内容が多岐に渡っており面白かったです。ISUCONのベンチマークの作り方については惜しげもなく大公開されていたため技術的にベンチマーカーは作れそうな一方で、実際の問題を作るにあたってはどうすれば? (パフォーマンスが終わっている、しかし筋道立てた解決方法があるアプリケーションを故意に作っるには?) という部分には触れられていなかったので、これは続編の書籍に期待したいと思います!




というわけで、ISUCONはゴールではなく、ISUCONで得た知識・経験を実際のアプリケーションに還元し、ワンランク上を行くことこそが真のGOAL……
『達人が教えるWebパフォーマンスチューニング ~ISUCONから学ぶ高速化の実践』はISUCONを題材としながら、実際のWebアプリケーションをどのように高可用なハイパフォーマンスWebアプリケーションとして構築・運用していくかを体系的に解説している大変良い書籍でした。すなわちこれはISUCONで勝つための単なるTips集ではなく、本チャンのWebアプリケーションを地道に良くしていくための知識の集合であり、つまり本チャンのWebアプリケーションを常日頃からハイパフォーマンスにしていればISUCONにも勝てるはず……この本を買って、毎日実践して、ISUCON勝ちましょう!!!

しかしこんな体系的かつ網羅的なISUCONの本があるなんて本当に良い時代ですね、僕もISUCON 3とかの時代にこれを読んでサクセスしたかった……これからはパフォーマンスチューニングに悩んでいる人がいたら恵比須顔でこの本をおすすめしたいと思います。あとぜんぜん知らなかった内容が普通にポロポロあったんで勉強になりました、それがどういう内容か、ここから先は君の目で確かめてくれ!

protoc-gen-java-dynamodb書いた / protoc pluginはじめて書いた

protobufでスキーマを書いて protoc-gen-java-dynamodb に食わせると、そのスキーマJavaの生成コードに「生成されたDynamoDBのエンティティクラスのコード」を良い感じにねじ込んでくれるというprotocプラグインを書きました。

syntax = "proto3";

package com.example.dynamodb;

import "path/to/protoc-gen-java-dynamodb/protos/options.proto";

option java_package = "com.example.dynamodb";
option java_outer_classname = "ExampleEntityProto";

option (net.moznion.protoc.plugin.dynamodb.fileopt).java_dynamodb_table_name = "example-entity-table";

message ExampleEntity {
  string hash_key = 1 [(net.moznion.protoc.plugin.dynamodb.fieldopt).java_dynamodb_hash_key = true];
  int64 range_key = 2 [(net.moznion.protoc.plugin.dynamodb.fieldopt).java_dynamodb_range_key = true];
  bool bool_var = 3;
}

例えばこのようなproto3を書いてプラグインに食わせると、 ExampleEntityProto.ExampleEntity.DynamoDBEntity というinner classとして以下のようなコードが生成されます。

    // protoc-gen-java-dynamodb plugin generated (((
    @com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBTable(tableName = "example-entity-table")
    public static class DynamoDBEntity {
      private String hashKey;

      @com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute()
      @com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBHashKey()
      public String getHashKey() {
        return this.hashKey;
      }

      public void setHashKey(final String v) {
        this.hashKey = v;
      }

      private Long rangeKey;

      @com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute()
      @com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBRangeKey()
      public Long getRangeKey() {
        return this.rangeKey;
      }

      public void setRangeKey(final Long v) {
        this.rangeKey = v;
      }

      private Boolean boolVar;

      @com.amazonaws.services.dynamodbv2.datamodeling.DynamoDBAttribute()
      public Boolean getBoolVar() {
        return this.boolVar;
      }

      public void setBoolVar(final Boolean v) {
        this.boolVar = v;
      }

      public DynamoDBEntity() {}

      public DynamoDBEntity(final String hashKey, final Long rangeKey, final Boolean boolVar) {
        this.hashKey = hashKey;
        this.rangeKey = rangeKey;
        this.boolVar = boolVar;
      }

      public ExampleEntity toExampleEntity() {
        return new ExampleEntity.Builder()
          .setHashKey(this.hashKey)
          .setRangeKey(this.rangeKey)
          .setBoolVar(this.boolVar)
          .build();
      }

    }

    public DynamoDBEntity toDynamoDBEntity() {
      return new DynamoDBEntity(this.getHashKey(), this.getRangeKey(), this.getBoolVar());
    }

    // ))) protoc-gen-java-dynamodb plugin generated

なのでprotobufでdeserializeしたオブジェクトに対して例えば obj.toDynamoDBEntity() としてやると生成された DynamoDBEntityインスタンスに変換ができ、それをDynamoDBMapper等に食わせることが可能です。逆に DynamoDBEntityインスタンスにも toSomethingActualClass() (SomethingActualClass の部分は実際のクラスによって変わります) という変換コードが生えているので、実際のクラスのオブジェクトに変換することも容易です。

くわしい使い方についてはREADMEをご参照ください。


モチベーションとしては、例えばDynamoDB Streamsを使っている時なんかに良くありがちですが「データをインサートするコンポーネント」と「ストリームに乗ってきたデータを処理するコンポーネント」とが別々の言語で記述されていると、それぞれのコンポーネントでテーブル定義を二重に定義しなければならない、みたいな状況になることがあってそれが嫌。それであればprotobufで定義を書いておいてそれを各言語向けにコード生成して使えば良いのではないか、ということで作られたというのがこのプラグインです。

同じような悩みがある方、ぜひご利用ください。




今回protoc pluginを書いたのははじめてで、正直なところまったくのnewbieなので世界観を掴むところからのスタートだったのですが、最初の一歩としては@yuguiさんの記事が大変参考になりました。

qiita.com

この記事で基本的なことを把握しつつ、あとは既存のプラグイン実装 (e.g. https://github.com/Fadelis/protoc-gen-java-optional) を見ながら公式ドキュメントを読んで実装していく……というふうに進めたところなんとか形になりました。

Javaでprotocプラグインを書くときは素手でやると結構泥臭い部分 (例えばJavaのbuiltin typeのハンドリングなど) があるので、そのあたりは既にあるフレームワークを部分的に使うと楽で良いです。

github.com

例えばSalesforceが出しているコレなどを活用すると面倒な部分を自分で手書きしなくて大変便利。

あと犯した勘違いとしては「protocのプラグイン (つまりexecutableなjar) だけを配信すれば使えるから、ライブラリとしてMaven Centralとかにアップロードしなくても良い」というものがあり、確かに自前でprotoのoptionを提供しない場合それは正なのですが、仮に独自のoptionを提供している場合はそのoptionの情報をJavaのコードとして提供する必要があります。従ってMaven Centralなりなんなりでライブラリコードも配信する必要があります。なのでこのプラグインのライブラリもMaven Centralにアップロードされています。

なおGoで似たようなことをしたい時はsrikrsna/protoc-gen-gotagという便利なやつがあって、これはprotoファイルの中にコメントで書いておくと良い感じで生成コード中のGoのカスタムタグに値を埋め込んでくれるというナイスなやつです。
当初はJavaでもこういうことをやろうと意気込んでいたのですが、Javaのprotocのライブラリはprotoファイル中のコメントを読む機能が無かったり *1、Plugin Insertion Point (つまり生成コードを捻じ込める箇所) が限られていたり *2、仮に protoc-gen-gotag と同じアプローチを採ってprotocによって生成されたコードを構文解析してそこに値をブチ込むにしてもJavaだと色々と大変そうだなあ……などなど色々と制限があったので今回書いたプラグインの形に落ち着いた次第です。

一定のインターバル期間中に stdin に何かが来る、あるいは来ない時に任意のコマンドを実行するツール "conk" を書いてた

github.com

これ去年書いたツールなのでいつの話をしておるのかという感じですけれども、一定のインターバルの間に stdin 経由で何かデータが来た時あるいは来なかった時に任意のコマンドを実行するツールを書いていました。

使い方の例としては

$ ./do_something | conk --interval-sec 5 \
  --on-notified-cmd '["echo", "notified"]' \
  --on-not-notified-cmd '["echo", "not notified"]' \
  --on-ticked-cmd '["echo", "ticked"]'

という風にしてあげると、5秒周期で評価が走り、その周期内に do_something からパイプ経由で何かデータを渡された時には echo notified が、何も来なかった時は echo "not notified" が実行され、そして来ようが来まいが echo ticked は毎度実行されるという挙動をするようになっております。
README のビデオを見てもらうとまさにそのような挙動を確認することができます。


たまにこういうのが欲しくなるので書いた、みたいな感じでした。

openapi-generator を使って go のファイルを生成した時に出てくる `var _ context.Context` の謎

openapi-generator を使って go のコード生成をすると、

// Linger please
var (
	_ context.Context
)

という一見不要そうな変数宣言がコード中に出てきます。これはなんなのでしょうか? 「残しておいてね」とは書いてありますが……

TL;DR

たぶんこれ過去のワークアラウンドがそのまま残っている感じだと思われます。まあ特に気にする必要もなさそう……

<追記>
このワークアラウンドの残滓を消すパッチを送ってみた。
github.com

^ 送ってみたところマージされたのでこの謎は過去のものになりつつあります。
</追記>

詳しく見てみる

というわけでコードを掘ってみましょう。リビジョンについては今日 (2022-02-16) 現在の最新である 986446c1d5e7b2c16c667ed18b6c95b2679268f5 から遡っていきます。

なるほど、ここに該当するコードがありますね。blameしていきましょう。

openapi-generator/modules/openapi-generator/src/main/resources/go/api.mustache の一番古いコミットを見てみるとここになるようです:

ここにもこの変数宣言はまだありますね。つまりこれが最古のコミットで、そこに「ある」ってことは「ある」んだよ、ガタガタ言うな、ということでしょうか……
とまあそうではなく、というのもこのファイルは openapi-generator/modules/swagger-codegen/src/main/resources/go/api.mustache から改名されているので一見すると歴史が途絶えてしまっているようですが、これよりも古いコミットはちゃんとあります。更にこのファイルの歴史を辿ってみましょう。
しかし swagger から openapi に名前が変わっているのは歴史を感じますね。

で、掘っていくとこのコミットに行き当たります: 3ed1aa8e79687bed63dfa064de27317273080471

この diff を見ると、件の変数宣言はここが始祖のようです。それはそうとして golang.org/x/net/context が使われていたりしてなんだか懐しいですね。

で、このテンプレートファイルを読んでみると {{#hasAuthMethods}}ctx context.Context, {{/hasAuthMethods}} という条件分岐があるのがわかります。
つまり、この条件が false になるとこの部分が欠落し、そうなった時に import "golang.org/x/net/context" があるとコンパイラに未使用の import があると怒られるので、そのワークアラウンドに無名変数 (_ って無名変数って名前で良いんでしたっけ) として context.Context を変数宣言することで import 未使用コンパイルエラーを回避しているように読みとれます。なるほど〜。 *1


さて翻って最新のコードを再び見てみましょう。

このコードを読むと context.Context は常に使われているように見えるし、わざわざ var _ context.Context を付けておく必要もないように見えます。これパッと見不要そうに思えるんですが、今でも残しておく必要あるんですかね? 後でパッチでも投げてみようかしら……

結論

// Linger please
var (
	_ context.Context
)

はかつてのワークアラウンドの名残 (だと思われる)。

*1:とはいえコミットログにその旨は書いていない。コミットログの issue 番号も恐らく前の repository のものなのでどれと紐付いているかわからない、という感じなのであくまでコードを読んだ上での推察です

API越しでタイムスタンプをやりとりする時のフォーマットをどうするべきか

APIのリクエストにせよレスポンスにせよ、タイムスタンプを利用するというのはよくある話です。
この時、そのタイムスタンプのフォーマットをどうするのが良いのかという話題です。IDLを使って縛るというというのは良い考えだと思いますが、IDLを使うにせよフォーマットについては決めなくてはならないので。

1. 文字列を使う

これあんま良くないと思うんですよね……というのも、とあるAPIを触っている時に「タイムスタンプはRFC3339です」というフィールドがあったんですけれどRFC3339ではないフォーマットで返却されたり受け入れられたりしたのであまり信用ができない……
まあフォーマットが不正というのは極端な例かもしれないですが、仮にフォーマットが不正だと多くの場合 strptime()time.Parse() なんかの時刻文字列のparserが正しく動かず (良いケースだとエラーが上がる、悪いケースだと本来意図しない時刻として扱われる。後者が大きな問題) システムが壊れると思うのでunix timeを使ったほうが良いと考えています。あといまだにstrptime/strftimeの変換指定が満足に覚えられない!

あとタイムスタンプ文字列だと無邪気にタイムゾーン情報が欠落してたりすることがあるんで、それもunix timeを使ったほうが良いと考える一因です。まあちゃんとタイムゾーン情報を付けてくれれば良いんですけど。

別解

ハハハ

2. unix timeを使う

まあこれが穏当なんじゃないですかね、UTCであることも明確だし……と思いきやこういった落し穴が。

これについては2つあると思っていて、時間の解像度 (単位) についてはフィールドに _sec とか _ms のような接尾辞を付けることで明確にするように心がけています。本質的にミスを防げるかというと怪しいですが、無いよりは良いでしょう、という考えです。

もう一つの「整数か小数か」については型として縛るというのが方法じゃないでしょうか……IDLでやるのが良いのでしょうね。

3. TAI64を使う

https://cr.yp.to/libtai/tai64.html

やった、一意なフォーマットだ!!!
とはいえ人間が基本的には読めない、うるう秒の扱いが特殊、など色々ありこれはこれで困りそうですね。

果たしてそうだろうか?

現時点での個人的な見解

  • unix timeを使う
  • 分解能は何らかの方法で明示する
  • IDLを使う

というあたりが現実的な落しどころじゃないでしょうか。他になにか、APIのリクエスト・レスポンスに使えそうな先鋭的な時間表現ってあるんでしょうか?

[追記]


[追記ここまで]

実行中のシェルスクリプトをchattr(1)を使ってimmutableにするというのはどうか

[追記]

実行中のシェルスクリプトをchattr(1)を使ってimmutableにするというのはどうか - その手の平は尻もつかめるさ

調べてみたけどこれが良さそう <a href="https://stackoverflow.com/a/3399850/1921216" target="_blank" rel="noopener nofollow">https://stackoverflow.com/a/3399850/1921216</a>

2022/01/02 17:02
b.hatena.ne.jp
このブックマークコメントで指摘されましたが、immutableにするまでもなくこのラッパースクリプトを噛ませると良さそう。

#!/bin/bash

# usage:
#   sh-run.sh script-you-want-to-run.sh args...

set -ue

file="$1"
script() {
        source "$file"
}

script ${@:2:($#-1)}

[追記ここまで]


sh/bashで実行したシェルスクリプトを実行中に上書きするとシェルスクリプトが再読み込みされ、意図しない挙動をするという現象が昨年末に話題になりました。

bash は、シェルスクリプトの実行中に適時シェルスクリプトを読み込みます。この挙動による副作用を認識できておらず、実行中のスクリプトが存在している状態でスクリプトの上書きによりリリースしてしまったことで、途中から修正したシェルスクリプトの再読み込みが発生し、結果的に未定義の変数を含む find コマンドが実行されてしまいました。
https://www.iimc.kyoto-u.ac.jp/services/comp/pdf/file_loss_insident_20211228.pdf

これは簡単に挙動を確認できます。例えば以下のようなシェルスクリプト script.sh を用意して、

#!/bin/bash

sleep 10
echo "hello"

bash script.sh として実行します。そしてsleepしている10秒間に

#!/bin/bash

sleep 10
echo "yo"

などと書き換えると再読み込みが実施され、sleep終了後にはstdoutに hello ではなく yo が表示されることなります。
今回は単純に「echoする内容」を書き換えただけなので再読み込み後の挙動はわかりやすいものでしたが、しかし大規模にスクリプトを書き換えた際にそれがどのように振る舞うかは予測が難しいものになります。


この実行中の再読み込み挙動は多くの場合望ましくないものだと思います。
というわけで実行中にchattr(1)を利用してimmutableにすることで、ファイル内容の変更や削除を防ぐというのはどうだろうか、という提案です。
例えば、

sudo chattr +i script.sh

としてあげると script.sh はimmutableとなり、ファイルの削除や名前変更、ファイルの書き込みなどが禁止されます。

it cannot be deleted or renamed, no link can be created to this file, most of the file's metadata can not be modified, and the file can not be opened in write mode.
https://man7.org/linux/man-pages/man1/chattr.1.html

すなわち実行したいシェルスクリプトファイルをimmutableにしてあげると、そのファイルに対する変更が禁止されるため意図しない再読み込みも防ぐことが可能となります。安全そうですね。
従って、以下のようなシェルスクリプトを実行するためのラッパースクリプトを噛ませることで「シェルスクリプトに自動的にchattr +iしつつ実行する」というふうにしてやるとオペミスを防げて安全かつ便利なのではないかと思いました。

使い方の例: sh-run.sh script-you-want-to-run.sh args...

chattrでimmutable属性を付与するには実行ユーザーがrootあるいはCAP_LINUX_IMMUTABLE capabilityを有している必要があります。なので上記のラッパーではsudoを付けてchattrを実行しています。


自分はシェスクリプトで書かれた長めのバッチ処理を実行することがしばしばあるので、この運用を試しにやってみようかなと思います。
なお、zsh script.sh などとしてbashあるいはsh以外のシェル処理系で実行する場合はこのような再読み込み挙動は発生しません。zsh等をalternativeとして使うというのも一つの防護策かもしれないですね。

FAQ

ラッパースクリプトが途中で書き換えられたらどうなるの?

このラッパースクリプトにもchattrでimmutable属性を付けておけば良いのでは?

古いaws/aws-lambda-goでAWS Lambdaのcontainer image runtimeを使うと刺さる

具体的に言うと、aws/aws-lambda-go@v1.18.0よりも前のバージョンでAWS Lambdaのcontainer image runtimeを使うとハンドラが呼び出されず、タイムアウトするまで刺さります。

例えば以下のような非常に簡単なLambda Functionをデプロイした時、

package main

import (
	"context"
	"fmt"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

func handler(ctx context.Context, event events.DynamoDBEvent) error {
	fmt.Println("CALLED")
	return nil
}

func main() {
	lambda.Start(handler)
}

古いaws/aws-lambda-goを利用していると、以下のログのように CALLED は表示されずタイムアウトまで実行を引っぱります。つまり handler() が呼ばれることなくタイムアウトをしているという挙動であることがわかります。

START RequestId: ...snip... Version: $LATEST
END RequestId: ...snip...
REPORT RequestId: ...snip...	Duration: 3003.52 ms	Billed Duration: 3000 ms	Memory Size: 128 MB	Max Memory Used: 10 MB	
2021-12-20T02:49:56.683Z ...snip... Task timed out after 3.00 seconds

このライブラリのバージョンをv1.18.0以降のバージョンにする *1 と、しっかりとハンドラが呼ばれて意図通りの動きをします。

START RequestId: ...snip... Version: $LATEST
CALLED
END RequestId: ...snip...
REPORT RequestId: ...snip...	Duration: 9.12 ms	Billed Duration: 924 ms	Memory Size: 128 MB	Max Memory Used: 26 MB	Init Duration: 914.31 ms	

go1.xで既に動いているLambda FunctionをそのままContainerに移植してみたところ、この問題で絶妙にハマってしまいました。Lambdaの実行ログには特にそれらしいエラーメッセージも出てこないし、そもそもハンドラ関数が呼ばれないのが謎すぎた……。


それはそうとしてv1.18.0のリリースノートにはそれらしい変更について言及が無いんですが、どれが該当する変更なんでしょう。これなのでしょうか? https://github.com/aws/aws-lambda-go/pull/298
[追記]
上記の件はやはりこのPull Requestが関連するとのこと https://github.com/aws/aws-lambda-go/pull/298


[追記ここまで]

さておきライブラリのバージョンは上げないと駄目ですね。ヤル気があるので最新まで上げました。以上です。

*1:つまりv1.18.0は大丈夫