ZOZOTOWNのGo言語におけるマイクロサービス開発の共通規約を守るための取り組み

ogp

はじめに

こんにちは。ECプラットフォーム部の北原です。普段はZOZOTOWNのバックエンドの開発、運用に携わっており、現在は会員機能を司るマイクロサービスの開発を進めています。

今回はZOZOTOWNのGo言語におけるマイクロサービス開発の共通規約を守るための取り組みを紹介します。

マイクロサービス開発の課題

ZOZOTOWNでは複数のマイクロサービスでGo言語を使っています。マイクロサービスではトレース、ヘッダー処理、認証関連などの機能をサービス毎に持つことはよくあると思います。一方で、マイクロサービス開発ではサービス毎に別のチームが開発することもよくあるため、実装者による認識の齟齬、漏れなどで同一機能の実装に差異が生じてしまうかもしれないという課題があります。

開発の当初から共通機能の管理への課題感はあり、ZOZOTOWNのGo言語におけるマイクロサービス開発の共通規約を守るため、標準的な機能を盛り込んだ開発テンプレートを作り課題に取り組んでいます。

開発テンプレート

開発テンプレートの共通規約の実装、共通機能の例、構成を紹介します。

共通規約の実装例

開発テンプレートはバックエンドの共通規約の実装、共通ライブラリ、ドメインロジックのサンプルコードを開発プロジェクトとして展開できるようにしたものとなります。

バックエンドの共通規約の実装例として次のようなものがあります。

  • トレース
  • ヘッダー処理
  • 認証

それぞれ紹介していきます。

トレース

マイクロサービスのログやトレースではいくつかのサービスを使っており次のような住み分けをしています。

機能・サービス 用途
アクセスログ 障害調査やユーザからの問い合わせ対応
Datadog トレース分析、アラート検知
Sentry 未知のエラー検知、エラーの管理

例として、アクセスログの実装を抜粋したものとなります。まず、アクセス時にロガーを呼び出すミドルウェア(HTTPハンドラにおけるミドルウェアパターン)を、次にロガーの実装を示します。共通の項目を定義しサービス毎にズレがないよう共通としています。TraceID を持っていますが、Datadogでは全てのトレースを保持できないため、Datadogで破棄されたトレースの調査用に保持しています。

  • アクセスログコード抜粋
func (mw loggingMiddlewareImpl) LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        accessLog := &httpLogger.AccessLog{
            Method:        r.Method,
            Host:          r.Host,
            Path:          r.URL.Path,
            Query:         r.URL.RawQuery,
            RequestSize:   r.ContentLength,
            UserAgent:     header.GetUserAgent(r),
            ...
            TraceID:       r.Header.Get(constant.HeaderKeyZozoTraceID),
            UID:           r.Header.Get(constant.HeaderKeyZozoUID),
        }
        ctx := setAccessLog(r.Context(), accessLog)
        sw := &StatusResponseWriter{ResponseWriter: w, status: http.StatusOK}

        next.ServeHTTP(sw, r.WithContext(ctx))

        accessLog.Status = sw.status
        requestedAt, err := GetRequestedAt(ctx)
        if err != nil {
            lib.LogError(ctx, err)
        } else {
            accessLog.Latency = time.Since(requestedAt).Seconds()
        }

        mw.accessLogger.Log(accessLog)
    })
}

type loggingMiddlewareImpl struct {
    accessLogger httpLogger.AccessLogger
}

httpLogger.AccessLogger では現在は zap.Logger を利用しています。ユーザからの問い合わせ調査など、サービスを横断した調査を円滑にするためサービス間で出力される情報が揃っていることが求められます。フォーマットや日時の精度など、機能が提供されることでサービス横断的な品質担保が容易になります。

  • ロガーコード抜粋
type AccessLogger interface {
    Log(accessLog *AccessLog)
}

type accessLoggerImpl struct {
    *zap.Logger
}

func (l accessLoggerImpl) Log(a *AccessLog) {
    l.Info(
        zap.Int("status", a.Status),
        zap.String("method", a.Method),
        zap.String("host", a.Host),
        zap.String("path", a.Path),
        ...
    )
}

ヘッダー処理

APIではエンドポイントに送る値としてユーザの入力パラメータでない値はヘッダーでやりとりすることが多いと思われます。User Agentなどのユーザ情報や、認証情報、APIの呼び出し元の情報など必要なヘッダー情報はマイクロサービス間でも持ち回る共通規約となっています。

APIの処理中で別のマイクロサービスのAPIを呼び出す際も同等のヘッダーを付加する必要があるためContextで持ち回っています。これもマイクロサービスとして共通の実装にすることで抜け漏れのない機能として提供できます。

  • ヘッダー処理のミドルウェアコード抜粋
func RequestMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        if userAgent := r.Header.Get(constant.HeaderKeyForwardedUserAgent); userAgent != "" {
            ctx = setForwardedUserAgent(ctx, userAgent)
        }
        if userIP := r.Header.Get(constant.HeaderKeyUserIP); userIP != "" {
            ctx = setUserIPAddress(ctx, userIP)
        }
        if traceID := r.Header.Get(constant.HeaderKeyZozoTraceID); traceID != "" {
            ctx = SetTraceID(ctx, traceID)
        }
        if uid := r.Header.Get(constant.HeaderKeyZozoUID); uid != "" {
            ctx = setUID(ctx, uid)
        }
        if xForwardedFor := r.Header.Get(constant.HeaderKeyXForwardedFor); xForwardedFor != "" {
            ctx = setXForwardedFor(ctx, xForwardedFor)
        }
        if r.RemoteAddr != "" {
            ctx = setRemoteAddress(ctx, r.RemoteAddr)
        }
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
  • 別のマイクロサービスのAPI呼び出しHTTPクライアントのヘッダー追加コード抜粋
func setHeaders(ctx context.Context, request *http.Request) error {
    if encodedInternalIDToken, err := middleware.GetEncodedInternalIDToken(ctx); err == nil {
        request.Header.Set(constant.HeaderKeyZozoInternalIDToken, encodedInternalIDToken)
    }
    if ipAddress, err := middleware.GetUserIPAddress(ctx); err == nil {
        request.Header.Set(constant.HeaderKeyUserIP, ipAddress)
    }
    if userAgent, err := middleware.GetForwardedUserAgent(ctx); err == nil {
        request.Header.Set(constant.HeaderKeyForwardedUserAgent, userAgent)
    }
    if traceID, err := middleware.GetTraceID(ctx); err == nil {
        request.Header.Set(constant.HeaderKeyZozoTraceID, traceID)
    }
    if uid, err := middleware.GetUID(ctx); err == nil {
        request.Header.Set(constant.HeaderKeyZozoUID, uid)
    }
    if apiClient, err := middleware.GetAPIClient(ctx); err == nil {
        request.Header.Set(constant.HeaderKeyAPIClient, apiClient)
    }
    if xForwardedFor, err := middleware.GetXForwardedFor(ctx); err == nil {
        if remoteAddr, err := middleware.GetRemoteAddress(ctx); err == nil {
            host, _, e := net.SplitHostPort(remoteAddr)
            if e != nil {
                return xerrors.Errorf("split remote address: %v", e)
            }
            request.Header.Set(constant.HeaderKeyXForwardedFor, xForwardedFor+", "+host)
        }
    }
    return nil
}

実装自体は非常にシンプルなものですが、シンプルであっても個別に実装せずヘッダーを伝播させる規約を守るためコピー用のコードを用意しています。

認証

認証はバックエンドの前段にあるAPI Gatewayで行われており、認証が成功した場合にはバックエンドへの通信のヘッダーに認証されたことを表すトークンが付与されます。 ヘッダーで渡されるトークンからユーザの情報を取得するためデコード処理を行っていますが、デコードの仕様や取得した値のバリデーションなど実装の差異がないよう共通処理としています。

  • 認証ユーザ情報取得コード抜粋
func InternalIDTokenMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get(constant.HeaderKeyZozoInternalIDToken)

        internalIDToken, err := model.DecodeInternalIDToken(token)
        if err != nil {
            writeRespInvalidInternalIDToken(w)
            return
        }
        ctx := setInternalIDToken(r.Context(), internalIDToken)
        ...
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

API Gatewayで認証されたユーザ情報をDecodeInternalIDTokenで復元しContextにセットします。ミドルウェアのパッケージ外で値の変更ができないようレイヤーに閉じた実装として提供しています。

共通機能の例

バックエンドやAPIとしてよく利用され共通化できる機能もテンプレートとして提供しています。 ヘルスチェックのように各サービスで実装する機能は重複実装しないようテンプレートに含めています。

  • ヘルスチェックコード抜粋
type clientOptions struct {
    healthCheckReadiness func() error
}

func NewHealthCheckControllerWithReadiness(readiness func() error) func(*http.Request, RequestParameters) ([]byte, error) {
    options.healthCheckReadiness = readiness
    return healthCheckController
}

func healthCheckController(r *http.Request, _ RequestParameters) ([]byte, error) {
    switch r.Method {
    case http.MethodGet:
        switch r.URL.String() {
        case "/health/liveness":
            return healthCheckLiveness()
        case "/health/readiness":
            return healthCheckReadiness()
        default:
            return nil, view.ErrNotFound
        }
    default:
        return nil, view.ErrNotFound
    }
}

func healthCheckLiveness() ([]byte, error) {
    return []byte("{}"), nil
}

func healthCheckReadiness() ([]byte, error) {
    if err := options.healthCheckReadiness(); err != nil {
        return nil, view.ErrServiceUnavailable
    }
    return []byte("{}"), nil
}
  • HTTPサーバーのrouter設定コード抜粋
func init{
    ...
    opt := controller.ClientOptions{HealthCheckReadiness: mysql.Readiness}
    healthCheckController := controller.NewHealthCheckControllerWithReadiness(opt)

    routes := map[string]func(*http.Request, controller.RequestParameters) ([]byte, error){
        "/health/liveness":  healthCheckController,
        "/health/readiness": healthCheckController,
    }
    for route, controller := range routes {
        Router.Handle(route, buildHandler(controller))
    }
    ...
}
  • MySQLヘルスチェックコード抜粋
func Readiness() error {
    db, err := mysql.NewDB()
    if err != nil {
        return err
    }
    defer db.Close()

    err = db.Ping()
    if err != nil {
        return err
    }
    return nil
}

マイクロサービス作成の度に再実装するコストをかけないようにバックエンドの共通規約を守る機能やプロジェクトでよく利用される機能群をテンプレートにまとめています。

開発テンプレートの構成

テンプレートの責務はバックエンドの共通規約を守る、再利用性の向上、開発の立ち上げスピード向上にあると考えています。 要素としては、業務共通的なミドルウェア、共通ライブラリ、ドメインロジックのサンプルコードとなります。

ディレクトリ構成とその説明

ヘキサゴナルアーキテクチャをベースに、アダプター・アプリケーション・ドメインのレイヤーで責務を分離するようなアーキテクチャを設計しました。 アダプターレイヤーで外部依存の機能を実装し、ドメインレイヤーに業務のルールを整理、アプリケーションレイヤーでドメインを利用したロジックを実装するようなシンプルな切り分けになっています。

ヘキサゴナルアーキテクチャ

社内のGo言語のプロジェクトは何かしらのレイヤードアーキテクチャを採用していることが多いため、ヘキサゴナルアーキテクチャであればギャップが少なく導入しやすいと考えています。またアプリケーションレイヤーにある程度の選択肢を持たせるなどテンプレートの設計としてはマッチしていると考え選定しています。

起動するとデフォルトでHTTPサーバーが立ち上がり、ある程度必要なものが用意されているため、各ドメインロジックの実装に注力できます。

その他の技術要素

他にはlinterやテストのヘルパー類、Docker、DatadogとSentryなどテンプレートを利用することでバックエンドとしての標準機能や品質のベースが整うような構成となっています。 DockerやDatadog、SentryなどSREチームによって標準化されているものは、テンプレートとして利用しやすいようヘルパーなどを用意する形になっています。

ライブラリやディレクトリ構成を合わせることで、別のプロジェクトを参照する際の認知負荷を下げられることを期待しています。

開発テンプレートの課題とSDK化

テンプレートでの開発を進める中で運用課題となりそうなポイントが出てきました。

当初はサービス毎にテンプレートのリポジトリをコピーする方式でしたが、プロジェクト開始時のバージョンのコードがコピーされ、テンプレートの変更をサービス側で取り込む運用を想定していました。 しかし利用プロジェクトが増えるとそれぞれに反映してもらう手間、実装タイミング、プロジェクト毎に反映の有無が別れるなど運用の手間(負荷)が懸念されました。

そこでライブラリとして必要なパーツを利用できるようSDK化を進めることにしました。 SDK (Software Development Kit) として、テンプレートの機能をライブラリとして切り出しリポジトリをインポートして利用できる形にしました。 変更がインポートできるようになることで、テンプレートで懸念されたアップデートの運用の手間や取り込みのタイミングなどある程度払拭できるようになると考えました。

SDK化することで機能のつながりがわかりづらくなったり、インポートの手間が増えたりと障壁がゼロなわけではありません。 そこで、テンプレートの位置付けをSDKの利用方法を示したサンプルアプリケーションと改めることでSDKの理解が進むようにし、各サービスに展開しやすくしています。

プライベートリポジトリの利用

SDKとして切り出されたライブラリはGitHubのプライベートリポジトリで管理されています。プライベートリポジトリのアクセスはいくつか方法がありますが、インポートする際の設定で検討した内容となります。

Go言語でのプライベートリポジトリの取得に関してはこちらを参照してください。

https://go.dev/ref/mod#private-modules

プライベートリポジトリのモジュールを go.mod に追加する場合は GOPRIVATE にリポジトリを指定し go get します。 GOPRIVATE を設定することで、GONOPROXY, GONOSUMDB の対象となります。

  • Direct access to private modulesより引用

The GOPROXY variable does not need to be changed in this situation. It defaults to https://proxy.golang.org,direct, which instructs the go command to attempt to download modules from https://proxy.golang.org first, then fall back to a direct connection if that proxy responds with 404 (Not Found) or 410 (Gone).

The GOPRIVATE setting instructs the go command not to connect to a proxy or to the checksum database for modules starting with corp.example.com.

ローカルの開発ではSSHの鍵認証を使ってGitHubのプライベートリポジトリにアクセスできる環境が整っている場合が多いと思いますが、その場合SSH経由で接続できます。 Macでの例となりますが<アカウント>、<リポジトリ>はそれぞれの環境の値が入ります。

git config --global url."ssh://git@github.com/<アカウント>".insteadOf "https://github.com/<アカウント>"
go env -w GOPRIVATE=github.com/<アカウント>/<リポジトリ>
go get -u github.com/<アカウント>/<リポジトリ>
  • .gitconfig
[url "ssh://git@github.com/"]
        insteadOf = https://github.com/

GOPRIVATE はGo言語の path.Match で一致する形式で記述できるため次のような設定もできます。

go env -w GOPRIVATE=github.com/<アカウント>/*

開発での自動テストやlintを実施するため、ローカルでDocker Composeを利用して、コンテナを起動する手順があり次のような設定ファイルを追加しています。 Dockerfile でプライベートリポジトリにアクセスする際はGitHubのpersonal access token (PAT) を利用しています。 PATを docker-compose.ymlsecrets に設定し --mount=type=secret して取得しています。

  • Dockerfile
FROM golang:1.18.0-bullseye as debugger
...
RUN --mount=type=secret,id=personal_access_token git config --global url."https://$(cat /run/secrets/personal_access_token):x-oauth-basic@github.com/".insteadOf "https://github.com/"
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOPRIVATE=github.com/<アカウント>/*
COPY go.mod go.sum ./
RUN go mod download
...
  • docker-compose.yml
services:
  app:
    build:
      context: .
      target: debugger
      secrets:
        - personal_access_token
    environment:
    ...
secrets:
  personal_access_token:
    file: personal_access_token.txt
  • personal_access_token.txt
ghp_zzz.. <personal_access_token>

PATの使用は極力減らしたいと考えており課題感は残るものの、このようにプライベートリポジトリを利用できるよう設定しております。

コードの管理としてはバージョン管理もポイントとしてありますが、可能な限り後方互換性を持たせ、最新のバージョンを取得する方式を維持していきたいと考えています。

これから

マイクロサービス化の開発はまだまだ残っているので、チームで課題を検討する際にSDKが1つの手段となるよう柔軟に対応していければと考えています。

またSDKを広げることで開発チームのDRYから組織的なDRYにつなげ、より価値のある課題に取り組めればと考えています。

最後に

ZOZOTOWNのマイクロサービス化はまだ始まったばかりです。新たなマイクロサービスの開発も目白押しです。ご興味のある方は、以下のリンクからぜひご応募ください。お待ちしています。

corp.zozo.com

hrmos.co

カテゴリー