Slide 1

Slide 1 text

golang.tokyo#37 障害再発防止のための 静的解析ツールを実装する

Slide 2

Slide 2 text

About Me Ren Kanai twitter: @rennnosuke_rk GitHub: rennnosuke Back-end Engineer at Pairs

Slide 3

Slide 3 text

Table of Contents 1. 静的解析の概要 2. Go静的解析ツールの実装例 3. 障害事例 4. sqinstmtcheckの実装 5. 静的解析ツールを使ってみて良かった点・苦労した点 6. まとめ

Slide 4

Slide 4 text

静的解析の概要

Slide 5

Slide 5 text

静的解析の概要 静的解析とは ● プログラムを実行せずソースコードを分析すること ● ソフトウェア開発では、Linterやソフトウェアメトリクスの測定などに使用する Goの静的解析 ● Goは静的解析のためのパッケージが標準で提供されている ○ go/ast ○ golang.org/x/tools/go/ast ○ golang.org/x/tools/go/analysis ● Goの言語仕様自体も他の言語と比較してシンプルであり、独自のLinterを作りやすい

Slide 6

Slide 6 text

静的解析の概要 Goの静的解析の大まかなイメージ ● ソースコードを意味のある単語=トークンに分解(字句解析) ● トークンを構文上の関係をもとに整理して、抽象構文木=ASTを作る(構文解析) ● 生成したASTから、コード上の構文がどうなっているのかを調べる ソースコード 抽象構文木(AST) func filterFieldList fields FieldList トークン

Slide 7

Slide 7 text

Go静的解析ツールの実装例

Slide 8

Slide 8 text

Go静的解析ツールの実装例 Analyzer ● Goの静的解析モジュール ● モジュール化することで再利用しやすい形にできる ○ あるAnalyzerの出力を別のAnalyzerの入力にする(Analyzer.Requires) ○ 複数のAnalyzerを1つにまとめる、など ● Analyzer.Run に解析を実行する関数を設定 import ( "golang.org/x/tools/go/analysis" "golang.org/x/tools/go/analysis/passes/inspect" ) var Analyzer = &analysis.Analyzer{ Name: "example", Doc: "example analyzer", Run: run, Requires: []*analysis.Analyzer{inspect.Analyzer}, } func run(pass *analysis.Pass) (any, error) { // ... }

Slide 9

Slide 9 text

Go静的解析ツールの実装例 Inspector ● 抽象構文木(AST)を調べるための構造体 ○ ASTのノード ast.Node を巡回してコード上の構文を検査できる ○ pass.ResultOf で Analyzer.Requires 中のAnalyzerからInspectorを取得可能 ● Inspectorのノード巡回用メソッド(第一引数に巡回したい ast.Node の種類を指定可能) ○ Preorder : 対象ノードを巡回 ○ Nodes : 対象ノードとサブツリーを巡回 ○ WithStack : Nodesと同様、巡回記録を持つスタックへの参照を引数に持つ func run(pass *analysis.Pass) (any, error) { inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) nodeFilter := []ast.Node{ (*ast.FuncDecl)(nil), } inspect.Preorder(nodeFilter, func(n ast.Node) { // ... }) }

Slide 10

Slide 10 text

Go静的解析ツールの実装例 ASTの巡回 ● Inspector.Preorder / Nodes / WithStack でASTを巡回 ○ n ast.Node 具象型やフィールドなど をチェックしつつ、目的の構文を探す ● 警告したい構文を見つけたら pass.Report または pass.Reportf で警告 ○ pass.Report の場合 SuggestedFixes を指定して修正提案することも可能 ○ ReviewDogと組み合わせると GitHub PR上コメントに修正提案を出せる `func run()` を見つけて、関数名`exec`への修正提案を行う例 nodeFilter := []ast.Node{ (*ast.FuncDecl)(nil), } inspect.Preorder(nodeFilter, func(n ast.Node) { // `nodeFilter` で `*ast.FuncDecl` 型であることがわかっている f := n.(*ast.FuncDecl) if f.Name.Name == "run" { pass.Report(analysis.Diagnostic{ Message: "run function", SuggestedFixes: []analysis.SuggestedFix{ { Message: "replace func name: `run` -> `exec`", TextEdits: []analysis.TextEdit{ { Pos: f.Name.Pos(), End: f.Name.End(), NewText: []byte("exec"), }, }, }, }, }) } })

Slide 11

Slide 11 text

Go静的解析ツールの実装例 ASTの巡回 ● Inspector.Preorder / Nodes / WithStack でASTを巡回 ○ n ast.Node 具象型やフィールドなど をチェックしつつ、目的の構文を探す ● 警告したい構文を見つけたら pass.Report または pass.Reportf で警告 ○ pass.Report の場合 SuggestedFixes を指定して修正提案することも可能 ○ ReviewDogと組み合わせると GitHub PR上コメントに修正提案を出せる `func run()` を見つけて、関数名`exec`への修正提案を行う例 nodeFilter := []ast.Node{ (*ast.FuncDecl)(nil), } inspect.Preorder(nodeFilter, func(n ast.Node) { // `nodeFilter` で `*ast.FuncDecl` 型であることがわかっている f := n.(*ast.FuncDecl) if f.Name.Name == "run" { pass.Report(analysis.Diagnostic{ Message: "run function", SuggestedFixes: []analysis.SuggestedFix{ { Message: "replace func name: `run` -> `exec`", TextEdits: []analysis.TextEdit{ { Pos: f.Name.Pos(), End: f.Name.End(), NewText: []byte("exec"), }, }, }, }, }) } })

Slide 12

Slide 12 text

障害事例

Slide 13

Slide 13 text

障害事例 発生事象 Aurora MySQL CPU使用率が上昇し、アプリケーション全体のパフォーマンスが低下 原因 ● クエリ中のIN句の値の数が数万件になるケースが一部発生 ○ 対象カラムはPKであり、殆どのケースで問題なかった ○ 実行計画上も負荷の兆候は見られず ● MySQLオプティマイザがフルスキャンを選択 ○ MySQLのRange Optimizationにより、 IN句の値の数によりオプティマイザの挙動が変化 →リリースをrollbackして早期止血対応 参考: https://dev.mysql.com/doc/refman/5.7/en/range-optimization.html https://developers.freee.co.jp/entry/large-in-clouse-length-cause-full-scan

Slide 14

Slide 14 text

障害事例 恒久対応(予防観点) ● IN句を含むクエリに動的に値を渡すとき、複数回のクエリに分けて実行する(chunk化) ○ IN句を含むクエリがchunk化されていない場合、警告を出す

Slide 15

Slide 15 text

障害事例 恒久対応(予防観点) ● IN句を含むクエリに動的に値を渡すとき、複数回のクエリに分けて実行する(chunk化) ○ IN句を含むクエリがchunk化されていない場合、警告を出す →Linterの作成 Shift Left: より早期に問題を発見し、かかるコストを小さくする 参考: https://www.youtube.com/watch?v=RFa_zSrxDK8

Slide 16

Slide 16 text

sqinstmtcheckの実装

Slide 17

Slide 17 text

sqinstmtcheck IN句を含むクエリがchunk化されていない場合警告を出すLinter $ sqinstmtcheck ./path/to/repo/user /path/to/repo/user/user_repository.go:587:24: SQL execution include `IN` statement must be chunked. Please chunk it with `entities.Step` .

Slide 18

Slide 18 text

sqinstmtcheck chunk化用の関数: entities.Step() ● 引数 ids を limit 件ごとに分割して process() を実行する // ChunkFunc divides slice into sub-slices of length chunkSize and calls fn for each chunk. func ChunkFunc[T any](items []T, chunkSize int, fn func([]T) error) error { for chunk := range slices.Chunk(items, chunkSize) { if err := fn(chunk); err != nil { return err } } return nil } // Step breaks ids into chunks of size limitOpt (or default 1000), // and passes them to the process function. // idsを分割して 1000件(デフォルト )ずつ取得する . func Step[T any](ids []T, process func([]T) error, limitOpt ...int) error { limit := 1000 if len(limitOpt) == 1 { if n := limitOpt[0]; n > 0 { limit = n } } return collection.ChunkFunc(ids, limit, process) }

Slide 19

Slide 19 text

chunk化用の関数: entities.StepCollect() ● 引数 ids を limit 件ごとに分割して process() を実行する ● entities.Step とほぼ同じ、違いはslicesにして返す点のみ sqinstmtcheck // StepCollect breaks ids into chunks of size limitOpt (or default 1000), // and collects the results of the process function into a slice. // idsを分割して 1000件(デフォルト )ずつ取得した結果をスライスに収集 . func StepCollect[ID, Out any](ids []ID, process func([]ID) ([]Out, error), limitOpt ...int) ([]Out, error) { limit := 1000 if len(limitOpt) == 1 { if n := limitOpt[0]; n > 0 { limit = n } } list := make([]Out, 0, len(ids)) for chunk := range slices.Chunk(ids, limit) { res, err := process(chunk) if err != nil { return nil, err } list = append(list, res...) } return slices.Clip(list), nil

Slide 20

Slide 20 text

sqinstmtcheck 前提 ● 弊社ではRDB(Aurora MySQL)との疎通に以下のライブラリを使用 ○ sqlx: database/sqlの拡張ライブラリ ○ squirrel: SQLクエリビルダー ● squirrelクエリビルダーでクエリを記述し、sqlxを使用する関数にクエリビルダーを渡す ○ ※記述を簡略化するため、squirrel のimport alias に sq を指定している func (r *UserRepository) FindActiveIDsByIDs(ctx context.Context, ids []int64) ([]int64, error) { q := sq.Select("`id`"). From(tableName). Where(sq.Eq{"`id`": ids}). // ここを探す Where("(account_status = ?)", pairsconst.AccountStatusActive) return entities.Select[int64](ctx, entities.ReplicaDB(ctx), q) // chunk化されていない }

Slide 21

Slide 21 text

sqinstmtcheck Linterの仕様 ● sqinstmtcheckが警告を出す条件 ○ sq.Eq{”id”: {slices or array}} が含まれる ○ ラッパー関数によるchunk化を実施していない ● ただしIN句指定がリテラルの場合は警告しない(大量に値を指定するケースは殆どないため) ○ 例えば sq.Eq{”status”: []string{”active”, “inactive”}} など func (r *UserRepository) FindActiveIDsByIDs(ctx context.Context, ids []int64) ([]int64, error) { q := sq.Select("`id`"). From(tableName). Where(sq.Eq{"`id`": ids}). // ここを探す Where("(account_status = ?)", pairsconst.AccountStatusActive) return entities.Select[int64](ctx, entities.ReplicaDB(ctx), q) // chunk化されていない }

Slide 22

Slide 22 text

sqinstmtcheck 使用するAnalyzer var Analyzer = &analysis.Analyzer{ Name: "sqinstmtcheck", Doc: "sqinstmtcheck checks SQL include `IN` statement whether the query execution is chunked with `entities.Step` `entities.StepCollect` or not.", Run: run, Requires: []*analysis.Analyzer{inspect.Analyzer}, }

Slide 23

Slide 23 text

sqinstmtcheck run ● Inspectorを取得 ● 今回は *ast.CompositeLit のノードを調べる ○ squirrel の Eq 型は map のDefined typeであるため、 *ast.CompositeLit で表される ○ *ast.CompositeLit : mapのような複合リテラルを表すノードへの参照 func run(pass *analysis.Pass) (any, error) { inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) nodeFilter := []ast.Node{ (*ast.CompositeLit)(nil), } // ... }

Slide 24

Slide 24 text

sqinstmtcheck ノードの巡回 ● Inspector.WithStack を使う ○ 後で巡回履歴を探索 するため ● 巡回不要のファイルは無視 ○ squirrelをimport していないファイル ○ .*_test.go ● return false で WithStack がAST探索を終了 // "github.com/Masterminds/squirrel" をimportするファイル squirrelImported := make(map[string]struct{}) for _, f := range pass.Files { if hasSquirrelImport(f) { squirrelImported[pass.Fset.File(f.Pos()).Name()] = struct{}{} } } // ... inspect.WithStack(nodeFilter, func(n ast.Node, push bool, stack []ast.Node) bool { // "github.com/Masterminds/squirrel" をimportしていない ファイルを 無 視 fName := pass.Fset.File(n.Pos()).Name() if _, ok := squirrelImported[fName]; !ok { return false } // testも無視 if strings.HasSuffix(fName, "_test.go") { return false } // ... }

Slide 25

Slide 25 text

sqinstmtcheck sq.Eqを探す(Inspector.WithStack内) ● ast.Node が sq.Eq に該当する構文かチェックしていく ● return true すると、今見ている ast.Node をスキップしつつAST巡回を継続 // `nodeFilter` 指定より `n` は*ast.CompositeLitで確定 cl := n.(*ast.CompositeLit) // `github.com/Masterminds/squirrel` の `Eq` 構造体初期化を確認 // 1. `xx.yy` の `yy` の部分が `Eq` であることを確認 // *ast.SelectorExpr: {packageName}.{FuncName}や{variableName}.{FieldName}のような.での呼び出しを行なっている構文 se, ok := cl.Type.(*ast.SelectorExpr) if !ok { return true } if se.Sel.Name != "Eq" { return true } // 2. `sq.Eq` の `sq` の部分がimportされている `github.com/Masterminds/squirrel` かチェック idPkg, ok := se.X.(*ast.Ident) if !ok { return true } pn, ok := pass.TypesInfo.ObjectOf(idPkg).(*types.PkgName) if !ok || pn.Imported().Path() != pathSquirrel { // const pathSquirrel = "github.com/Masterminds/squirrel" return true }

Slide 26

Slide 26 text

sqinstmtcheck sq.Eqのエントリをチェック(Inspector.WithStack内) ● sq.Eq を見つけたら、値に配列・スライスを含むエントリがあるかチェック ○ shouldChunkArrayInEqEntry() ○ chunkすべき配列・スライスエントリを見つけたら、pass.Reportf で警告 // `Eq` に渡された entryのvalueが配列・スライスの場合 report for _, elt := range cl.Elts { kve, ok := elt.(*ast.KeyValueExpr) if !ok { continue } if shouldChunkArrayInEqEntry(kve, stack) { key := fmt.Sprintf("%s:%v", fName, kve.Pos()) if _, ok := walked[key]; ok { continue } msg := "SQL execution include `IN` statement must be chunked. Please chunk it with `entities.Step` `entities.StepCollect`." pass.Reportf(kve.Pos(), msg) walked[key] = struct{}{} } } return true

Slide 27

Slide 27 text

sqinstmtcheck shouldChunkArrayInEqEntry() ● isXXXArray() でエントリの値がchunk対象配列・スライス稼働かを調べる ● isChunked() でchunk化されているかどうか調べる ○ stack[1] : ルートノード直下の*ast.CompositeLit ○ sq.Eq を見つけた後、呼び元からchunk化関数が呼ばれるかどうかを調べるため // shouldChunkArrayInEqEntry 引数の `eqEntry` のValueがchunkするべき配列・スライスかどうかを返 す func shouldChunkArrayInEqEntry(eqEntry *ast.KeyValueExpr, stack []ast.Node) bool { // 配列・スライスがリテラルの場合、要素数が少ない場合が多いためチャンク不要 if isLiteralArray(eqEntry) { return false } // 配列・スライスが以下の場合、 chunkが必要(既に chunkされている場合、不要) // - 関数呼び出しの戻り値 // - 引数 // - 関数返り値を格納した変数 if isFuncCallArray(eqEntry) || isFieldArray(eqEntry) || isAssignedFuncCallArray(eqEntry) { return !isChunked(stack[1]) } return false }

Slide 28

Slide 28 text

sqinstmtcheck isLiteralArray() // isLiteralArray 引数の *ast.KeyValueExpr が示すMapEntryの値が // リテラルの配列あるいはスライスかどうかを返す func isLiteralArray(kve *ast.KeyValueExpr) bool { value, ok := kve.Value.(*ast.CompositeLit) if !ok { return false } _, ok = value.Type.(*ast.ArrayType) return ok }

Slide 29

Slide 29 text

sqinstmtcheck isFuncCallArray() // isFuncCallArray 引数の *ast.KeyValueExpr が示すMapEntryの値が関数呼び出しの戻り値であり // かつ配列・スライスかどうかを返す func isFuncCallArray(kve *ast.KeyValueExpr) bool { call, ok := kve.Value.(*ast.CallExpr) if !ok { return false } f, ok := call.Fun.(*ast.Ident) if !ok { return false } fd, ok := f.Obj.Decl.(*ast.FuncDecl) if !ok { return false } return slices.ContainsFunc(fd.Type.Results.List, func(ft *ast.Field) bool { _, ok := ft.Type.(*ast.ArrayType) return ok }) }

Slide 30

Slide 30 text

sqinstmtcheck isFieldArray() // isFieldArray 引数の *ast.KeyValueExpr が示すMapEntryの値が、 // 変数の配列あるいはスライスかどうかを返す func isFieldArray(kve *ast.KeyValueExpr) bool { idValue, ok := kve.Value.(*ast.Ident) if !ok || idValue.Obj == nil { return false } f, ok := idValue.Obj.Decl.(*ast.Field) if !ok { return false } _, ok = f.Type.(*ast.ArrayType) return ok }

Slide 31

Slide 31 text

sqinstmtcheck isAssignedFuncCallArray() // isAssignedFuncCallArray 引数の *ast.KeyValueExpr が示すMapEntryの値が、 // 関数呼び出しの戻り値を格納する変数であり、かつ配列・スライスかどうかを返す func isAssignedFuncCallArray(kve *ast.KeyValueExpr) bool { idValue, ok := kve.Value.(*ast.Ident) if !ok || idValue.Obj == nil { return false } as, ok := idValue.Obj.Decl.(*ast.AssignStmt) if !ok { return false } cs, ok := as.Rhs[0].(*ast.CallExpr) if !ok { return false } f, ok := cs.Fun.(*ast.Ident) if !ok || f.Obj == nil { return false } fd, ok := f.Obj.Decl.(*ast.FuncDecl) if !ok { return false } return slices.ContainsFunc(fd.Type.Results.List, func(ft *ast.Field) bool { _, ok := ft.Type.(*ast.ArrayType) return ok }) }

Slide 32

Slide 32 text

sqinstmtcheck isChunked() ● n ast.Node をRootとしたTreeから chunk化関数呼び出しを探索 ○ entities.Step ○ entities.StepCollect ● ast.Inspect で引数に渡した ast.Node 下のTreeを探索できる // chunkFuncSelectors // isChunked でchunk化関数として扱う関数 のselector var chunkFuncSelectors = map[string]struct{}{ "entities.Step": {}, "entities.StepCollect": {}, } // isChunked // 引数に渡した ast.Nodeを探索し、 chunk化関数呼び出しがあるかどうかを 返す func isChunked(n ast.Node) bool { f, ok := n.(*ast.FuncDecl) if !ok || f == nil { return false } var found bool ast.Inspect(f.Body, func(n ast.Node) bool { call, ok := n.(*ast.CallExpr) if !ok { return true } s := types.ExprString(call.Fun) for k := range chunkFuncSelectors { if strings.HasPrefix(s, k) { found = true return false } } return true }) return found }

Slide 33

Slide 33 text

静的解析ツールを使ってみて 良かった点・苦労した点

Slide 34

Slide 34 text

静的解析ツールを使ってみて良かった点・苦労した点 良かった点 ● sqinstmtcheckの効果 ○ 新しく実装されるIN句クエリに警告を出し、自動検出できる状態になった ○ 既存のINDEXスキャンリスクのあるIN句クエリを洗い出せるようになった ● コードベース上の問題の予防にはLinterが有効 ● 警告だけならコードベースの変更が発生しないので影響範囲をコントロールしやすい 苦労した点 ● 対象のコードや適用するルールが複雑になるほど解析しづらい ○ 開発・修正コストが大きい、修正提案も難しくなる

Slide 35

Slide 35 text

まとめ

Slide 36

Slide 36 text

まとめ ● Goの静的解析を使用したLinter開発例を紹介 ○ コードベース上の問題に対するガードレールとしておすすめ ● 今後の展望 ○ SuggestedFixesのサポート ○ Linter警告箇所修正をmerge blockerにするなどして強制性を持たせる (運用上問題なければ)

Slide 37

Slide 37 text

ご清聴ありがとうございました

Slide 38

Slide 38 text

参考 ● 14. 静的解析とコード生成 - Google スライド ● MySQL :: MySQL 5.7 Reference Manual :: 8.2.1.2 Range Optimization ● MySQLでIN句の中に大量の値の入ったクエリがフルスキャンを起こす話 - freee Developers Hub ● GopherCon 2021: Akhil Indurti - Writing a Static Analyzer for Go Code - YouTube ● https://github.com/Masterminds/squirrel ● https://github.com/jmoiron/sqlx ● https://github.com/reviewdog/reviewdog ● https://yuroyoro.github.io/goast-viewer/

Slide 39

Slide 39 text

TIPS

Slide 40

Slide 40 text

TIPS デバッグ用のAST ● 自分が探している構文がどのノードTypeに該当するか調べながら開発できる ○ gotype -ast ○ go/parser ○ GoAst Viewer ■ https://yuroyoro.github.io/goast-viewer/

Slide 41

Slide 41 text

TIPS ReviewDog ● GitHubなどコードホスティングサービス上でレビューコメントを投稿できるツール ○ https://github.com/reviewdog/reviewdog ● 弊社では独自のLinterやgolangci-lintの結果をReviewDogに流し、 GitHubのPRレビューコメントとして投稿させている

Slide 42

Slide 42 text

No content