Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

Ren Kanai
November 20, 2024
530

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

golang.tokyo#37 登壇資料です。

Ren Kanai

November 20, 2024
Tweet

Transcript

  1. Table of Contents 1. 静的解析の概要 2. Go静的解析ツールの実装例 3. 障害事例 4.

    sqinstmtcheckの実装 5. 静的解析ツールを使ってみて良かった点・苦労した点 6. まとめ
  2. 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) { // ... }
  3. 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) { // ... }) }
  4. 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"), }, }, }, }, }) } })
  5. 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"), }, }, }, }, }) } })
  6. 障害事例 発生事象 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
  7. 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) }
  8. 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
  9. 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化されていない }
  10. 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化されていない }
  11. 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}, }
  12. 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), } // ... }
  13. 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 } // ... }
  14. 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 }
  15. 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
  16. 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 }
  17. 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 }
  18. 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 }) }
  19. 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 }
  20. 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 }) }
  21. 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 }
  22. 静的解析ツールを使ってみて良かった点・苦労した点 良かった点 • sqinstmtcheckの効果 ◦ 新しく実装されるIN句クエリに警告を出し、自動検出できる状態になった ◦ 既存のINDEXスキャンリスクのあるIN句クエリを洗い出せるようになった • コードベース上の問題の予防にはLinterが有効

    • 警告だけならコードベースの変更が発生しないので影響範囲をコントロールしやすい 苦労した点 • 対象のコードや適用するルールが複雑になるほど解析しづらい ◦ 開発・修正コストが大きい、修正提案も難しくなる
  23. 参考 • 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/