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

GoのProtocプラグインを活用した効率的な負荷試験戦略 / Efficient Load Testing Strategies Utilizing the Go Protoc Plugin

Shota Iwami
December 01, 2023

GoのProtocプラグインを活用した効率的な負荷試験戦略 / Efficient Load Testing Strategies Utilizing the Go Protoc Plugin

Go Conference mini 2023 in KYOTO で発表しました。
https://kyotogo.connpass.com/event/285351/

Shota Iwami

December 01, 2023
Tweet

More Decks by Shota Iwami

Other Decks in Technology

Transcript

  1. あらゆるスキーマ定義に proto を採 用 • gRPC • RBAC • grpc-cli

    • validation gRPC • model struct • validation • query builder • validation Model Con fi g
  2. あらゆるスキーマ定義に proto を採 用 • スキーマ定義からコードを 生 成するための Protoc Plugin

    を 色 々書いている • できるだけあらゆる情報を proto fi le に集約す るようにしている
  3. モチベーション • 新規開発で全APIの単体負荷試験が必要 •全てのAPIのシナリオを書いていくのはめんどくさい • せっかく gRPC を使 用 しているなら

    proto を使 用 したい •基本的にシナリオ作成に必要な情報のほとんがすでに proto に記述されている •情報を分散させたくない •YAML や JSON、Javascript などに負荷試験に関するロジックが存在するようになってしまうとそっち のお世話をしないといけない… •開発の過程で proto fi le に変更が 入 ることも… •負荷試験ツールを疎結合にしたい •負荷試験のツール(k 6 , locust, ghz etc … )などを変えられるようにしたい •負荷試験ツールに関しての知識が必要な 人 を最低限(plugin書く 人 )にしたい
  4. z service SampleService { // Coupon rpcs. rpc ListCoupons(ListCouponsRequest) returns

    (ListCouponsResponse) {} // Scenario options. option (ext.scenario_executor) = { scenarios : [ { constant_arrival_rate : { rate : 100, time_unit : "1s", duration : "300s", pre_allocated_vus : 75, } }, { constant_arrival_rate : { rate : 200, time_unit : "1s", duration : "300s", pre_allocated_vus : 150, } } ] }; } z // Code generated by protoc-gen-go-grpc-scenario. DO NOT EDIT. import grpc from 'k6/net/grpc'; import { check } from 'k6'; … export const options …= {… scenarios: { contacts: { executor: 'constant-arrival-rate', rate: 100, timeUnit: '1s', duration: '300s', preAllocatedVUs: 75, } }, }; … export function listCoupons(token, req) { client.connect(baseURL, { plaintext: plaintext }); const params = { metadata: { "Authorization": 'Bearer ' + token, }, }; let response = client.invoke("sampleservice.SampleService/ListCoupons", req, params); check(response, { 'status is OK': (r) => r && r.status === grpc.StatusOK, }); client.close(); } … export default function () { let req = randomItem(reqs); let token = randomItem(users).AccessToken; listCoupons(token, req); } 生 成イメージ service.proto list_coupons__constant_arrival_rate__rate_ 1 00 _time_unit_ 1 s_duration_ 3 0 0 s_pre_allocated_vus_ 75 .pb.js
  5. z service SampleService { // Coupon rpcs. rpc ListCoupons(ListCouponsRequest) returns

    (ListCouponsResponse) {} // Scenario options. option (ext.scenario_executor) = { scenarios : [ { constant_arrival_rate : { rate : 100, time_unit : "1s", duration : "300s", pre_allocated_vus : 75, } }, { constant_arrival_rate : { rate : 200, time_unit : "1s", duration : "300s", pre_allocated_vus : 150, } } ] }; } 生 成イメージ service.proto gRPC 用 のrpc定義 シナリオ 用 の Options • 負荷シナリオなどを記述 • service に対するextend
  6. 実 行 シナリオとファイルは1:1 • シナリオのパラメータなどは実 行 時にChatOps的に 入 れるやり 方

    もある • 結果を参照する際、コマンドも含めて追わないといけない • 実 行 時に指定したファイルを 見 にいけばどのようなシナリオを実 行 したかわ かるようにしたい シナリオのバリエーションだけファイルを 生 成する
  7. 結果 • Commit Hash + ファイル名で実際に実 行 したファイルの確認を Github にLink

    で 飛 んで確認できる • 実施したシナリオについてのファイル名 から分かる(詳細は Github 見 にいけば わかる)
  8. protoc-gen-star(PG*) • Lyftによって開発されたPDK (plugin development kit) • protoc plugin を実装する際の処理を簡略化して準

    軟性を持たせられる • Protocol Bu ff ers の AST を 生 成して使 用 できる • UNSTABLEと記載されているが、先 日 stable に なった protoc-gen-validate でも使 用 されている • (実はvalidateの 方 も元々lyftが開発してた) https://github.com/lyft/protoc-gen-star https://buf.build/blog/protoc-gen-validate-v 1 -and-v 2 /
  9. package main import ( pgs "github.com/lyft/protoc-gen-star/v2" pgsgo "github.com/lyft/protoc-gen-star/v2/lang/go" "google.golang.org/protobuf/types/pluginpb" )

    func main() { optional := uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) pgs.Init( pgs.DebugEnv("DEBUG"), pgs.SupportedFeatures(&optional), ).RegisterModule( NewModule(), ).RegisterPostProcessor( pgsgo.GoFmt(), ).Render() } main
  10. package main import ( pgs "github.com/lyft/protoc-gen-star/v2" pgsgo "github.com/lyft/protoc-gen-star/v2/lang/go" "google.golang.org/protobuf/types/pluginpb" )

    func main() { optional := uint64(pluginpb.CodeGeneratorResponse_FEATURE_PROTO3_OPTIONAL) pgs.Init( pgs.DebugEnv("DEBUG"), pgs.SupportedFeatures(&optional), ).RegisterModule( NewModule(), ).RegisterPostProcessor( pgsgo.GoFmt(), ).Render() } main ੜ੒ͨ͠Goͷίʔυʹformatter͔͚ΒΕΔ ʢࠓճ͸JSͷίʔυΛੜ੒ͯ͠ΔͷͰ࢖༻ͯ͠ͳ͍ʣ moduleΛొ࿥͢Δ
  11. // Module describes the interface for a domain-specific code generation

    module // that can be registered with the PG* generator. type Module interface { // The Name of the Module, used when establishing the build context and used // as the base prefix for all debugger output. Name() string // InitContext is called on a Module with a pre-configured BuildContext that // should be stored and used by the Module. InitContext(c BuildContext) // Execute is called on the module with the target Files as well as all // loaded Packages from the gatherer. The module should return a slice of // Artifacts that it would like to be generated. Execute(targets map[string]File, packages map[string]Package) []Artifact } Module
  12. // Module describes the interface for a domain-specific code generation

    module // that can be registered with the PG* generator. type Module interface { // The Name of the Module, used when establishing the build context and used // as the base prefix for all debugger output. Name() string // InitContext is called on a Module with a pre-configured BuildContext that // should be stored and used by the Module. InitContext(c BuildContext) // Execute is called on the module with the target Files as well as all // loaded Packages from the gatherer. The module should return a slice of // Artifacts that it would like to be generated. Execute(targets map[string]File, packages map[string]Package) []Artifact } Module Pluginͱͯ͠ͷੜ੒ϩδοΫ ContextͷॳظԽ protoc-gen-goͷcontextͱ͔΋ೖΕΒΕΔ Pluginͷ໊લ
  13. package main import ( pgs "github.com/lyft/protoc-gen-star/v2" pgsgo "github.com/lyft/protoc-gen-star/v2/lang/go" ) type

    Module struct { *pgs.ModuleBase ctx pgsgo.Context } func NewModule() *Module { return &Module{ ModuleBase: &pgs.ModuleBase{}, } } func (m *Module) InitContext(c pgs.BuildContext) { m.ModuleBase.InitContext(c) m.ctx = pgsgo.InitContext(c.Parameters()) } func (m *Module) Name() string { return "grpc-scenario" } Module
  14. package main import ( pgs "github.com/lyft/protoc-gen-star/v2" pgsgo "github.com/lyft/protoc-gen-star/v2/lang/go" ) type

    Module struct { *pgs.ModuleBase ctx pgsgo.Context } func NewModule() *Module { return &Module{ ModuleBase: &pgs.ModuleBase{}, } } func (m *Module) InitContext(c pgs.BuildContext) { m.ModuleBase.InitContext(c) m.ctx = pgsgo.InitContext(c.Parameters()) } func (m *Module) Name() string { return "grpc-scenario" } Module protoc-gen-go༻ͷcontext
  15. //go:embed scenario.tpl var scenarioTemplate string var scenarioTpl = template.Must(template.New(“scenario"). Parse(scenarioTemplate))

    func renderScenario(params TemplateParams) (string, error) { buf := bytes.Buffer{} if err := scenarioTpl.Execute(&buf, params); err != nil { return "", err } return buf.String(), nil } type TemplateParams struct { Package string Service string ProtoFilePaths []string etc... } Template
  16. //go:embed scenario.tpl var scenarioTemplate string ɹ var scenarioTpl = template.Must(template.New(“scenario").

    Parse(scenarioTemplate)) ɹ func renderScenario(params TemplateParams) (string, error) { buf := bytes.Buffer{} if err := scenarioTpl.Execute(&buf, params); err != nil { return "", err } ɹ return buf.String(), nil } type TemplateParams struct { Package string Service string ProtoFilePaths []string etc... } Template TemplateͷಡΈࠐΈ TemplateͰ࢖͍͍ͨύϥϝʔλͷઃఆ
  17. func (m *Module) Execute(targets map[string]pgs.File, pkgs map[string]pgs.Package) []pgs.Artifact { for

    _, t := range targets { m.genFile(t) } return m.Artifacts() } func (m *Module) genFile(f pgs.File) { m.Push(f.Name().String()) defer m.Pop() // template ʹ৘ใΛೖΕΔ var params TemplateParams out, err := renderScenario(params) m.CheckErr(err, "unable to render template") m.AddGeneratorFile(fmt.Sprintf("%s/scenario.pb.js", params.Package), out) } Execute
  18. func (m *Module) Execute(targets map[string]pgs.File, pkgs map[string]pgs.Package) []pgs.Artifact { for

    _, t := range targets { m.genFile(t) } return m.Artifacts() } func (m *Module) genFile(f pgs.File) { m.Push(f.Name().String()) defer m.Pop() // template ʹ৘ใΛೖΕΔ var params TemplateParams out, err := renderScenario(params) m.CheckErr(err, "unable to render template") m.AddGeneratorFile(fmt.Sprintf("%s/scenario.pb.js", params.Package), out) } Execute ParameterͱTemplate͔Β ϑΝΠϧग़ྗ಺༰Λ࡞੒ ੜ੒͢ΔϑΝΠϧͱͯ͠ొ࿥
  19. func (m *Module) Execute(targets map[string]pgs.File, pkgs map[string]pgs.Package) []pgs.Artifact { for

    _, t := range targets { m.genFile(t) } return m.Artifacts() } func (m *Module) genFile(f pgs.File) { m.Push(f.Name().String()) defer m.Pop() // template ʹ৘ใΛೖΕΔ var params TemplateParams out, err := renderScenario(params) m.CheckErr(err, "unable to render template") m.AddGeneratorFile(fmt.Sprintf("%s/scenario.pb.js", params.Package), out) } Execute ͜Ε
  20. z // File describes the contents of a single proto

    file. type File interface { ParentEntity … // Services returns the services from this proto file. Services() []Service … } type ParentEntity interface { Entity // Messages returns the top-level messages from this entity. Nested // messages are not included. Messages() []Message // AllMessages returns all the top-level and nested messages from this Entity. AllMessages() []Message // MapEntries returns the MapEntry message types contained within this // Entity. These messages are not returned by the Messages or AllMessages // methods. Map Entry messages are typically not exposed to the end user. MapEntries() []Message // Enums returns the top-level enums from this entity. Nested enums // are not included. Enums() []Enum // AllEnums returns all top-level and nested enums from this entity. AllEnums() []Enum // DefinedExtensions returns all Extensions defined on this entity. DefinedExtensions() []Extension … } PG* AST • PG* は Protocol Bu ff ers のASTを 生 成 して Module で使えるようにしている • Plugin を書く際はこの Module を利 用 すれば簡単に書くことができる • 依存関係を解決してくれる
  21. func (m *Module) genFile(f pgs.File) { … protoServides := f.Services()

    methods := protoServides[0].Methods() protoMsgs := f.AllMessages() fields := protoMsgs[0].Fields() field := fields[0] filedName := field.Name() filedNameString := filedName.String() filedNameUppserCamelCase := filedName.UpperCamelCase().String() if ftype := field.Type().IsEmbed(); ftype.IsEmbed() { for _, f := range ftype.Embed().Fields() { … } } ... } パラメータ取得例
  22. // AST encapsulates the entirety of the input CodeGeneratorRequest from

    protoc, // parsed to build the Entity graph used by PG*. type AST interface { // Targets returns a map of the files specified in the protoc execution. For // all Entities contained in these files, BuildTarget will return true. Targets() map[string]File // Packages returns all the imported packages (including those for the target // Files). This is limited to just the files that were imported by target // protos, either directly or transitively. Packages() map[string]Package // Lookup allows getting an Entity from the graph by its fully-qualified name // (FQN). The FQN uses dot notation of the form ".{package}.{entity}", or the // input path for Files. Lookup(name string) (Entity, bool) } AST
  23. func (wf *standardWorkflow) Init(g *Generator) AST { wf.Generator = g

    wf.Debug("reading input") data, err := ioutil.ReadAll(g.in) wf.CheckErr(err, "reading input") wf.Debug("parsing input proto") req := new(plugin_go.CodeGeneratorRequest) err = proto.Unmarshal(data, req) wf.CheckErr(err, "parsing input proto") wf.Assert(len(req.FileToGenerate) > 0, "no files to generate") wf.Debug("parsing command-line params") wf.params = ParseParameters(req.GetParameter()) for _, pm := range wf.paramMutators { pm(wf.params) } //… return ProcessCodeGeneratorRequest(g, req) } Init type Generator struct { Debugger persister persister workflow workflow mods []Module in io.Reader // protoc input reader out io.Writer // protoc output writer debug bool params Parameters }
  24. func ProcessCodeGeneratorRequest(debug Debugger, req *plugin_go.CodeGeneratorRequest) AST { g := &graph{

    d: debug, targets: make(map[string]File, len(req.GetFileToGenerate())), packages: make(map[string]Package), entities: make(map[string]Entity), extensions: []Extension{}, } for _, f := range req.GetFileToGenerate() { g.targets[f] = nil } for _, f := range req.GetProtoFile() { pkg := g.hydratePackage(f) pkg.addFile(g.hydrateFile(pkg, f)) } for _, e := range g.extensions { e.addType(g.hydrateFieldType(e)) extendee := g.mustSeen(e.Descriptor().GetExtendee()).(Message) e.setExtendee(extendee) if extendee != nil { extendee.addExtension(e) } } return g } Generate AST
  25. func ProcessCodeGeneratorRequest(debug Debugger, req *plugin_go.CodeGeneratorRequest) AST { g := &graph{

    d: debug, targets: make(map[string]File, len(req.GetFileToGenerate())), packages: make(map[string]Package), entities: make(map[string]Entity), extensions: []Extension{}, } for _, f := range req.GetFileToGenerate() { g.targets[f] = nil } for _, f := range req.GetProtoFile() { pkg := g.hydratePackage(f) pkg.addFile(g.hydrateFile(pkg, f)) } for _, e := range g.extensions { e.addType(g.hydrateFieldType(e)) extendee := g.mustSeen(e.Descriptor().GetExtendee()).(Message) e.setExtendee(extendee) if extendee != nil { extendee.addExtension(e) } } return g } Generate AST
  26. func ProcessCodeGeneratorRequest(debug Debugger, req *plugin_go.CodeGeneratorRequest) AST { g := &graph{

    d: debug, targets: make(map[string]File, len(req.GetFileToGenerate())), packages: make(map[string]Package), entities: make(map[string]Entity), extensions: []Extension{}, } for _, f := range req.GetFileToGenerate() { g.targets[f] = nil } for _, f := range req.GetProtoFile() { pkg := g.hydratePackage(f) pkg.addFile(g.hydrateFile(pkg, f)) } for _, e := range g.extensions { e.addType(g.hydrateFieldType(e)) extendee := g.mustSeen(e.Descriptor().GetExtendee()).(Message) e.setExtendee(extendee) if extendee != nil { extendee.addExtension(e) } } return g } Generate AST func (g *graph) hydratePackage(f *descriptor.FileDescriptorProto) Package { lookup := f.GetPackage() if pkg, exists := g.packages[lookup]; exists { return pkg } p := &pkg{fd: f} g.packages[lookup] = p return p }
  27. func ProcessCodeGeneratorRequest(debug Debugger, req *plugin_go.CodeGeneratorRequest) AST { g := &graph{

    d: debug, targets: make(map[string]File, len(req.GetFileToGenerate())), packages: make(map[string]Package), entities: make(map[string]Entity), extensions: []Extension{}, } for _, f := range req.GetFileToGenerate() { g.targets[f] = nil } for _, f := range req.GetProtoFile() { pkg := g.hydratePackage(f) pkg.addFile(g.hydrateFile(pkg, f)) } for _, e := range g.extensions { e.addType(g.hydrateFieldType(e)) extendee := g.mustSeen(e.Descriptor().GetExtendee()).(Message) e.setExtendee(extendee) if extendee != nil { extendee.addExtension(e) } } return g } Generate AST func (g *graph) hydrateFile(pkg Package, f *descriptor.FileDescriptorProto) File { fl := &file{ pkg: pkg, desc: f, } if pkg := f.GetPackage(); pkg != "" { fl.fqn = "." + pkg } else { fl.fqn = "" } g.add(fl) for _, dep := range f.GetDependency() { // the AST is built in topological order so a file's dependencies are always hydrated first d := g.mustSeen(dep).(File) fl.addFileDependency(d) d.addDependent(fl) } if _, fl.buildTarget = g.targets[f.GetName()]; fl.buildTarget { g.targets[f.GetName()] = fl } enums := f.GetEnumType() fl.enums = make([]Enum, 0, len(enums)) for _, e := range enums { fl.addEnum(g.hydrateEnum(fl, e)) } exts := f.GetExtension() fl.defExts = make([]Extension, 0, len(exts)) for _, ext := range exts { e := g.hydrateExtension(fl, ext) fl.addDefExtension(e) } msgs := f.GetMessageType() fl.msgs = make([]Message, 0, len(f.GetMessageType())) for _, msg := range msgs { fl.addMessage(g.hydrateMessage(fl, msg)) } srvs := f.GetService() fl.srvs = make([]Service, 0, len(srvs)) for _, sd := range srvs { fl.addService(g.hydrateService(fl, sd)) } for _, m := range fl.AllMessages() { for _, me := range m.MapEntries() { for _, fld := range me.Fields() { fld.addType(g.hydrateFieldType(fld)) } } for _, fld := range m.Fields() { fld.addType(g.hydrateFieldType(fld)) } } g.hydrateSourceCodeInfo(fl, f) return fl } 気合い
  28. func ProcessCodeGeneratorRequest(debug Debugger, req *plugin_go.CodeGeneratorRequest) AST { g := &graph{

    d: debug, targets: make(map[string]File, len(req.GetFileToGenerate())), packages: make(map[string]Package), entities: make(map[string]Entity), extensions: []Extension{}, } for _, f := range req.GetFileToGenerate() { g.targets[f] = nil } for _, f := range req.GetProtoFile() { pkg := g.hydratePackage(f) pkg.addFile(g.hydrateFile(pkg, f)) } for _, e := range g.extensions { e.addType(g.hydrateFieldType(e)) extendee := g.mustSeen(e.Descriptor().GetExtendee()).(Message) e.setExtendee(extendee) if extendee != nil { extendee.addExtension(e) } } return g } Generate AST type Package interface { Node // The name of the proto package. ProtoName() Name // All the files loaded for this Package Files() []File addFile(f File) setComments(c string) } func (p *pkg) addFile(f File) { f.setPackage(p) p.files = append(p.files, f) }
  29. まとめ • Protoc plugin はサクッと実装できる • Proto fi le に全ての情報を集約すると

    Plugin で柔軟に拡張できる • 負荷試験シナリオも宣 言 的(?)に管理されている 方 が確認しやすい • protoc-gen-star はASTの 生 成をやってくれて使いやすい