🐾

GoアプリにDatadog APMトレースを自動挿入した話

に公開

はじめに

DatadogのAPM(Application Performance Monitoring)機能を使い、APIの処理時間や依存関係をリアルタイムで可視化できるようにしました。

ただし、トレースのためには、各メソッドごとにコードを埋め込む必要があります。

例えば以下のような感じです。

func (ur UserRepository) FindByID(ctx context.Context, id string) (domain.User, error) {
	span, ctx = tracer.StartSpanFromContext(ctx, "repositories.UserRepository.FindByID")
	defer span.Finish()

	// 処理は省略

	return user, nil
}

このようなコードを毎回手動で追加するのは、非常に手間がかかりますし、開発者に大きな負担を強いることになります。

この課題を解決するために、トレースコードの自動生成に取り組みました。

トレースコードの自動挿入を可能にする設計

考えたのがDecoratorパターンの活用です。

このパターンを使い、コード生成ツールにより、各関数をラップする構造体を生成し、依存解決時にその構造体を適用することで、既存のコードに手を加えることなくトレースコードを挿入することが可能です。


期待する生成コードの例

type TraceUserRepository struct {
	original IUserRepository
}

func NewTraceUserRepository(o IUserRepository) IUserRepository {
	return &TraceUserRepository{
		original: o,
	}
}

func (ur *TraceUserRepository) FindByID(ctx context.Context, id string) (domain.User, error) {
	environment := os.Getenv("ENVIRONMENT")
	if environment != "local" && environment != "test" {
		var span tracer.Span
		span, ctx = tracer.StartSpanFromContext(ctx, "repositories.UserRepository.FindByID")
		defer span.Finish()
	}

	return ur.original.FindByID(ctx, id) // 元のメソッドをそのまま呼び出す
}

このようなコードが生成されるツールを開発し、既存のコードに手を加えずにトレースされることを目指すことにしました。

スパン名は、パッケージ.構造体.メソッド形式としています。
また、不要なトレースを避けるため、localやtest環境ではトレースをスキップする設計としています。

ローカルで生成するか、継続的デリバリ(CD)で生成するか

自動生成コードの仕組みを導入する上で、開発者に負担がかからないことを重視し、設計しました。

そのため、CDフローによりコードが生成し、トレース用のDecoratorに置き換わることを目指しました。

これにより開発者はローカル実行時には、コードの生成をせずとも従来通りに動作ができ、全く意識をすることがない予定でした。


しかし、SREチームにこの考えを共有したとき、別の考えを持っていることに気づきました。

結果として、運用環境に配置するコードは必ずローカルで動作を確認されるべきであり、CDフロー内で生成されるべきではない、という方針に決定しました。

DI(依存性注入)をDecoratorに置き換える

依存を注入するときに生成したデコレーター(トレース用の構造体)に置き換え、各メソッドがトレースされるようにする必要があります。

今回は依存の置き換えを楽にするために、uber-go/dig のDecorateメソッドを利用して、依存注入を置き換えることにしました。


依存性解決の例

container := dig.New()
container.Provide(NewUserRepository)
container.Decorate(NewTraceUserRepository)


uber-go/dig については、こちらの記事でも紹介しています。

https://zenn.dev/edash_tech_blog/articles/85551e0aff68dc

トレースされるための条件

これまでに記載した設計をまとめると、各メソッドがトレースされるためにはいくつかの条件を満たす必要があります。

戻り値が抽象型であること

DIされる構造体が抽象ではなく、実装の場合、置き換えることができないため、コード生成時に戻り値が抽象でない場合には、対象外として処理をスキップしています。

引数に context.Context が含まれていること

Datadogのトレーシングでは、context にトレース情報が埋め込まれており、下記のような形式で新たなスパンを開始します。

そのため対象のメソッドが context.Context を引数として受け取っている必要があります。

span, ctx = tracer.StartSpanFromContext(ctx, "repositories.UserRepository.FindByID")

ただし、contextはアプリケーション全体で継続的に伝播されているため、この条件は問題ないものとして判断します。

構造体のコンストラクタがuber-go/digの依存関係に含まれること

uber-go/dig のDecorateメソッドを用いてProvideされたインスタンスをラップする想定のため、Provideに登録された構造体を基準にコードを生成する必要があります。

コードの生成ツールが、uber-go/dig に依存してしまうものの、他のDIライブラリへの切り替えも可能なため、こちらも問題ないものと判断します。

コード生成ツールの概要

コード生成ツールの中身に関しては、概要のみを説明します。

  1. uber-go/digで使用している依存関係を取得し、処理を繰り返す
  2. メソッドの戻り値を確認
  3. もし、抽象でない場合、スキップ
  4. 戻り値の構造体に関するメソッド情報を収集
  5. トレース用のコード生成
    1. トレース用の構造体とそのコンストラクタ、構造体に紐づくメソッドのラッパー関数を生成
    2. もし、構造体に紐づくメソッド生成時に、引数にcontext.Context型が含まれていない場合、トレースコードを含まない、ただのラッパー関数として生成
  6. 生成したコンストラクタをまとめたメソッドを生成

生成コードを無視して処理を実行する

もし、コード生成ツールに何らかの問題が生じた場合でも、切り離して実行できるようにします。
下記はGoコードのテンプレートです。


// Code generated by the generator. DO NOT EDIT.

// Intentionally omitting the `//go:build !datadogtrace` build tag

package {{.PackageName}}

var instance TraceContainer

type traceContainer struct {
	dependencies []any
}

type TraceContainer interface {
	Dependencies() []any
	SetDependencies([]any)
}

func NewTraceContainer() TraceContainer {
	if instance == nil {
		instance = &traceContainer{}
	}
	return instance
}

func (t *traceContainer) SetDependencies(d []any) {
	t.dependencies = d
}

func (t *traceContainer) Dependencies() []any {
	return t.dependencies
}


TraceContainerをシングルトンパターンで用意し、追跡したい依存関係を取得、設定するメソッドを用意します。


// Code generated by the generator. DO NOT EDIT.
//go:build !datadogtrace

package {{.PackageName}}

import (
{{- range .Imports }}
	{{.}}
{{- end }}
)

func init() {
	tc := NewTraceContainer()
	dependencies := []any{
		{{- range .Dependencies }}
		{{.}},
		{{- end }}
	}
	tc.SetDependencies(dependencies)
}


init関数はPackage初期化時に実行されるため、必要な依存関係が自動で設定されるようになります。

こちらには//go:build !datadogtraceというビルドタグを設定しています。

そのため、go run -tags datadogtrace main.goという形で実行をすれば、Dependenciesメソッドでは空が返るようになります。


Dependenciesメソッドの呼び出し元では下記のように実装をしています。

Provideメソッドを通じて登録した後に、Decorateメソッドにトレース用のコンストラクタ関数を登録します。

func BuildContainer() (*dig.Container, error) {
	container := dig.New()

	deps := Dependencies()
	for _, dep := range deps {
		if err := container.Provide(dep); err != nil {
			return nil, err
		}
	}

	// 自動生成されたトレース付きの抽象実装に差し替える
	traceContainer := NewTraceContainer()
	traceDeps := traceContainer.Dependencies()

	for _, dep := range traceDeps {
		if err := container.Decorate(dep); err != nil {
			return nil, err
		}
	}

	return container, nil
}

go run -tags datadogtrace main.goと実行することで、従来通りの(トレースコードを含まない)実行が可能になり、万一問題が発生した際に、問題の切り分けが容易になります。

// 通常実行
go run main.go

// トレース機能を無効化(トラブルシューティング用途)
go run -tags datadogtrace main.go

継続的インテグレーション(CI)で生成差分を検知する

ローカルで生成したものをコミットするという方針のため、ワークフロー内でのコード生成は行わず、差分がある場合にCIをエラーにするよう対応します。

- name: Run datadog trace code generation
  run: |
    [コード生成処理を入れる]

- name: Check diff
  run: |
    if [ -n "$(git status --porcelain)" ]; then
      echo "Datadog trace code differences were found. Please ensure all related code is properly generated."
      exit 1
    fi

差分比較時に、git diffを使用していないのは、未追跡のファイルの差分もチェックを行うためです。

なお、生成される依存関係の順序はツール内部で制御されており、出力が毎回安定するよう設計しています。
このため、git status による差分チェックにおいても、順序の揺れによるノイズが発生しないよう配慮しています。

別リポジトリへの分離

作成したツールは特定のプロジェクトに依存した形では無く、複数のプロジェクトに導入する必要があります。

そのため、別リポジトリに生成コードを格納します。

格納後、https://github.com/[組織名]/[リポジトリ名]/releases にアクセスし、セマンティックバージョンでバージョンを公開します。


生成ツールを参照するプロジェクト側では、このリポジトリを参照する必要がありますが、Privateリポジトリである場合には、下記の手順を行う必要があります。

  1. Privateリポジトリに格納されたパッケージを参照するには、GOPRIVATE環境変数を設定する必要があります。
    (下記の例では*にしているため、組織に所属する全てのPrivateリポジトリを読み取れるようになります)
export GOPRIVATE="github.com/[組織名]/*"
  1. パッケージを取得します。
go get github.com/[組織名]/[リポジトリ名]@1.0.0

課題

このコード生成ツールは、CLIツールとして提供したいですが、現在はuber-go/digの依存関係に含まれるコンストラクタ関数のリストを取得して、それを元に処理をしたいです。

そのため、ツールを利用したいプロジェクトの各リポジトリ上にツール用のモジュールを作成し、プログラム上で引数として渡すような形としています。

[おまけ] 継続的デリバリ(CD)で生成する場合

ツールの障害時の安全性確保

ローカルで生成する場合には、生成ツールの異常は事前に検知されますが、ワークフロー実行時に生成する場合は、それが直接インシデントに繋がる恐れがあります。

コード生成で問題が発生した場合には、go vetコマンドにより検知し、問題がある場合には生成コードを削除し、問題を通知することを目指します。

生成コードは、datadogtraceフォルダに配置し、.gen.goという拡張子をつけています。

そのため、下記のようにして生成コードに構文エラーがないかチェックします。

find . -type f -path "*/datadogtrace/*.gen.go" -exec dirname {} \; | sort -u | xargs go vet 2>&1 || true

この処理をCDフローに組み込みます。

弊社ではGitHub Actionsを利用しているため、その例を示します。

- name: Run datadog trace code generation
  run: |
    [コード生成処理を入れる]

- name: Run go vet
  id: go-vet
  run: |
    REPORT=$(find . -type f -path "*/datadogtrace/*.gen.go" -exec dirname {} \; | sort -u | xargs go vet 2>&1 || true)
    echo "::set-output name=report::$REPORT"

- name: Handle go vet report
  if: steps.go-vet.outputs.report != ''
  run: |
    [生成コード削除処理を入れる]

- name: Slack Notification on Failure of Datadog Trace Code Generation
  uses: rtCamp/action-slack-notify@v2
  if: steps.go-vet.outputs.report != ''
  env:
    [envを設定する]

このようにしてデプロイ時にトレース用の処理を生成し、それが問題がある場合は、従来の内容でデプロイすることができます。

また、生成処理に問題が発生している場合、それを検知するためにSlackに通知しています。

Discussion