エキスパートたちのGo言語

コンテキスト

なぜコンテキストが必要なのか?

トランザクション処理など複数の並行処理で値や処理が失敗したことを共有するために必要

並行処理の数が少ない場合は、チャネルを利用して自分で実装することもできるが、並行処理で行うごルーチンの数が多い場合、コンテキストを使うと簡単に共有できる。

Context インターフェース

type Context interface {
	Deadline() (deadline time.Time, ok bool) // コンテキストが自動でキャンセルされる時刻と時刻を設定しているかどうかのブール値
	Done() <-chan struct{} // キャンセルされているかどうか
	Err() error // キャンセルされた理由
	Value(key interface{}) interface{}
}

コンテキストを使うときのルール

  • コンテキストは構造体に入れず、関数の引数として利用すべき
  • 第一引数にして、変数名はctxにすべき
  • コンテキストに保存する値はリクエストスコープに収まる値にすべき

キャンセル可能なコンテキスト

  1. リソース解放
    キャンセル処理付きのコンテキストを生成した場合、リソース解放をすべき。
    defer cancel()
    (全ての処理が正常終了すると、キャンセル処理が呼ばれないため)

  2. 並行処理でのエラーハンドリング
    コンテキストを使った並行処理でエラーハンドリングする際に、実装が難しくなる。
    errgroupパッケージのWithContext関数を使うと簡易化
    WithContext関数で生成されたゴルーチンのうち、一つでもエラーが発生した場合にキャンセルが実行されてcontext.Doneのチャネルを受け取れる。

ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout)
defer cancel()
eg, ctx := errgroup.WithContext(ctx)
for _, size := range times {
	eg.Go(func() error {
		// do something
	})
}
  1. WithDeadline / WithTimeout
    子コンテキストが持つデッドライン時刻は、親コンテキストが持つデッドライン時刻を超えないように制御。
    親コンテキストのデッドライン時刻に達すると子コンテキストも同時にキャンセルされる。

WithValue

  1. 子コンテキストから親コンテキストを辿って値を探す
    同じキーで保存した場合、一番近いコンテキストの値になる

  2. キーの型 Tips
    キーは、「新しく名前をつけた型を宣言すると、呼び出すパッケージで同じ規定型かつ同じ値を持っていてもイコールにならない」ことを利用すると、他のパッケージによって値が勝手に更新されることを防ぐことができます。

package external

type requestIDKey struct{}

func GetRequestID(ctx context.Context) (int, bool) {
	r, ok := ctx.Value(requestIDKet{}).(int)
	if ok {
		return r, true
	}
	return 0, false
}

func WithRequestID(ctx context.Context, reqID int) context.Context {
	return context.WithValue(ctx, requestIDKey{}, reqID)
}

ポインタ

ポインタと実体の使い分け

GC の観点

メモリの読み書きを行う場所は、スタックとヒープに分かれる。

  • スタック
    メモリの使い方や使用量がコンパイル時に決定できる場合に用いられ、関数呼び出し時に確保され、関数から抜けるときに解放される。
  • ヒープ
    メモリの使い方や使用量が実行時にしかわからない場合に用いる。

ガベージコレクタ(GC)を用いてヒープのメモリを管理し、しかるべきタイミングで解放しているが、Go では GC 時に STW(Stop The World)が発生するものを採用しているので、GC が動いている間はプログラムの実行が止まってしまう。
そのため、パフォーマンスの観点でスタックかヒープにメモリを確保するかは重要。

  • 実体
    スタック上に確保
  • ポインタ
    ヒープ or スタックのどちらかに確保。コンパイル時に変数を作った時点から利用しなくなるまで追跡可能であれば、スタックに確保するように最適化してくれる(エスケープ解析)。
    エスケープ解析の結果はgo build -gcflags "-m"でビルドすると見ることができ、スタックとヒープのどちらに確保されるかがわかる。

コピーコストの観点

特別な理由がない場合はレシーバはポインタとして定義したほうが良い。

ミュータブルとイミュータブルの観点

他の場所から値を書きかえらないように(イミュータブル)したい場合、実体で定義する。

type Time struct {
	wall uint64
	ext int64
	loc *Location
}

func (t Time) Unix() int64 {
	return t.unixSec()
}

unsafe.Pointer

ポインタとは、型とともに定義することでメモリ上のアドレスとそのアドレス位置からのどの範囲を読み取ればいいかわかるもの。
もし同じアドレス位置を指していたとしても別の型で定義された場合、扱う範囲が異なるため不正な値を読み取ってしまう。
unsafe.Pointerとはあるポインタ型から任意のポインタ型にキャストを可能にする仕組み。

Tips

unsafe.Pointerを活用した例として、string 型から[]byte 型へのキャストがある。string と[]byte はキャストによって変換できるが、変換の際に文字列データのコピーが発生する。
unsafe.Pointer を使うとコピーせず、キャストができる。文字列とスライスの構造体がDataLenフィールドまで一致しているためこのテクニックが使える。

s := "hello"
b := *(*[]byte)(unsafe.Pointer(&s))
  • 文字列
type String struct {
	Data uintptr
	Len int
}
  • スライス
type Slice struct {
	Data uintptr
	Len int
	Cap int
}

エラーハンドリング

Tips

  • 関数で error を返す場合、error 型以外の戻り値にはその型のゼロ値を用いるのが一般的
  • 独自のエラーの定義
type MyError {
	Op string
	Table string
}

func (e *MyError) Error() string {
	return fmt.Sprintf("myerror: %s %s", e.Op, e.Table)
}
  • 再利用するものやerrors.Is()などでエラー判別したい場合は、エラーをパッケージ単位で変数として持つと良い
package sql

var ErrNoRows = errors.New("sql: no rows in result set")

ラッピング

単にバケツリレーで発生したエラーを返していくだけでは、「どのような処理でそのエラーが発生したのか」という文脈が失われてしまう。
→ ラッピングすることで「どこ」で行われたかの情報を付随させることができる

fmt.Errorf

Unwrap メソッドを実装した error の値を返す。

return fmt.Errorf("handle signup request* %w", err)

errors.As / errors.Is

ラッピングされたエラーから、エラー型固有の情報を利用したい場合はerrors.Asを、単にエラー同士が等しいかどうかを確認したい場合はerrors.Is関数を使うと良い。

errors.As

var me *MyError
if errors.As(err, &me) {
	fmt.Printf("op: %s, table: %s", me.Op, me.Table)
}
  1. 第一引数のエラーが第二引数のエラーに型アサーション(代入)する
  2. 1 が失敗した場合、第一引数のエラーが「As」関数を実装していれば、呼び出し、結果が true になるか判定
  3. 2 が失敗した場合、errors.Unwrapを呼び出し、取り出したエラーに対してまた 1 から検証する

erros.Is

if errors.Is(err, sql.ErrNoRows) {
	// do something
}
  1. 二つのエラーの値が等しくなるか(「==」と同様)
  2. 1 が失敗した場合、第一引数が「Is」関数を実装していれば、呼び出し、結果が true になるか判定

並行処理のエラーハンドリング

golang.org/x/sync/errgroupパッケージは「最初に発生したエラー」のみ取得できるが、並行している各処理が返すエラーを全てハンドリングしたい場合は、github.com/hashicorp/go-multierrorパッケージを用いると良い。

var group multierror.Group
for _, name := range names {
	group.Go(func() error {
		// do something
	})
}

if err := group.Wait(); err != nil {
	for _, e := range err.Errors {
		// do something
	}
}

第 1 章 Go エキスパートたちの実装例1  CLI ツール、ライブラリ

静的解析ツール

インターネット回線のスピードテスト

ダウンロードでスピードテストするサンプルコード

HTTP リクエスト

req, err := http.NewRequest(http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
defer resp.Body.Close()

sync/atomic

データの競合を回避するためにsync.Mutexを使うことができるが、もっとシンプルに書く方法がある。

var cnt uint32
var mu sync.RWMutex
for i := 0; i < times; i++ {
	go func() {
		mu.Lock()
		cnt++
		mu.Unlock()
	}()
}
  • プリミティブ型の場合:sync/atomicパッケージのatomic.AddInt64()など
var cnt uint32
for i := 0; i < times; i++ {
	go func() {
		atomic.AddInt32(&cnt, 1)
	}()
}
  • マップ型の場合:syncパッケージのsync.Map{}
var m sync.Map
go func() {
	m.Store("cnt", 1)
}
m.Load("cnt")

time.Ticker

time.Tickerはインターバルで処理したい時に便利

t := time.NewTicker(150 * time.Millisecond)
for {
	select {
	case <-t.C:
		fmt.Println(time.Now())
	}
}

インタラクティブな gRPC クライアント

gRPC はコンテンツの送受信するために Protocol Buffers のようなシリアライザを使用し、バイナリベースのプロトコルである HTTP/2 で通信するため、curl などの CLI ツールでリクエストを送信し、レスポンスの内容を目で確認することができない。

→ 簡単にリクエストを構築し、送受信ができる gRPC クライアントEvans

CLI ツール作成に役立つ OSS

gRPC の API 定義取得

API 定義ファイルが存在する場合

通常、gRPC の API 定義ファイルを protoc で読み込むことになるが、protoc がユーザ環境にインストールされていなければならない。
→ Protocol Buffers パーサの github.com/jhump/protoreflect を使用することで protoc ヘの依存を取り除いている

API 定義ファイルが存在しない場合

gRPC Server Reflection Protocol
→ サーバがどのような RPC を受け付けているかを知るための RPC を Protocol Buffers を用いて定義している。

srv := grpc.NewServer()

// do something

reflection.Register(srv)

gRPC のメッセージの動的な構築

API 定義ファイルがない場合、自動生成された型が手に入らないため、動的にメッセージを構築する必要がある。
→ github.com/jhump/protoreflect のdynamicパッケージのdynamic.Messageを使用。
Marshal すると、内部で型アサーションを行い、proto.Marshalerを実装している場合、Marshal メソッドを呼び出している。

文字列出力のテスト

文字列の出力がシンプルである場合、単純な文字列比較によりテストしますが、コマンドヘルプの内容のテストや、複数行にわたる出力など、複雑な出力が行われる場合はしばしばゴールデンファイルテストパターンを使う。

期待する出力が書き込まれたファイルをゴールデンファイルと呼び、テスト実行時に-updateフラグを指定されている場合、ゴールデンファイルをテストによって得られた出力で上書きする。
期待通りの出力であればバージョン管理システムにコミットする。

アプリケーションの配布

マルチコンパイル、Github Releases や Homebrew での配布までを1コマンドで実行
GoReleaser

.goreleaser.yml
builds:
-
  goos:
    - darwin
    - linux
    - windows
  goarch:
    - amd64
    - arm
    - '386'
changelog:
  sort: asc
brews:
  - tap:
    owner: shellingford330
    name: homebrew-test
    url_template: "http://github.com/shellingford330/test..."
  install: |
    bin.install "evans"

チェックディジットライブラリ

ライブラリを作る際のポイント

  • Example テストを積極的に利用する
  • awesome-goにプルリクエストを送ってみる
    Go Trending や Go Weekly といった Twitter アカウントに紹介されやすくなる

Kubernetes などの設定ファイルのテストツール

インフラも IaS によってソフトウェア開発のプラクティスの恩恵を受けることができるようになった。
その中でもテストは恩恵があまり得られない。テストには大きく以下のケースがある。

  • 言語の仕様の元に正しい構文で書かれている(リント)
  • アプリケーションの設計、仕様に沿った実装ができているか(ユニットテスト)
  • アプリケーションが正しく応答するか(E2E テスト)

この中で2つ目の YAML などの任意のフィールドの値が正しいかどうか(k8s の場合、レプリカ数が正しいか)についてのテストが難しい。
このようにインフラの設定・状態をコードで示すことを、(Hashicorp では)Policy as Code と呼ぶ。

PaC を実現するsteinがある。

Stein の紹介

stein の大まかな仕組みは、JSON や YAML といった設定ファイルのルールを HCL という言語で記述し、Go で書かれた stein アプリケーションが設定ファイルがルールに準拠しているかチェックするという流れになっている。

Pas
rule "replicas" {
	conditions = [
		"${jsonpath("spec.replicas") > 3}"
	]
}

Stein の実装

HCL を使うメリット

  • 学習コストが低い点
  • アプリケーション(stein)側で YAML や JSON と同じようにデータ構造(Go の構造体)として扱える
  • アプリケーション(stein)側で式の評価(組み込み関数などをアプリケーション側で設定できる)ができる

PaC はユースケースによって異なるルールを設定できる必要があるため、3つ目の「式の評価」ができる柔軟性の高い特徴が最適。

Go における HCL のデコード方法

HCL を Go の構造体に落とし込むには、github.com/hashicorp/hcl2を使う。

HCL が独自の「式の評価」ができる理由

HCL では中間表現を持つことで、デコード前に色々な処理を挟めるようになっている。中間表現に以下のようなコンテキスト情報を介してデコードすることで、スキーマ(独自 DSL の構文)側で定義済みの変数、もしくは関数として展開することができる。

コンテキスト情報
ctx := &hcl.EvalContext{
	Variables: map[string]cty.Value{
		"name": cty.StringVal("babarot"),
	},
	Functions: map[string]function.Function{
		"upper": stdlib.UpperFunc,
	}
}
message = "HELLO, ${upper(name)}!"
# ==> "HELLO, BABAROT!"

独自 DSL の定義

ルールファイル(ポリシー)は独自 DSL(HCL)で記述されていて、以下の手順で Go にパースされる。

  1. 独自 DSL の構文に従っているかチェック
  2. スキーマをもとに「中間表現(hcl.File)」にパースされる
  3. 「中間表現」にコンテキスト情報渡してデコード(指揮評価して)し、Go の構造体にマッピングされる
schema
var policySchema = &hcl.BodySchema{
	Blocks: []hcl.BlockHeaderSchema{
		{
			Type: "rule",
			LabelName: []string{"name"}
		},
	}
}

Could Spanner 用データベーススキーマ管理ツール

Bazel を用いたビルド

Bazel は Google が開発したオープンソースのビルドツールである。

Bazel のメリット

  • さまざまな言語・プラットフォームに対応している。
  • Python の方言を用いてビルドのルールを記述するので、高レベルな言語を用いてルールを記述できる

Go で書かれたソフトウェアのビルド

  1. プロジェクトのルートディレクトリに WORKSPACE というファイルを配置
    これにより Bazel がそのディレクトリをワークスペースとして認識するようになる。
  2. 作成した各パッケージのディレクトリに BUILD.bazel というファイルを配置
    このファイルに、作成したソフトウェアのビルド方法をルールとして記述

WORKSPACE

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
<!-- ビルドに必要なルールをダウンロード -->
http_archive(
    name = "io_bazel_rules_go",
    urls = [
        "https://storage.googleapis.com/bazel-mirror/github.com/bazelbuild/rules_go/releases/download/v0.20.1/rules_go-v0.20.1.tar.gz",
    "https://github.com/bazelbuild/rules_go/releases/download/v0.20.1/rules_go-v0.20.1.tar.gz",
    ],
    sha256 = "842ec0e6b4fbfdd3de6150b61af92901eeb73681fd4d185746644c338f51d4c0",
)
load("@io_bazel_rules_go//go:deps.bzl", "go_register_toolchains", "go_rules_dependencies")
go_rules_dependencies()
go_register_toolchains()

<!-- BUILD.bazelを自動生成するためのルールをダウンロード -->
http_archive(
    name = "bazel_gazelle",
    urls = [
        "https://storage.googleapis.com/bazel-mirror/github.com/bazelbuild/bazel-gazelle/releases/download/v0.19.0/bazel-gazelle-v0.19.0.tar.gz",
        "https://github.com/bazelbuild/bazel-gazelle/releases/download/v0.19.0/bazel-gazelle-v0.19.0.tar.gz",
    ],
    sha256 = "41bff2a0b32b02f20c227d234aa25ef3783998e5453f7eade929704dcff7cd4b",
)
load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies")

load 文は extension を読み込むための記述。http_archiveという命令は他のリポジトリをアーカイブファイルとしてダウンロードし、それを解凍してビルドルールで使えるようにするための命令。rules_goは Go で書かれたソフトウェアのビルドのために必要なルール群をまとめたリポジトリ。

go_register_toolchainsはビルドで用いる Go のツールチェインを登録するための命令。
go_rules_dependenciesは Go のビルドルール自体が必要としている外部依存を登録するためのルール。

BUILD.bazel

各パッケージに BUILD ファイルを自動生成するためにgithub.com/bazelbuild/bazel-gazelleが提供されている。
gazelle_dependenciesを用いて gazelle 自身が必要としている外部依存を登録。

プロジェクトのルートディレクトリに配置した BUILD.bazel ファイルに以下のように記述する。

load("@bazel_gazelle//:def.bzl", "gazelle")
# gazelle:prefix github.com/example/project
gazelle(name = "gazelle")

その後、コマンドを実行して BUILD ファイルを自動生成する。その際、Bazel に依存モジュール(go.mod)の内容を認識させる必要がある。

$ bazel run //:gazelle -- update-repos -from_file=go.mod

これにより、WORKSPACE ファイルにgo_repositoryという Go の依存モジュールを登録するための命令が記述される。

また、同時に main.go と同じディレクトリに存在する BUILD.bazel に以下の記述が追記される。

go_binary(
	name = "...",
	embed = [..."],
	visibility = ["..."].
)

go_binary がバイナリを生成するためのルールで、これをもとに実行可能なバイナリが bazel-bin ディレクトリ配下に生成される。

$ bazel build //:wrench

## ビットコインメッセージの変換関数の生成

  • コードの自動生成
  • AST

パッケージの AST を取得

pacakge.go
type packageInfo struct {
	name string
	files []*ast.File
}

func makePackageInfo(path string) (*packageInfo, error) {
	cfg := &packages.Config{
		Mode: packages.NeedName | packages.NeedSyntax,
		Tests: false,
	}
	packageList, err := packages.Load(cfg, path)
	p := packageList[0]
	return &packageInfo{name: p.Name, files: p.Syntax}, nil
}

golang.org/x/tools/go/packagesにあるpackage.Load関数を使うと、パッケージ全体の AST が取得できる。
packages.Load関数の引数のpackages.Config

  • Mode: packages.NeedName | packages.NeedSyntax:パッケージ名とファイルごとの AST を取得
  • Tests: false:関連する test パッケージを取得しない

構造体の AST を取得する

struct.go
type typeInspector struct {
	typeName string
	typeSpec *ast.TypeSpec
}

func (p *packageInfo) findTypeSpec(typeName string) (*ast.TypeSpec, error) {
	ti := &typeInspector{typeName: typeName}
	for _, file := p.files {
		if file == nil {
			continue
		}
		ast.Inspect(file, ti.Inspect)
		if ti.typeSpec != nil {
			return ti.typeSpec, nil
		}
	}
	return nil, errors.New("not found type")
}

構造体の情報は TypeSpec という type 宣言の情報を保持している AST ノードにある。。目的の AST を見つけるために見つけるためにast.Instpectという関数を使用。ast.Inspect 関数の引数としてルートの AST ノードと AST を調べる関数を渡すと、再起的にノードを巡回する。

inspect.go
func (t *typeInspector) inspect(node ast.Node) bool {
	decl, ok := node.(*ast.GenDecl)
	if !ok || decl.Tok != token.TYPE {
		return true
	}
	for _, spec := range decl.Specs {
		typeSpec, ok := spec.(*ast.TypeSpec)
		if !ok {
			continue
		}
		if typeSpec.Name().String() != t.typeName {
			continue
		}
		t.typeSpec = typeSpec
		return false
	}
	return true
}

抽象構文木のノードを表す ast.Node を引数に渡す。構造体宣言を抽出したいので、*ast.GenDecl(generic declaration node)かどうかをキャストで判定し、Tok が TYPE である宣言ノードを抽出。
GenDecl で TYPE であれば、Specs に Spec と言われる単一のノード情報のスライスを保持している。

フィールド情報を取得する

filed.go
func makeStructureInfo(ts *ast.TypeSpec, typeName string) (*StructureInfo, error) {
	structType, ok := interface{}(ts.Type).(*ast.StructType)
	for _, fi := range structType.Fields.List {
		fi, err := makeFiledInfo(fi)
		// フィールド情報を格納
	}
}

func makeFiledInfo(field *ast.Field) (*filedInfo, error) {
	typeName, err := exprToTypeName(filed.Type) // 型を文字列に変換する関数
	fieldName := filed.Names[0].String()
	tag := filed.Tag
	if _, isSlice := filed.Type.(*ast.ArrayType); isSlice {
		// doSomething
	}
}

TypeSpec が構造体ではない場合(type Hoge intなど)は除外する。Fields.Listでフィールド情報を取得。

条件を柔軟に変えられるリトライライブラリ

リトライを導入する前の注意点

  • 外部サービスの SDK を利用している場合、SDK の中にはあらかじめそのサービスに適したリトライを備えていることが多い。自前のリトライ処理と SDK のリトライ処理を重ねるとリトライする回数が定数倍に増える。

  • 冪等性に対応している必要がある。

  • リトライに成功しないと無限にリトライされて処理資源を占有し続けるため、リトライする上限数を決めるべき

関数にオプション引数を渡す

functional-optionsという実装パターン。

type options struct {
  cache  bool
  logger *zap.Logger
}

type Option interface {
  apply(*options)
}

type cacheOption bool

func (c cacheOption) apply(opts *options) {
  opts.cache = bool(c)
}

func WithCache(c bool) Option {
  return cacheOption(c)
}

type loggerOption struct {
  Log *zap.Logger
}

func (l loggerOption) apply(opts *options) {
  opts.logger = l.Log
}

func WithLogger(log *zap.Logger) Option {
  return loggerOption{Log: log}
}

// Open creates a connection.
func Open(
  addr string,
  opts ...Option,
) (*Connection, error) {
  options := options{
    cache:  defaultCache,
    logger: zap.NewNop(),
  }

  for _, o := range opts {
    o.apply(&options)
  }

  // ...
}

リトライの間隔の増やし方

Exponetial backoff:乗数倍で待機時間を増やしていく
例:50ms, 100ms, 200ms, 400ms, ...
ただ、この方法には問題がある。同時に多くのリクエストが失敗した際、同じ戦略で待機時間が計算されるので同じタイミングにリトライされて、一気にリクエスト先に負荷をかけてしまう。

間隔の算出に jilter(タイミングのずれ)を取り入れて待機時間に揺らぎを持たせることで、リクエストが集中することを緩和する。

第 3 章 Go エキスパートたちの実装例3  ソフトウェアや Web サービスの拡張機能

Go によるプラグイン機能の実装

パッケージ/ソフトウェアを提供するときに、ユーザの要望によって新しく機能をつけたいときにパッケージ本体に機能を追加するとどんどん肥大化したり、柔軟性がないという問題がある。ユーザが自由にカスタマイズできるように動的にプラグインを入れられると便利。

例)認証系のパッケージでインフラ層が MySQL にしか対応していなく、PostgreSQL や AWS RDS に対応したいとき、本体のパッケージでは動作する部分が書いてあるだけなのでユーザが実装したプラグインに差し替えることができるため、柔軟性が高い。

Go でプラグイン機能を実装する方法

  1. ソフトウェア本体とプラグインをまとめて1つのバイナリにビルドする方法

    1. ユーザに Go の開発環境を要求することになる
    2. 新しいプラグインを追加するごとに再ビルドする必要がある
  2. 動的ライブラリとしてプラグインを作成する方法(実行時に動的にロード)

    1. コードが簡潔に書ける
  3. ソフトウェア本体とプラグインとが別のプロセスとして起動し、お互いに通信することで動作する方法

    1. 本体とプラグインが疎結合なため、プラグイン側でクラッシュしたり、悪意あるプラグインによるセキュリティリスクを軽減できる
    2. コードが複雑

動的ライブラリとしてプラグインを作成

Go 標準のpluginパッケージを使用する。サンプルコードはこちら

プラグインの実装

plugin/en/plugin.go
package main

import "fmt"

func Greet() {
	fmt.Println("Hello!")
}
$ go build -buildmode=plugin -o en.go ./plugin/en

プラグインを利用する側の実装

greet.go
package main

func main() {
	if err := greet(os.Args[1]); err != nil {
		panic(err)
	}
}

func greet(lang string) error {
	p, err := plugin.Open(lang + ".so")
	if err != nil {
		return err
	}

	v, err := p.Lookup("Greet")
	if err != nil {
		return err
	}

	f, ok := v.(func())
	if !ok {
		return errors.New("Greet must be a function type")
	}

	f()
	return nil
}

Lookup メソッドは、プラグインでエクスポートされている変数と関数を名前で取得できる。

$ go build -o greet greet.go
$ ./greet en
Hello!

別のバイナリとしてプラグインを作成して RPC 経由で利用する

ソフトウェア本体とプラグインが別のプロセスで動作してお互いに通信するタイプのもの。
github.com/hashicorp/go-pluginというツールがよく使われる。

サンプルコードはこちら

GitHub Actions による自動化

サンプルコードはこちら

GitHub Actions の作り方

  1. action.yml の作成
  2. Action の実装
  3. Dockerfile の作成
  4. Action の公開

1. action.yml

作成する Action の定義ファイル。

name: "Print tilte"
description: "Print tilte with the given title"
author: "Hirofumi Suzuki"
inputs:
  title:
    description: "A title."
    required: true
outputs:
  number:
    description: "Given title"
runs:
  using: "docker"
  image: "Dockerfile"
branding:
  icon: 'calendar'
  color: 'orange'

入出力

入力値は環境変数のINPUT_VARIABLE_NAMEの形式で渡される。
例)「INPUT_TITLE」という環境変数にセットされる。

出力値は標準出力に::set-output name=title::valueの形式で出力することで、他のステップから${{ steps.id.outputs.title }}から呼び出せる。

2. Action の実装

func main() {
	title := os.Getenv("INPUT_TITLE")
	fmt.Fprintf(os.Stdout, "::set-output name=title::%s\n", title)
}

Tips

モックサーバ

標準ライブラリのhttptest.NewServer関数を使うと、任意の http.Handler インターフェースの ServeHTTP メソッドが呼び出される HTTP サーバを簡単に作成することができる。

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	// do something
}))
defer ts.Close()
fmt.Println(ts.URL) // モックサーバのURL

bytes.Buffer

テストなどでio.Writerインターフェースへの出力を確認したい場合、bytes.Bufferが便利。

var outStream bytes.Buffer

4. Action の公開

README.md ファイルを公開する前に作成。
[Draft a release]というボタンから action を公開する。

Envoy Control Plane Kubernetes Controller

  • Envoy
  • xDS API
  • go-control-plane

Envoy

Envoy は C++で実装されたネットワークプロキシであり、「設定の大部分を API 経由で動的に変更できる」という特徴をもっている。この API を xDS API という。

Envoy はプロキシとしての設定を以下のような単位に分割する。

  • Listener:Envoy 自身が Listen するポート番号と、それに対する Route の設定
  • Route:Listener に対するルーティングであり、どのアップストリーム(Cluster)にトラフィックを流すかの設定
  • Cluster:リクエスト先のアップストリームの設定であり、FQDN あるいは複数の IP アドレス(Endpoint)で構成される
  • Endpoint:Cluster に対する実際のエンドポイント(IP アドレス)

xDS API

xDS API は x Discovery Service の略であり、Cluster や Listener などの設定を、プロキシに対して動的に配信するための API である。
xDS API で設定を変更するサーバーを Control Plane という。

xDS API は Protocol Buffers を用いて gRPC のサービスとして定義されている。例えば、CLuster の設定を配布するためのサービスである Cluster Discovery Service(CDS)が定義されている。他に、Listener Discovery Service(LDS)、Route Discovery Service(RDS)と言った gRPC のサービスも定義されており、Cluster や Listener などの各設定を1つの gRPC サービスで配信するための Aggregated Discovery Service(ADS)というサービスも存在する。

go-controle-plane

go-controle-plane は xDS API の Go 実装であり、Go を用いて Controle Plane を開発するためのライブラリである。

  • envoy:Envoy の設定や xDS API の gRPC の定義から生成した Go のコード
  • pkg/server:CDS などの xDS API の gRPC サーバ実装
  • pkg/cache:サーバに接続されているプロキシに対して Cluster や Listener などの設定をキャッシュするための実装

Controle Plane の実装

import (
	cache "github.com/envoyproxy/go-controle-plane/pkg/cache/v2"
	server "github.com/envoyproxy/go-controle-plane/pkg/server/v2"
	"google.golang.org/grpc"
)

snapshotCache := cache.NewSnapshotCache(true, cache.IDHash{}, logger)
server := server.NewServer(context.Background(), snapshotCache, &callbacks{})
grpcServer := grpc.NewServer()
lis, err := net.Listen("tcp", ":8081")

discovery.RegisterAggregatedDiscoveryServiceServer(grpcServer, server)
err := grpcServer.Serve(lis)

NewSnapshotCache 関数ではキャッシュ用の変数を作成し、1番目の引数では ADS を用いるかどうか、2番目の引数(cache.IDHash{})は Controle Plane に接続している個々のプロキシの ID を識別するための変数。

サーバの起動後は、キャッシュを更新することで gRPC サーバから各プロキシに設定を配布できる。

clusters := []*api.Cluster{
	{
		Name: "cluster-1",
		// 略
	}
}
clusterResources := make([]types.Resource, len(clusters))
for i, cluster := clusters {
	clusterResources[i] = types.Resource(cluster)
}
// 略
snapshot := cache.NewSnapshot("version", endpointResources, clusterResources, routeResources, listenerResources, runtimeResources)
err := snapshotCache.SetSnapshot("node-id", snapshot)

SetSnapshot メソッドでキャッシュの更新が行われると、内部的には gRPC サーバからプロキシへの設定の配布が発火される実装になっている。1番目の引数の ID で特定されるプロキシ(Node)に対して設定を配布する。

go-controle-plane は「xDS API サーバとしての最小限の実装」に徹しており、「どのようにして Cluster や Listener などの設定を作成するかどうか」という点については個々の Control Plane の実装者に委ねている。例えば、Custom Resource で xDS API で定義されている任意の設定を記述し、任意のプロキシに対してこの設定を配布することが可能。

kind

kind(Kubernetes in Docker)は Docker を用いてローカル環境に Kubernetes クラスタを構築するためのツールである。

クラスターをローカル環境に作成

$ kind create cluster --name <cluster name> --image kindest/node:v1.21.1

Skaffold

Skaffold は Kubernetes をターゲットとして開発された、ビルドやデプロイを支援するためのコマンドラインツール。Skaffold dev コマンドは自動デプロイ機能でソースコードの変更を検知して設定ファイルに従ってアプリケーションをビルドし、クラスタにデプロイするもの。

$ skaffold dev --filename=./dev/skaffold/skaffold.yaml
skaffold.yaml
apiVersion: skaffold/v2beta11
kind: Config
build:
	artifacts:
		- image: boots
			docker:
				dockerfile: ./Dockerfile
deploy:
	kubectl:
		manifests:
			- ./dev/skaffold/pod.yaml

デプロイ対象の Kubernetes クラスタが kind で作成されたものだった場合、kind の load docker-image コマンドを用いてクラスタに Docker コンテナのイメージをロードする。

pod.yaml
apiVersion: v1
kind: Pod
metadata:
	name: bootes
	namespace: bootes
spec:
	containers:
		- name: bootes
			image: bootes

Custom Terraform Provider

Terraform は大きく次の二つのコンポーネントによって構成されている。

  • Terraform Plugin:外部リソースの構成管理やプロビジョニングを担う
  • Terraform Core:Plugin にかかわらず Terraform を実行するための共通ロジックを担っている

で構成されている。

Terraform Plugin はさらに二つの構成要素がある。

  • Terraform Provisioner:スクリプトを実行したり、Chef などのエージェントをインストールする Provisioner がある
  • Terraform Provider:外部リソースの CRUD 操作を行う

Terrfaorm Provider はディストリビューション方法で3つに分けられる。

  • Build-in Provider
  • Hashicorp Distributed Provider
  • Custom Terraform Provider

Provider の開発をサポートするTerraform Plugin SDKが HashiCorp 社から提供されている。

Custom Terraform Provider の作り方

API クライアントの開発

Provider とは別に SDK を開発することを推奨。

  • SDK として他の用途に再利用できる
  • API リクエストの一連の処理を Provider から隠蔽でき、責任分離できる点

Provider スキーマの実装

  1. スキーマを決める

Provider ブロックのスキーマ(設定すべき項目)を実装する必要がある。その各情報をもとに外部サービスへアクセスをする。

provider.go
package miro

import (
	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

func Provider() *schema.Provider {
	return &schema.provider{
		Schema: map[string]*schema.Schema{
			"access_token": {
				Type: schema.TypeString,
				Description: "Access key for Miro API",
				Required: true,
				Sensitive: true,
			},
		},
		ResourceMap: map[string]*schema.Resource{},
		DataSourceMap: map[string]*schema.Resource{},
	}
}

Required を true に設定したフィールドが指定されていなければ、terraform plan コマンド or terraform apply コマンドなどの実行は失敗する。

Resource とは1つの外部リソースの単位(Google Compute Engine インスタンスや GitHub の Team)に対応していて、Data Source とは外部リソースを Resource のように Terraform で扱う読み取り専用のもの。

  1. スキーマ情報から初期化

Provider の ConfigureContextFunc で Provider の初期化のための関数を指定できる。

provider.go
import (
	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
	"github.com/hashicorp/terraform-plugin-sdk/v2/plugin"
)


func Provider() *schema.Provider {
	return &schema.Provider{
		ConfigureContextFunc: providerConfigureFunc,
	}
}

func providerConfigureFunc(ctx context.Context, data *schema.ResourceData) (interface{}, diag.Diagnostics) {
	key := data.Get("access_token").(string)
	var diags diag.Diagnostics
	return miro.NewClient(key), diags
}
  1. Terraform init 時にインストールされるようにする
main.go
package main

func main() {
	plugin.Serve(&plugin.ServeOpts{
		ProviderFunc: func() *schema.Provider {
			return miro.Provider()
		},
	})
}
  1. Provider のバイナリをインストール

Terraform は、デフォルトで読み込むディレクトリ(.terraform/providers/)に使用する Provider のバイナリがなければ、Terraform Registry からインストールしようとする。

provider.tf
terraform {
	required_providers {
		miro = {
			source = "Miro-Ecosystem/miro"
		}
	}
}

provider "miro" {
	access_token = var.access_token
}
$ terraform init
* miro-ecosystem/miro: version = "~> 99.0.0"

Terraform has been successfully initialized!
  1. Resource スキーマ実装

.tf ファイルの Resource ブロックのスキーマを定義する。

resource_board.go
func resourceBoard() *schema.Resource {
	return &schema.Resource{
		Schema: map[string]*schema.Schema{
			"name": {
				Description: "Name of the Board",
				Type: schema.TypeString,
				Required: true,
			},
		},
	},
}

このスキーマを使って Miro の Board API の CRUD を行う次の Context Functions を実装。

  1. Create Context Function を実装
resource_board.go
func resourceBoard() *schema.Resource {
	return &schema.Resource{
		CreateContext: resourceBoardCreate,
	}
}

func resourceBoardCreate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
	c := meta.(*miro.Client)
	var diags diag.Diagnostics
	name := data.Get("name").(string)
	req := &miro.CreateBoardRequest{
		Name: name,
	}

	board, err := c.Boards.Create(ctx, req)
	if err != nil {
		return diag.FromErr(err)
	}

	data.SetId(board.Id)

	return resourceBoardRead(ctx, data, meta)
}

引数の meta は実装した ConfigureContextFunc の戻り値である。
data.SetIdでこの Board を Terraform で一意に識別する ID を設定。

  1. Read Context Function
resource_board.go
func resourceBoard() *schema.Resource {
	return &schema.Resource{
		ReadContext: resourcBoardRead,
	}
}

func resourceBoardRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
	c := meta.(*miro.Client)

	var diags diag.Diagnostics
	board, err := c.Boards.Get(ctx, data.Id())
	if err != nil {
		return diag.FromErr(err)
	}

	if board == nil {
		data.SetId("")
		return diag
	}

	if err := data.Set("boards", board); err != nil {
		return diag.FromErr(err)
	}

	diag.SetId(board.ID)
	return diags
}

data.SetId("")とすることで Terraform の管理下から外すことができる。

  1. Provider へ Resource を登録

Resource のブロックの種類をキーにして、ResourceMap へ登録。

provider.go
func Provider() *schema.Provider {
	return &schema.Provider{
		ResourceMap: map[string]*schema.Resource{
			"miro_board": resourceBoard(),
		}
	}
}
miro.tf
resource "miro_board" "test" {
	name = "Test Board"
}
  1. Terraform Registry に公開

Terraform Registry に GPG の公開鍵を登録し、対応する秘密鍵で Terraform Provider のバイナリを署名してから、アップロードする。