cover

Golangによる即刻バックエンドの実践

sorcererxw

背景

ビジネスの変遷に伴い、即刻のバックエンドサービスでは多くの古いコードが蓄積され、メンテナンスコストが高くなっていました。コードのリファクタリングや書き直しは議題に上がっています。Node.jsと比較して、Golangにはいくつかの利点があります。即刻のバックエンドは既にサービス化が進んでおり、他のビジネスでもGoでの実践が行われているため、Goを使って即刻サービスの一部を書き直すことは実現可能な選択肢です。このプロセスを通じて、同じビジネス上での2つの言語の違いを検証し、Go関連のインフラを改善することができます。

改造の成果

現時点で、即刻の推薦フィードやユーザー選別サービスはGo言語で書き直され、すでにオンラインになっています。元のサービスと比べて、新しいバージョンのサービスのコストは大幅に削減されました:

  • インターフェースの応答時間が50%短縮しました
    旧サービスのインターフェース応答時間
    新しいサービスインターフェースの応答時間
  • メモリ使用量が95%削減されました
  • CPU 使用率が90%減少しました

注:上記のパフォーマンスデータは、ユーザー選別サービスを例にしています。これは読み込みが書き込みよりもはるかに多く、タスクが単一のサービスです。リライトの過程で元の実装もある程度最適化されたため、上記のデータは参考としてのみ提供され、GoとNodeの実際のパフォーマンス比較を完全に代表するものではありません。

改造計画

第一歩:サービスの書き換え

外部インターフェースを変更せずに、ビジネスのコアロジックを一から書き直す必要がありました。しかし、書き直しの過程でいくつかの問題に直面しました:

  • 従来のNodeサービスでは、インターフェースの入出力タイプを明示的に宣言していないことが多く、書き換える際には関連するすべてのフィールドを見つける必要がありました。
  • 従来のコードにはほとんど単体テストが含まれていなかったため、書き直し後にはビジネス要件を理解し、単体テストを設計する必要がありました。
  • 旧コードでは any 型が大量に使用されており、可能なすべての型を明確にするためにはかなりの労力が必要です。多くの型はNode.jsではそれほど厳密である必要はありませんが、Go言語に移行すると、その余地はありません。

要するに、リライトは単なる翻訳ではなく、ビジネスを深く理解し、新たなコードセットを再実装する必要があります。

第二段:正確性の検証

多くのサービスに完全な回帰テストがないため、単にユニットテストに依存するだけでは、正確性を保証するには不十分です。

一般的に、読み取り専用のインターフェースは、データの対拍によってインターフェースの正確性を検証することができます。つまり、同じ入力に対する新旧サービスの出力を比較します。小規模なデータセットの場合、ローカルで2つのサービスを起動してテストすることができます。しかし、データ規模が大きくなると、完全にローカルでテストすることは不可能になります。一つの方法は、トラフィックの複製テストです。

サービス間の環境を跨ぐ呼び出しが面倒で性能にも影響するため、メッセージキューを使用してリクエストを非同期で複製し、対応しています。

  • 元のサービスは、応答するたびに、入力と出力をメッセージにパッケージングして、メッセージキューに送信していました。
  • テスト環境下の消費サービスはメッセージを受け取り、入力を新しいバージョンのサービスに再送信します。
  • 新版サービスが応答した後、消費サービスは前後の二回の応答内容を比較し、結果が異なる場合はログを出力します。
  • 最後に、ログをローカルにダウンロードし、テストデータに基づいてコードを一つずつ修正すればよいです。

第三段:グレースケール展開と旧サービスの段階的置換

ビジネスの正確性に自信を持つようになったら、新しいバージョンのサービスを段階的にオンラインにすることができます。サービスの分割のおかげで、上流と下流が感じることなくサービスを置き換えることができ、対応するサービスを新しいコンテナに徐々に置き換えるだけで済みます。

エンジニアリングプラクティス

リポジトリ構造

プロジェクトの構造は Standard Go Project Layout に基づいたモノレポです:

.
├── build: 构建相关文件,可 symbolic link 至外部
├── tools: 项目自定义工具
├── pkg: 共享代码
   ├── util
   └── ...
├── app: 微服务目录
   ├── hello: 示例服务
      ├── cmd
         ├── api
            └── main.go
         ├── cronjob
            └── main.go
         └── consumer
             └── main.go
      ├── internal: 具体业务代码一律放在 internal 内,防止被其他服务引用
         ├── config
         ├── controller
         ├── service
         └── dao
      └── Dockerfile
   ├── user: 大业务拆分多个子服务示例
      ├── internal: 子业务间共享代码
      ├── account:账户服务
         ├── main.go
         └── Dockerfile
      └── profile: 用户主页服务
          ├── main.go
          └── Dockerfile
   └── ...
├── .drone.yml
├── .golangci.yaml
├── go.mod
└── go.sum
  • <code>app</code> ディレクトリには、すべてのサービスコードが含まれており、階層を自由に分割することができます。
  • すべてのサービスで共有されるコードは、ルートディレクトリの <code>pkg</code> 内に配置されています。
  • すべての外部依存関係はルートディレクトリの <code>go.mod</code> で宣言されています。
  • 各サービスまたはサービス群は、internal ディレクトリを通じて、その下の全てのコードを独占し、他のサービスからの参照を避けます。

このモードがもたらす利点:

  • 開発時は単一のコードリポジトリのみを気にする必要があり、開発効率が向上します。
  • すべてのサービスコードは一緒に配置することができ、大きな機能のサービス集合から小さな運営活動サービスまで、合理的な階層構造を通じて、app ディレクトリ下で明確に保守することができます。
  • 公共コードを変更する際には、依存しているすべてのサービスが互換性を保つようにします。互換性がない場合でも、IDEが提供するリファクタリング機能を使えば、簡単に置き換えが可能です。

継続的インテグレーションとビルド

静的チェック

プロジェクトでは golangci-lint を静的解析ツールとして使用しています。コードを push するたびに、Github Action が自動的に golangci-lint を実行し、非常に迅速かつ便利です。エラーが発生した場合は、その警告を直接 PR 上にコメントとして残します。

golangci-lint 自体には lint 戦略が含まれていませんが、様々な linter を統合することで、非常に細かい静的チェックを実現し、潜在的なエラーを未然に防ぐことができます。

テスト+ビルドイメージ

Drone を使用してテストとイメージのビルドを行います。Github Action 上でのビルドも試みましたが、matrix の特性を利用することで monorepo をうまくサポートできます。しかし、イメージのビルドは比較的時間がかかるため、Github Action 上で行うと Github Action のクオータを大量に消費してしまいます。クオータがなくなると通常の開発作業に影響が出るためです。

最終に選んだのはDroneプロジェクトで、Drone Configuration Extensionを使って複雑なビルド戦略もカスタマイズできます。通常、CIシステムのビルド戦略は十分に賢く、どのコードをビルドし、どのコードをテストする必要があるかを自動的に判断できることが望ましいです。開発初期には、私も全く同感で、スクリプトを書いてプロジェクト全体の依存関係トポロジーを分析し、ファイルの変更と組み合わせて、影響を受けるすべてのパッケージを見つけ出し、テストとビルドを実行しました。一見理想的ですが、実際には、公共コードを変更するとほぼすべてのサービスが再ビルドされることになり、まさに悪夢です。この方法はユニットテストには適しているかもしれませんが、パッケージングにはそうではないでしょう。

そこで、私はもっとシンプルで直接的な戦略を選びました。Dockerfile をビルドの目印として使用することです。あるディレクトリにDockerfileが含まれている場合、そのディレクトリは「ビルド可能」とみなされます。そのディレクトリのサブファイルに変更があった場合(新規追加または修正された場合)、そのDockerfileは「ビルド待ち」とされます。Droneは、ビルド待ちの各Dockerfileに対してパイプラインを起動してビルドを行います。

いくつか注目すべき点があります:

  • ビルド時には現在のサービスのコードだけでなく、共有コードもコピーする必要があるため、コンテキストディレクトリをルートディレクトリに設定し、サービスディレクトリを引数として渡してビルドを容易にする必要があります:
    docker build --file app/hello/Dockerfile --build-arg TARGET="./app/hello" .
  • イメージ名はデフォルトで内側から外側へのフォルダ名を連結した形で命名されます。例えば <code>./app/live/gift/Dockerfile</code> をビルドした後、イメージ名は <code>{registry}/gift-live-app:{branch}-{commitId}</code> の形式で生成されます。
  • すべてのビルド(依存関係のダウンロード、コンパイルを含む)はDockerfileによって定義されており、CIメインフローに多くのロジックを導入することで柔軟性を低下させることを避けています。Docker自体のキャッシュメカニズムを利用することで、ビルド速度も非常に速くなります。
  • サービスディレクトリ外の共有コードが変更された場合、Droneは影響を受けるサービスの構築を感知できないという問題があります。解決策としては、gitコミットメッセージに特定のフィールドを追加し、Droneに対応するビルドを実行するように指示します。

コンフィグレーション管理

Node プロジェクトでは、通常 node-config を使用して、異なる環境に対して異なる設定を行います。Go のエコシステムには、同じ作業を直接行うための既成のツールはありませんが、この方法を捨ててみることもできます。

Twelve-Factor 原則 が推奨するように、私たちは複数の異なる設定ファイルではなく、可能な限り環境変数を通じてサービスを設定するべきです。実際に、Node プロジェクトでは、ローカル開発環境を除いて、私たちは環境変数を通じて動的に設定することが多いですし、test.json/beta.json は直接 production.json を参照しています。

私たちは設定を二つの部分に分けました:

  • シングルコンフィグレーションファイル

    私たちはサービス内でファイルを通じて、基本設定として完全な設定を定義し、ローカル開発時にも使用できるようにしています。

  • ダイナミック環境変数

    サービスがオンラインにデプロイされた後、基本設定の上で、環境変数を設定に注入します。

私たちはサービスディレクトリ内にconfig.toml(好きな設定フォーマットを選択してください)を作成し、基本的な設定を記述して、ローカル開発時に使用することができます。

# config.toml
port=3000
sentryDsn="https://[email protected]"

[mongodb]
url="mongodb://localhost:27017"
database="db"

実際の運用環境で実行する際には、設定に環境変数を注入する必要があります。環境変数を設定データ構造に注入するために、Netflix/go-env を使用することができます:

type MongoDBConfig struct {
	URL      string `toml:"url" env:"MONGO_URL,MONGO_URL_ACCOUNT"`
	Database string `toml:"database"`
}

type Config struct {
	Port      int            `toml:"port" env:"PORT,default=3000"`
	SentryDSN string         `toml:"sentryDsn"`
	MongoDB   *MongoDBConfig `toml:"mongodb"`
}

//go:embed config.toml
var configToml string

func ParseConfig() (*Config, error) {
  var cfg Config
	if _, err := toml.Decode(configToml, &cfg); err != nil {
		return nil, err
	}
	if _, err := env.UnmarshalFromEnviron(&cfg); err != nil {
		return nil, err
	}
	return &cfg, nil
}

上記のコードは、最新のGo1.16のembed機能も使用しており、Compiler Directiveを一行追加するだけで任意のファイルを最終的に構築されるバイナリファイルに一緒にパッケージすることができます。ビルドされたイメージは、単一の実行ファイルをコピーするだけで済み、ビルドとリリースの複雑さを低減します。

サービス呼び出し

コード管理

即刻のバックエンドには複数の言語(Node/Java/Go)で書かれたサービスがあり、各サービスで型を重複定義すると人的資源の浪費や統一性の欠如を招くため、ProtoBufで型を定義し、protocを使用して対応するコードを生成し、一つのリポジトリで各言語のクライアントをメンテナンスしています。

.
├── go
│   ├── internal: 内部实现,如 http client 封装
│   ├── service
│   │   ├── user
│   │   │   ├── api.go: 接口定义与实现
│   │   │   ├── api_mock.go: 通过 gomock 生成的接口 mock
│   │   │   └── user.pb.go: 通过 protoc 生成的类型文件
│   │   ├── hello
│   │   └── ...
│   ├── go.mod
│   ├── go.sum
│   └── Makefile
├── java
├── proto
│   ├── user.proto
│   ├── hello.proto
│   └── ...
└── Makefile

各サービスは独立したパッケージを通じてインターフェースを公開しており、各サービスは四つの部分で構成されています:

  • インターフェースの定義
  • インターフェース定義に基づいた具体的な呼び出しコード
  • インターフェースの定義に基づいて gomock がモック実装を生成する
  • プロトコルから型コードを生成する

API 設計

インターフェースの定義は、コード生成を使用しない前提で、オプションのパラメータを通じて、各インターフェースにデグレード、リトライ、タイムアウトなどのオプションを追加します。

result, err := userservice.DefaultClient.IsBetaUser(
  context.Background(), 
  []string{"guoguo"}, 
  option.WithRetries(3),  // 重试三次
  option.WithDowngrade(func() interface{} { return map[string]bool{"guoguo":false} }), // 接口降级
  option.WithTimeout(3*time.Second), // 超时控制,也可以直接使用 context.WithTimeout
)

ProtoBuf

上述の通り、内部インターフェースの接続とメンテナンスコストを削減するため、ProtoBufを使用して型を定義し、Goの型を生成しました。ProtoBufの定義を使用しているにもかかわらず、サービス間では依然としてJSONでデータを伝達しており、データのシリアライズとデシリアライズが問題となっています。

ProtoBuf と JSON の相互変換を簡素化するために、Google は jsonpb というパッケージを提供しています。このパッケージは、従来の string enum と互換性を持たせるために、enum の Name(string) と Value(int32) の変換をネイティブの json パッケージに基づいて実装しています。また、oneof 型もサポートしています。これらの機能は、Go のネイティブ json では実現できません。ネイティブの json を使用して proto 型をシリアライズすると、enum が文字列として出力されなかったり、oneof が完全に出力されなかったりする問題が発生します。

そうなると、コード内のすべてを jsonpb でネイティブの json に置き換えればいいのではないかと思うかもしれませんが、そうではありません。jsonpb は proto 型のシリアライズのみをサポートしています:

func Marshal(w io.Writer, m proto.Message) error 

プロトコルバッファを使用してすべての外部読み書きインターフェースの型を定義しない限り、jsonpb を通じて使用することはできません。

しかし、道は尽きることがない。Go のネイティブな json は二つのインターフェースを定義しています:

// Marshaler is the interface implemented by types that
// can marshal themselves into valid JSON.
type Marshaler interface {
	MarshalJSON() ([]byte, error)
}

// Unmarshaler is the interface implemented by types
// that can unmarshal a JSON description of themselves.
// The input can be assumed to be a valid encoding of
// a JSON value. UnmarshalJSON must copy the JSON data
// if it wishes to retain the data after returning.
//
// By convention, to approximate the behavior of Unmarshal itself,
// Unmarshalers implement UnmarshalJSON([]byte("null")) as a no-op.
type Unmarshaler interface {
	UnmarshalJSON([]byte) error
}

どんな型でも、これら2つのインターフェースを実装していれば、(反)シリアライズ時に自分自身のロジックを操作することができ、フック関数のようなものです。そのため、すべてのproto型に対してこれら2つのインターフェースを実装する必要があります:自分自身をjsonで(反)シリアライズしようとすると、代わりにjsonpbを使用するようになります。

func (msg *Person) MarshalJSON() ([]byte, error) {
	var buf bytes.Buffer
	err := (&jsonpb.Marshaler{
		EnumsAsInts:  false,
		EmitDefaults: false,
		OrigName:     false,
	}).Marshal(&buf, msg)
	return buf.Bytes(), err
}

func (msg *Person) UnmarshalJSON(b []byte) error {
	return (&jsonpb.Unmarshaler{
		AllowUnknownFields: true,
	}).Unmarshal(bytes.NewReader(b), msg)
}

探索の末、最終的に protoc-gen-go-json という protoc プラグインを見つけました。これにより、proto タイプを生成すると同時に、すべてのタイプに対して json.Marshalerjson.Unmarshaler を実装できます。これにより、シリアライズの互換性のために妥協する必要がなくなり、既存のコードにも全く侵入性がありません。

リリース

独立維持されるリポジトリであるため、プロジェクト内で使用するには Go module の形式でインポートする必要があります。Go module の設計のおかげで、バージョンのリリースは Github とシームレスに統合でき、非常に効率的です。

  • テストバージョン

    go mod は、対応するブランチのコードを依存関係として直接フェッチすることをサポートしており、手動でアルファ版をリリースする必要はありません。呼び出し元のコード実行ディレクトリで <code>go get -u github.com/iftechio/jike-sdk/go@{branch}</code> を実行するだけで、対応する開発ブランチの最新バージョンを直接ダウンロードできます。

  • 正式版

    変更がメインブランチにマージされると、Github Releaseを通じて安定版をリリースすることができます(ローカルでgit tagを打つことも可能です)。具体的なバージョン番号を指定してリポジトリのスナップショットを取得するには、次のコマンドを使用します:go get github.com/iftechio/jike-sdk/go@{version}

    go get は本質的にコードをダウンロードすることであり、私たちのコードは Github にホストされているため、中国国内のアリババクラウドでコードをビルドする際に、ネットワークの問題で依存関係の取得に失敗することがあります(private mod は goproxy を通じて取得できません)。そのため、goproxy を改造し、クラスター内部に goproxy をデプロイしました:

    • パブリックリポジトリに対しては、goproxy.cn を通じて取得します。
    • プライベートリポジトリに対しては、プロキシを通じて GitHub から直接フェッチすることができ、また goproxy は GitHub のプライベートリポジトリの認証作業も代行してくれます。

      私たちは、内部の goproxy を通じて依存関係をダウンロードするために、以下のコードを実行するだけで済みます:

      GOPROXY="http://goproxy.infra:8081" \
      GONOSUMDB="github.com/iftechio" \
      go mod download

コンテキスト

Context は、API の境界やプロセス間で、期限、呼び出し元のキャンセル、その他のリクエストスコープの値を伝達する手段を提供します。

Context は Go 言語において非常に特別な存在であり、まるで橋のように全てのビジネスを繋げ、データやシグナルがビジネスの上流と下流の間で伝達できるようにします。私たちのプロジェクトでは、context が数多くの用途に使用されています:

キャンセルシグナル

HTTPリクエストにはそれぞれコンテキストが添付されており、リクエストがタイムアウトしたりクライアント側が接続を積極的に閉じたりした場合、最上位層がキャンセル信号をコンテキストを通じて全体のチェーンに伝えます。下流の呼び出しはすぐに実行を終了します。この規格に沿っていれば、上流がリクエストを閉じると、すべてのサービスが現在の操作をキャンセルし、大量の無駄な消費を削減できます。

開発中には以下の点に注意が必要です:

  • ほとんどのタスクがキャンセルされると同時に、context.ErrCancelledエラーが投げられ、呼び出し元が例外を感知して終了できるようになります。しかし、RPCのサーキットブレーカーはこのエラーをキャッチして失敗として記録します。極端なシナリオでは、クライアントがリクエストを繰り返し発行してすぐにキャンセルすることで、サービスのサーキットブレーカーが次々と作動し、サービスの不安定を引き起こす可能性があります。解決策はサーキットブレーカーを改造し、特定のエラーは引き続き投げるが、失敗として記録しないようにすることです。
  • 分散システムのシナリオでは、ほとんどのデータ書き込みはトランザクションを使用できません。操作が途中でキャンセルされた場合でも、最終的な一貫性が保証されるかどうかを考慮する必要があります。一貫性の要求が高い操作については、実行前にキャンセルシグナルを積極的にブロックする必要があります。
    // 返回一个仅仅实现了 Value 接口的 context
    // 只保留 context 内的数据,但忽略 cancel 信号
    
    func DetachedContext(ctx context.Context) context.Context {
    	return &detachedContext{Context: context.Background(), orig: ctx}
    }
    
    type detachedContext struct {
    	context.Context
    	orig context.Context
    }
    
    func (c *detachedContext) Value(key interface{}) interface{} {
    	return c.orig.Value(key)
    }
    
    func storeUserInfo(ctx context.Context, info interface{}) {
      ctx = DetachedContext(ctx)
    	saveToDB(ctx, info)
      updateCahce(ctx, info)
    }

コンテキスト伝播

リクエストが入るたびに、http request context は traceId やユーザー情報など、現在のリクエストに関する様々な情報を携帯しています。これらのデータは context を通じてビジネスの全プロセスに渡って透過的に伝達され、その間に収集されたモニタリングデータはこれらのデータと関連付けられ、モニタリングデータの集約に便利です。

Context.Value は制御するのではなく、情報を伝えるべきです。

コンテキストを使用してデータを渡す際に最も注意すべき点は、コンテキストのデータは監視用にのみ使用し、ビジネスロジックには使用しないことです。いわゆる「明示的なものが暗示的なものより優れている」という原則に従い、コンテキストは内部データを直接外部に公開しないため、コンテキストを使用してビジネスデータを渡すと、プログラムが非常に不格好になり、テストも困難になります。言い換えれば、どんな関数であっても、たとえ渡されたのが <code>emptyCtx</code> であっても、正確性に影響を与えるべきではありません。

エラー収集

エラーは単なる値です

Goのエラーは一般的な値です(外部から見れば文字列です)。これはエラーの収集にある程度の手間をもたらします。エラーを収集する際には、そのエラーの内容だけでなく、エラーのコンテキスト情報も知る必要があります。

Go1.13 は エラーラップの概念 を導入しました。Wrap/Unwrap の設計により、エラーを単方向リンクリストの構造に変えることができ、各ノードにカスタムのコンテキスト情報を保存できます。また、リストの先頭となるエラーを使用して、後続のすべてのエラーノードを読み取ることが可能です。

個々のエラーにとって、エラーのスタックトレースは最も重要な情報の一つです。Go言語では <code>[runtime.Callers](https://github.com/golang/go/blob/cda8ee095e487951eab5a53a097e2b8f400f237d/src/runtime/extern.go#L199)</code> を使用してスタックトレースを収集します:

呼び出し元は、呼び出し元のゴルーチンのスタック上の関数呼び出しのリターンプログラムカウンタをスライス pc に記入します。

ご覧の通り、Callers は単一の goroutine 内のコールスタックのみを収集することができます。完全な error trace を収集したい場合は、goroutine を跨いでエラーを伝播する際に、スタックトレースをエラー内部に含める必要があります。この時、サードパーティのライブラリ pkg/errorserrors.WithStackerrors.Wrap を使用することで実現できます。これらは新しいエラーノードを作成し、その時点のコールスタックを保存します:

// WithStack annotates err with a stack trace at the point WithStack was called.
// If err is nil, WithStack returns nil.
func WithStack(err error) error {
	if err == nil {
		return nil
	}
	return &withStack{
		err,
		callers(),
	}
}

func main() {
  ch := make(chan error)
  go func() {
    err := doSomething()
	  ch <- errors.withStack(err)    
  }()
  err := <-ch
  fmt.Printf("%w", err)

最終のエラー収集(多くの場合、ルートのwebミドルウェアで行われます)は、Sentryを直接使用することができます:

sentry.CaptureException(err)

Sentryは、errors.Unwrapインターフェースに基づいて、各レイヤーのerrorを取り出します。Sentryは各レイヤーのerrorに対して、自動的にエラースタックをエクスポートできます。stacktraceは正式な標準ではありませんが、Sentryはいくつかの主流のStacktraceソリューションに積極的に対応しています。その中には、pkg/errorsのものも含まれています。

最終的にはSentryのバックエンドで完全なエラー情報を確認できます。以下の図のように、各大きなセクションは一つのエラーレイヤーであり、各セクション内にはそのエラーのコンテキスト情報が含まれています。

参照リンク

TJが語るGoとNodeの生産性の優位性

Standard Go Project Layout

The Twelve-Factor App

Go Wiki - Module: Releaseing Modules (V2 or Higher)

How to correctly use context.Context in Go 1.7

Dave Cheney - エラーをただチェックするだけでなく、上手に処理しましょう

Uber Go コーディング規約