Help us understand the problem. What is going on with this article?

【実践Go×gRPC】interceptorで実装した認証処理をヘルスチェックメソッドだけスキップさせる

More than 1 year has passed since last update.

はじめに

みなさんはgRPCサーバの認証処理をどのように実装されているでしょうか?
例えば、Go言語で実装する場合、grpc-ecosystemのinterceptorを利用する方法が人気があるようです。
https://github.com/grpc-ecosystem/go-grpc-middleware

interceptorで認証処理を実装するのは便利なのですが、特定のAPI(例えばヘルスチェック用API)のみを認証スキップしたい場合、どのように実装すればよいかぱっとわからなかったので、ここでまとめておきます。

本記事では、Unary Interceptorのみを対象とします。

gRPCの基本部分で自信が無い方は、よろしければ、いまさらだけどgRPCに入門したので分かりやすくまとめてみたをご覧ください。

interceptorとは

前提となるinterceptorの説明をします。
ご存知の方は飛ばしてください。

interceptorとは、認証処理、ログ、メッセージ仕様、バリデーション、リトライ、監視などのシステム開発における共通的な処理(ミドルウェア)です。
grpc-ecosystemに組み込まれているので信頼性は高そうです。
UnaryとServer Streamingに対応しています。

go-grpc-middleware配下にて各ミドルウェアのUnaryServerInterceptor関数が既に実装されています。
例えば、logrusはこのような実装になっています。

logrus/server_interceptors.go
func UnaryServerInterceptor(entry *logrus.Entry, opts ...Option) grpc.UnaryServerInterceptor {
    o := evaluateServerOpt(opts)
    return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
        startTime := time.Now()
        newCtx := newLoggerForCall(ctx, entry, info.FullMethod, startTime)

        resp, err := handler(newCtx, req)

        if !o.shouldLog(info.FullMethod, err) {
            return resp, err
        }
        code := o.codeFunc(err)
        level := o.levelFunc(code)
        durField, durVal := o.durationFunc(time.Since(startTime))
        fields := logrus.Fields{
            "grpc.code": code.String(),
            durField:    durVal,
        }
        if err != nil {
            fields[logrus.ErrorKey] = err
        }

        levelLogf(
            ctx_logrus.Extract(newCtx).WithFields(fields), // re-extract logger from newCtx, as it may have extra fields that changed in the holder.
            level,
            "finished unary call with code "+code.String())

        return resp, err
    }
}

下記は使い方の例です。
NewServer関数にUnaryInterceptor関数を渡します。
UnaryInterceptor関数にはChainUnaryServer関数を渡します。
ChainUnaryServer関数には対象ミドルウェアのUnaryServerInterceptor関数を一つ以上指定します。ここでは、色々詰めています。

main.go
import "github.com/grpc-ecosystem/go-grpc-middleware"

myServer := grpc.NewServer(
    grpc.UnaryInterceptor(grpc_middleware.ChainUnaryServer(
        grpc_ctxtags.UnaryServerInterceptor(),
        grpc_opentracing.UnaryServerInterceptor(),
        grpc_prometheus.UnaryServerInterceptor,
        grpc_zap.UnaryServerInterceptor(zapLogger),
        grpc_auth.UnaryServerInterceptor(myAuthFunction),
        grpc_recovery.UnaryServerInterceptor(),
    )),
)

また、ChainUnaryServer関数でなくWithUnaryChainServer関数を使えば、もう少しだけシンプルに実装できます。

interceptorは、必要なUnaryServerInterceptor関数を呼び出すだけで簡単にミドルウェアの実装ができるので楽です。
(ただし、各種ミドルウェアの事前設定は必要です。例えばログであればログフォーマットやログレベルなど。)

ヘルスチェックメソッドを認証スキップする

ここからが本題です。

ヘルスチェックメソッドの実装

まずは、対象となるヘルスチェックメソッドを用意します。
ヘルスチェックのIFは下記のhealth.protoの通りです。
これは、gRPC公式で用意されているprotoファイルとなります。
https://github.com/grpc/grpc/blob/master/doc/health-checking.md

pb.goをprotocコマンドで自動生成しておきます。

health.proto
syntax = "proto3";

package grpc.health.v1;

message HealthCheckRequest {
  string service = 1;
}

message HealthCheckResponse {
  enum ServingStatus {
    UNKNOWN = 0;
    SERVING = 1;
    NOT_SERVING = 2;
  }
  ServingStatus status = 1;
}

service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);

  rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}

ヘルスチェックサーバの実装は下記のhealth.goです。
ここは好きなように実装すれば大丈夫です。

  • 構造体SkipAuthHealthServerにはhealth.HealthServerインターフェースを埋め込んでいます。
  • var _ grpc_auth.ServiceAuthFuncOverride = (*SkipAuthHealthServer)(nil)により、構造体SkipAuthHealthServerはgrpc_auth.ServiceAuthFuncOverrideインターフェースを実装していることを明示的に示しています。
  • 構造体SkipAuthHealthServerはgrpc_authパッケージのAuthFuncOverrideをオーバーライドすることで、ServiceAuthFuncOverrideインターフェースを満たしています。
  • health.HealthServerインターフェースはCheckメソッドとWatchメソッドのシグネチャを持っており、構造体SkipAuthHealthServerはそれらを実装しているため、health.HealthServerインターフェースを満たしています。
  • 今回はWatchメソッドは活用していません。
health.go
type SkipAuthHealthServer struct {
    health.HealthServer
}

var _ grpc_auth.ServiceAuthFuncOverride = (*SkipAuthHealthServer)(nil)

// AuthFuncOverride SkipAuthHealthServer構造体がServiceAuthFuncOverrideインターフェースを実装する
func (*SkipAuthHealthServer) AuthFuncOverride(ctx context.Context, fullMethodName string) (context.Context, error) {
    return ctx, nil
}

func (h *SkipAuthHealthServer) Check(context.Context, *health.HealthCheckRequest) (*health.HealthCheckResponse, error) {
    // ここまで処理が来ればヘルスチェックとしては成功となる
    return &health.HealthCheckResponse{
        Status: health.HealthCheckResponse_SERVING,
    }, nil
}

func (h *SkipAuthHealthServer) Watch(*health.HealthCheckRequest, health.Health_WatchServer) error {
    return status.Error(codes.Unimplemented, "service watch is not implemented current version.")
}

interceptorを使った認証処理の実装

interceptorを使って認証処理を実装していきます。
認証方法はAPIキー認証とします。

metadata.goは、HTTP2ヘッダ(metadata)に埋め込まれたauthorizationからトークンを抽出するコードです。

metadata.go
const AuthorizationKey = "authorization"

func ExtractAuthorization(ctx context.Context) (string, error) {
    return fromMeta(ctx, AuthorizationKey)
}

func fromMeta(ctx context.Context, key string) (string, error) {
   // metadataの取得
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return "", fmt.Errorf("not found metadata")
    }

    // トークン抽出
    vs := md[key]
    if len(vs) == 0 {
        return "", fmt.Errorf("not found %s in metadata", key)
    }
    return vs[0], nil
}

auth.goは、上記でmetadataから取得したトークンが正しいかを検証してコンテキストを返すコードです。
AuthFunc関数の第二引数にfullMethodNameを持たせています。
この値が認証パスしたいサービス(今回は/grpc.health.v1.Health/Check)の場合はトークンチェックせずにnilを返し、他のサービスの場合はAPIキーが正しいものかを確認します。
fullMethodNameは自動生成されたpb.goで定義されているため、そこで確認できます。
サンプルコードなので色々ベタ書きなのはご容赦ください。

auth.go
type AuthFunc func(ctx context.Context, fullMethodName string) (context.Context, error)

func APIKeyAuth() AuthFunc {
    return func(ctx context.Context, fullMethodName string) (context.Context, error) {

        // APIキー認証をパスするサービスを指定する
        if fullMethodName == "/grpc.health.v1.Health/Check" {
            return nil, nil
        }

        // metadataからAPIキーを取得する
        authorization, err := ExtractAuthorization(ctx)
        if err != nil {
            return nil, err
        }

        APIKey := "api_key"

        // APIキーの比較
        if authorization != APIKey {
            return nil, errors.New("authentication failed")
        }

        return ctx, nil
    }
}

interceptor.goは、認証処理をgrpc.UnaryServerInterceptor型として返すことで、Chainできるようにします。

interceptor.go
func UnaryServerInterceptor(authFunc AuthFunc) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
        newCtx, err := authFunc(ctx, info.FullMethod)
        if err != nil {
            return nil, status.Error(codes.Unauthenticated, err.Error())
        }

        res, err := handler(newCtx, req)
        if err != nil {
            return nil, err
        }

        log.Info("%+v\n", res)
        return res, nil
    }
}

main.goでは、WithUnaryServerChainで認証処理を設定します。
本来は、ログ設定などもここで設定します。

main.go
s := grpc.NewServer(
    grpc_middleware.WithUnaryServerChain(interceptor.UnaryServerInterceptor(interceptor.APIKeyAuth())))

health.RegisterHealthServer(s, &h.SkipAuthHealthServer{})
reflection.Register(s)

if err := s.Serve(lis); err != nil {
    log.Fatal("failed to serve: ", err)
}       

まとめ

  • スキップしたいfullMethodNameを調べて認証処理をスキップするだけ

参考

gold-kou
NTT米屋➡︎ZOZOテクノロジーズ スクラムマスター兼バックエンドエンジニアです。 GoとかgRPCとかOpenAPIとか。 外部記憶として学んだことを記事として残しています。 私の記事は個人的なものであり、会社を代表するものではございません。
zozotech
70億人のファッションを技術の力で変えていく
https://tech.zozo.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away