ISUCON8予選は学生枠6位で本戦出場が決まりました
この記事はこちらに移動しました。
ISUCON8の予選に、メルカリで一緒だった@zaq1tomoと@inatonixとzin-gonic
というチーム名で出場しました。
結果は学生枠6位だったので、やったこととかまとめます。
事前準備
- 二度のisucon合宿を行った(どちらも環境構築で頓挫)
- 何回か集まってisucon7の過去問練習
- ishoconという株式会社scoutyさん主催のコンテストで練習した
- hidakkathonという株式会社会社サイバーエージェントさん主催のコンテストで練習した
- 前日に当日と同じ要領でhidakkathonの問題をみんなでやった
とまあ初出場ということもあり結構ちゃんと準備して、何も言い訳できない状況だったのでとりあえず通過できてよかったです
やったこと
チューニング対象は、イベントの座席予約アプリでした。
言語は、3人の技術スタック的にGo一択でした。
@nakabonne: インフラ担当
@zaq1tomo: アプリ担当
@inatonix: アプリ担当
序盤
@nakabonne
- ssh公開鍵登録
- dotfileをclone
- ベンチ走らせてhtopでリソース確認
- リスタートスクリプトの準備
- スロークエリログを仕込む
- 再起動に備え、自動起動設定をする
- プロファイルングツールalpを仕込む
- ログローテートスクリプトを仕込む
- alpに渡すためにh2oのアクセスログをLTSV形式にする
リバースプロキシは完全にnginxのつもりでいたので、h2oアクセスログをalpがパースできる形式で出力するのに結構時間かかってしまって、大迷惑かけてしまいました。
本戦では爆速でやりたいです。
ちなみに今回はnginxに差し替えてる方が結構いたので、インフラ担当としてその辺のトレードオフも見積もれるようになりたいです。
@inatonix, @zaq1tomo
方針決め
リソース
弱者の戦い方として、無理に複数台構成はしないと最初から決めていたため、ゴリゴリインメモリキャッシュを使おうと話していました。
実行時にCPU使用率が100%に張り付くので、なるべくredis等の別プロセスは立ち上げたくないと感じたので、キャッシュするならGoプロセス管轄内のメモリに乗せようとなりました。
ただ、mariadbが大分CPU食っているのでdbだけ別インスタンスに移したい気持ちはありました。
スロークエリ
reservationsテーブルから全座席ごとにselectしてるクエリが37万回呼ばれていて、これを解消しない限り次に進まないことがすぐに分かりました。
今回はボトルネックが明白だったため、学生でも迷わずに進める良問でした。
アクセスログ
一番上の /api/users/:id
で、先程の重いクエリが流れていたため、平均9秒かかっていました。その他レスポンスタイム上位のエンドポイントはほとんど同じクエリがボトルになっていました。
中盤
@nakabonne
- 二人の修正が競合しないようにひたすらデプロイ
reservation
テーブルのuser_id
とかにindex張ってみる → 意味なし- h2oでロードバランシングしてみる → 出来なかった
reservations
テーブルにはevent_id
とcanceled_at IS NULL
にマルチカラムインデックス張るのが正解だったっぽいです。
単体テーブルへのクエリならまだいいのですが、内部結合となると実行計画が全く理解できず死亡しました。
@inatonix, @zaq1tomo
- それぞれ別のアプローチでgetEventsクエリのN+1解消に取り組む
- @inatonix: Goプロセス内インメモリキャッシュ
- @zaq1tomo: クエリ変えてクエリ数減らす
僕のアクセスログ解析が遅れたせいもあり、全くスコアが上がらず完全にお通夜モードになっていた15時頃(だったかな)に、@zaq1timoのN+1修正が火を吹き1000点→10000点と10倍になりました。
/api/users/:id
の平均レスポンスタイムも9秒→1秒まで解消していました。
終盤
@zaq1tomoが合計料金をSUMするクエリをチューニングしてくれてちょっとスコアが上がり、諸々のログを吐かないようにしたら13000点を超えるようになりました。
ただ、ランダムにfailしたりしてハラハラしていました。
競技30分前に再起動し最終的に14000点を超えたところでコードフリーズし、お片付けを始めました。
感想
1392名参加している大会で、障害等を何も起こさなかった運営の方々がかっこよすぎました。
今回は各チームごとにベンチマークを用意してくれた(構成的にするしかなかった)Conohaさんにも感謝の念が溢れます。
本戦勝つぞー
Go × Clean Architectureのサンプル実装
Click here for English version
*追記:Student Goで発表しました。
クリーンアーキテクチャとは
以下を実現することで、関心の分離をするアーキテクチャパターンです。
詳しくは様々な記事で説明されているので、今エントリでは割愛し実装パターンに絞って紹介します。
サンプルアプリケーション
↓サンプルコード
仕様は、/users
にPOSTすることでユーザー登録するだけのapiです。
基本はmanuelkiessling/go-cleanarchitectureやhirotakan/go-cleanarchitecture-sampleを参考にしていますがこれらはDBアクセスに直接sqlドライバーを使用しているため、今回はORMを使用した実装パターンを作ってみました。ORMライブラリはgormを使用しています。
app設計概要
何層に分けるかは自由なのですが、閉鎖性共通の原則(CCP)に則って分けます。つまり、同じ理由で変更するコンポーネントはまとめます。
今回は原文に従って4層に分けました。
この時、外側から内側に向かって単一方向に依存することを徹底します。
ディレクトリ構成
├── adapter │ ├── controllers │ ├── gateway │ └── interfaces ├── domain ├── external │ └── mysql ├── main.go └── usecase └── interfaces
各ディレクトリは、それぞれ以下の層の役割をしています。
ディレクトリ | 層 |
---|---|
external | frameworks & drivers |
adapter | interface adapters |
usecase | app business rules |
domain | enterprise business rules |
依存関係の徹底(重要)
実装の説明に入る前に、大事なルールを一つ説明します。
前述したとおり、依存関係は外側から内側へ単一方向へ保つ必要があります。
しかしながらどんなプログラムにも入力と出力があり、内側で処理した結果を外側へ出力するということが頻繁に起こります。
つまり、内側から外側へ依存したい場面が必ず現れます。その矛盾を依存関係逆転の原則を用いて解決することが、クリーンアーキテクチャをクリーンに保つための鍵となります。
依存関係逆転の原則(DIP)とは
簡単に言うと、内側は外側に依存するのではなく、抽象に依存するべきであるという原則です。
よくわからないと思うので、実際にコードを見てみましょう。
func (i *UserInteractor) Add(u domain.User) (int, error) { return i.UserRepository.Store(u) }
これは、内から2番目の層、app business rules層
の実装です。
この層の外側から渡されたユーザデータを、外側にあるDBに保存しようとしています。
ここでやってしまいがちなのが、そのまま外側の層にアクセスしてしまうことです。
しかし外側に直接アクセスしてしまうと依存関係が内→外に向いてしまうので避けたいところです。
そこで依存関係逆転の原則を使います。Goではこれをinterface
を定義することで実現します。
type UserRepository interface { Store(domain.User) (int, error) FindByName(string) ([]domain.User, error) FindAll() ([]domain.User, error) }
同じ層にrepositoryインターフェースを定義し、このインターフェースに依存するようにします。
そして具象は外側で定義しておき、実行時に外側から渡してあげることで外→内の依存関係を保つことができます。
これが依存関係逆転の原則です。
ここを見ると分かりやすいかと思います。
サンプルコードでは、抽象に依存するためのinterface
を各層のinterfacesディレクトリにまとめてあります。
各層の実装
内側の2層はプロジェクトによって結構変わってくると思うので、ここでは外側の2層に絞って説明していきます。
Frameworks & Drivers
DBアクセス
以下のようなDB接続等の外部との仲介実装はこの層で完結するようにします。
var db *gorm.DB func Connect() *gorm.DB { var err error db, err = gorm.Open("mysql", "root:@tcp(db:3306)/hoge") if err != nil { panic(err) } db.Table("users").CreateTable(&gateway.User{}) return db } func CloseConn() { db.Close() }
ルーティング
http通信処理もこの層で完結します。今回はWAFにginを使用していますが、この層が独立しているので差し替えが容易になっています。
また、DBのコネクションやlogger等の具象型もここで内側の層に渡すようにしています。
こうすることで、前述した依存関係逆転の原則を実現しています。
var Router *gin.Engine func init() { router := gin.Default() logger := &Logger{} conn := mysql.Connect() userController := controllers.NewUserController(conn, logger) router.POST("/users", func(c *gin.Context) { userController.Create(c) }) Router = router }
Interface Adapters
ORMのマッパーもここで定義します。
ここでは、DB用に最適化された型をドメインロジック用に最適化された型に変換することで、インピーダンス・ミスマッチを解決することに徹しています。
adapter層は抽象に依存しているとはいえ、この層で定義するinterface
は外側のライブラリにある程度依存してしまいます。
原則では、
「抽象」は実装の詳細に依存してはならない
とされていますが、この層は変換が目的なので、ある程度どちらのことも知っているのが自然かと思います。
ここに関してもっと良い実装パターンをご存知の方がいらしたらご教授願いたいです。
type ( UserRepository struct { Conn *gorm.DB } User struct { gorm.Model Name string `gorm:"size:20;not null"` Email string `gorm:"size:100;not null"` Age int `gorm:"type:smallint"` } ) func (r *UserRepository) Store(u domain.User) (id int, err error) { user := &User{ Name: u.Name, Email: u.Email, } if err = r.Conn.Create(user).Error; err != nil { return } return int(user.ID), nil }
まとめ
見ての通り、user登録するだけのapiでもこんな大きなプロジェクトになってしまいます。
抽象化することで関心を分離することができますが、そこまでして分離する必要があるかはよく考える必要がありそうです。
基本的に抽象化するとコードは複雑化し、直感的ではなくなるので、アプリケーションの規模が小さい場合は効力を発揮しない場合が多いと思います。
何か間違いがあればご指摘頂けると大変助かります。
参考
csvをGoの構造体にマッピングする
Goでcsvを扱う際は、標準パッケージのendording/csvで対応できますが、いちいちスライスを扱うのは少しつらいものがあります。
大抵の場合構造体にマッピングした方が扱いやすいため、その方法を紹介していこうと思います。
csvマッピングライブラリはjszwec/csvutilやgocarina/gocsvがありますが、今回は前者を使っていきます。
mapperの定義
User struct { ID int `csv:"id"` Name string `csv:"name"` }
以下のuser.csv
を読み込みます。
id, name 1, nakabonne 2, ryo nakao
Unmarshal
var users []User // バイト列を読み込む b, _ := ioutil.ReadFile("user.csv") // ユーザー定義型スライスにマッピング _ := csvutil.Unmarshal(b, &users)
Marshal
// ユーザー定義型スライスの作成 users := []User{ {ID: 1, Name: "nakabonne"}, {ID: 2, Name: "ryo nakao"}, } // バイト列に変換 b, err := csvutil.Marshal(users)
【Go】エディタ操作用簡易ライブラリを作った
はじめに
しっかりエディタでファイルオープンするとなると意外に煩雑になることに気づきました。 そこでedindというライブラリに切り出したので、今回はその紹介をしたいと思います。
使い方
クイックスタート
Factoryを生成し、エンドユーザーのシェルで実行可能なエディタを検出します。
import "github.com/nakabonne/edind" // Factory生成 f := edind.NewEditorFactory() // 実行可能なエディターを検出 editor, _ := f.DetectEditor() // pathを渡してファイルを開く _ = editor.Open("sample.txt")
選択肢追加
エディタ検出時の選択肢を追加したい場合、以下のように AddChoices
メソッドを使用します。
f := edind.NewEditorFactory() f.AddChoices( []string{"vi"}, []string{"oni", "-w"}, )
5分でElasticsearch+Kibanaの環境を作る
動機
とりあえずローカルで一刻も早く動かしたい場合にサクッと構築する手順をまとめます。
手順
1, Dockerのインストール
2, dokcer-compose.ymlを作成し、以下をコピペする
version: "3.0" services: elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:5.1.1 environment: - "ES_JAVA_OPTS=-Xms512m -Xmx512m" - xpack.graph.enabled=false - xpack.monitoring.enabled=false - xpack.security.enabled=false - xpack.watcher.enabled=false ports: - "9200:9200" - "9300:9300" volumes: - es-data:/usr/share/elasticsearch/data kibana: image: docker.elastic.co/kibana/kibana:5.1.1 ports: - "5601:5601" links: - elasticsearch:elasticsearch volumes: es-data: driver: local
3, docker-compose.ymlが存在するディレクトリでdocker-compose up
する(お使いのdocker-composeバージョンによってはyamlファイル内のversionを変更する必要があります)
4, http://localhost:5601
にブラウザでアクセスし、kibanaの画面にアクセス
補足
xpackというパッケージの機能を全てオフにすることで、認証なしでリクエストを送ることが出来るようになります。
なので、サクッとローカルで試したい場合はこれらを外すことでスムーズに行なえます。
xpackとは何でしょうか。公式ガイドには以下のように書かれています。
X-Packは、セキュリティ、アラート、モニタリング、レポート、グラフの機能をインストールしやすい1つのパッケージにまとめたElastic Stackの拡張機能です。
GAE/Goからelasticsearchへ接続する
クライアントライブラリの選定
以上の2つが候補としてあげられます。どちらかというとGoらしく書ける印象だった2つ目のolivere/elasticを今回は使用します。
クライアントの生成
olivere/elasticは使用しているelasticsearchのバージョンごとにライブラリのブランチも切られています。
今回はelasticsearchバージョン5を使用しているので、gopkg.in/olivere/elastic.v5をimportして使用します。
import ( elastic "gopkg.in/olivere/elastic.v5" "google.golang.org/appengine/urlfetch" ) const ESURL = "https://35.174.182" func main(){ c := context.Background httpCli := urlfetch.Client(c) // URL Fetch Serviceの使用 cli, err := elastic.NewClient(elastic.SetURL(ESURL), elastic.SetHttpClient(httpCli)) // clientの生成 if err != nil { panic(err) } }
NewClient(options ...ClientOptionFunc) (*Client, error)を呼び出すことによってクライアントを生成します。
ClientOptionFunc型の値を引数に与えることによってクライアントの設定を行います。
URL Fetch Service
GAE/Goから外部にリクエストを送る場合は、URL Fetch Serviceを使用します。
そのため、NewClient時にHTTPクライアントをurlfetch.Clientに差し替える必要があります。
操作
先程生成したクライアントを使用してelasticsearchのデータを操作していきます。
詳しくはwikiを参照して下さい。
func main(){ cli.Start() defer cli.Stop() // ドキュメントの追加 _, err := cli.Index(). Index("index"). Type("type"). Id("id"). BodyJson(struct{}). Do(c) // ドキュメントの削除 _, err := cli.Delete(). Index("index"). Type("type"). Id("id"). Do(c) }
Feature Flagを用いたA/B TestingツールをGCPで設計する
先日、AbemaTVが開催する「AbemaTV x A/B Testing」にてFeature Flagを用いたA/B Testingツールのインフラアーキテクチャ設計を行いました。
その時の学びをまとめていきます。
要件
Feature Flagを用いてA/Bテストできれば良いのですが、以下は守る必要がありました。
- Uiの表示速度に影響しない
- データに対して柔軟に素早く集計・分析できるようにする
- スケーラブル
アーキテクチャ構成図
僕達の最終的なアウトプットはこのようになりました。
色々書いてありますが、大事なのは以下の2つです。
- 構成図下部のclientアプリがフォアグラウンドに切り替わったタイミングでfeature flagを受け取る。
- test対象のclientは定期的にaction logをlogging service(構成図右下)にpostする。
Feature Flag取得時のキャッシュ戦略
上図の通り、とりあえずUIを表示し非同期で取得し次回起動時に反映するという方式をとりました。
こうすることで、「UIの表示速度に影響しない」という要件を満たします。
しかしデメリットもあります。
それはFeature Flag取得までにラグがあることです。
すぐにFlagを切り替えたい時に、二回のアプリ起動が必要になります。
そのためアプローチとして以下の措置をとりました。
- プッシュ通知でアップデートフラグをローカルに立てる
- クライアントが次回アクティブになった時に強制的に反映(ロードが生じる)
わざわざローカルにフラグを立てるのは、プッシュ通知時に一斉にアップデートするというモデルはスケーラブルではないからです。
スケーラブル戦略
最も瞬間的にアクセスが集中する部分はflag取得前のtestユーザーチェックだと考えました。
(↓緑で囲われてる部分)
そこで、MySQLの前段にRedisを置くことを決断しました。
set型の集合にuser_idをkeyにして、testユーザーチェック等の処理に必要な情報を置いておきます。
こうすることで、O(1)でのreadが可能になります。
また、Redis Clusterを使用することで複数ノードに自動的に分散することができます。
永続化とクラスタリングにはデータ更新時に整合性の問題がありますが、今回はtest終了時まで更新はないので問題ありません。
各micro serviceの仕事
lab receive service(構成図左下)
- Labからセグメント情報もらう
- ユーザーがアンケート等で直接入力した値と比較し、いい感じに挿入
logging service(右下)
- clientから生のaction logを受け取る
- redisからuserが参加中のtest情報を受け取り、BigQueryに挿入
aggregate service(右上)
- バッチでBigQueryに対してクエリを実行
- 管理画面からの呼び出しによって人為的にクエリ実行
cache service(左上)
- テスト実行者が設定した情報をもとに、前段に置く値を決定する
フィードバック
構成図右部のlog集計部分をLambda Architectureに従って設計するべきだとのフィードバックを受けました。
Lambda Architecture
Lambda Architectureとは、以下3つのレイヤーから構成される設計指針です。
バッチレイヤー:ログをバッチで集計するので正確だけど、リアルタイムではない
サービスレイヤー:バッチレイヤーの集計結果を提供する
スピードレイヤー:データはリアルタイムだが、データは少し不正確の可能性ある
スピードレイヤーの集計で得た最新の値とバッチレイヤーの集計によって得た確定した値をマージすることで、素早く推定することが出来るというモデルらしいです。
まだあまり理解していないので、詳しくは以下書籍を読んでみます。
まとめ
無知は罪だということを痛感しました。引き続きインプットの質を高めていこうと思います。