Go Conference '20 in Autumn SENDAI
Yuichi Watanabe at Nulab Inc.
Gitホスティングの可用性を高めるためにはどのような方法があるのか?
可用性を高める手段を考える
どのようにスケールアウトさせるか
リポジトリを持つストレージ周りの課題
アプリの分割
ストレージの複製・分散の仕組みを検証
手段を実装に落とし込む
github.com/vvtanabe/git-ha-poc
Goを用いたアプリの分割
grpc-goによるサービス連携
Go製のgRPC動的プロキシ
非同期レプリケーション
手段を実装に落とし込む
github.com/vvtanabe/git-ha-poc
Goを用いたアプリの分割
grpc-goによるサービス連携
Go製のgRPC動的プロキシ
非同期レプリケーション
可用性を高める手段を考える
どのようにスケールアウトさせるか
リポジトリを持つストレージ周りの課題
アプリの分割
ストレージの複製・分散の仕組みを検証
Gitホスティングの可用性を高めるためにはどのような方法があるのか?
サーバで保持するベアリポジトリ
作業ディレクトリをもたないリポジトリ
.gitディレクトリ以下のファイル群を持つ
リポジトリのconfig、hook script等...
tagやbranch等の参照情報
Gitオブジェクト
tree, blob, commit, tag
上記のpack(圧縮データ)
├── branches
├── config
├── description
├── HEAD
├── hooks
├── index
├── info
├── objects
│ ├── 01
│ │ └── a4798bf8e9872b0ae0301657d9908a0d8bd789
│ ├── cb
│ │ └── 311c3d61afbe9f979770be8f999b66e7550ec8
│ ├── d1
│ │ └── 85f2630d528949f038e055d6b35d5954a8a0d5
│ ├── info
│ └── pack
└── refs
├── heads
│ └── master
└── tags
└── v1.0.0
限定されるストレージの種類
ストレージ間のデータの複製と一貫性
ストレージの分割
NFS等のファイルストレージは?
Gitはリポジトリの状態によってCPU、ディスクIOが跳ね上がりやすく
ブロックストレージの10倍程度遅くなる場合も
S3等のオブジェクトストレージは?
s3fs(FUSE)等のファイルシステムとしてマウントさせるツールが必要
読み書きするファイルが増えると線形に増える通信コスト
パフォーマンスを考慮するとブロックストレージ一択
一般的に複数のホストから取り扱いできない
同領域へ書き込むと衝突する恐れがある
複数のホストから安全に取り使うために
物理的に異なるストレージへ複製が必要
限定されるストレージの種類
物理的に異なるストレージ間でリアルタイムにデータを複製する安全な方法
GitそのものはMySQLやPostgreSQLといったRDBが持つレプリケーション機能は提供していない
複製データの不整合を回避したい
予期しないエラー発生時も、Gitリポジトリとしての整合性を保つ
書き込みトランザクションの担保
ストレージ間のデータの複製と一貫性
パーティショニング
データを一定の集合で異なるストレージへ分割して負荷を分散させる
適切なノードへのルーティング
処理対象のリポジトリを持つノードを動的に解決してルーティングする仕組み
データを持つストレージ分割
リポジトリをある一定の数の集合で分割して保存するストレージを分散する
=> 処理するノードの分母を増やす
分割したストレージを複製する
=> 1リポジトリに対して処理できるノードを増やす
サーバーは状態を持つ者と持たない者で分割する
=> アプリケーションとRDBミドルウェアのような関係性
Git Smart HTTP (net/http)
httpでgit pullやgit pushする経路
Basic認証、アクセス制限
Git SSH (golang.org/x/crypto/ssh)
sshでgit pullやgit pushする経路
公開鍵認証、アクセス制限
ステートレスなフロントエンド
Git Web (net/http)
リポジトリの情報をWebブラウザで表示するためのエンドポイントを提供する
Git Smart HTTP (net/http)
httpでgit pullやgit pushする経路
Basic認証、アクセス制限
Git SSH (golang.org/x/crypto/ssh)
sshでgit pullやgit pushする経路
公開鍵認証、アクセス制限
ステートレスなフロントエンド
Git Web (net/http)
リポジトリの情報をWebブラウザで表示するためのエンドポイントを提供する
Git Smart HTTP (net/http)
httpでgit pullやgit pushする経路
Basic認証、アクセス制限
Git SSH (golang.org/x/crypto/ssh)
sshでgit pullやgit pushする経路
公開鍵認証、アクセス制限
ステートレスなフロントエンド
Git Web (net/http)
リポジトリの情報をWebブラウザで表示するためのエンドポイントを提供する
ステートフルなバックエンド
Git RPC (google.golang.org/grpc)
リポジトリの読み書き
Gitリポジトリの読み書きに特化した データベースミドルウェア
gRPCで各種操作のRPCを提供
リクエストの特性に応じた通信方式の選択
大容量データの読み込み
git clone, pull, blobのダウンロード
Server Streaming RPC
One-to-many
大容量データの書き込み
git push
Client Streaming RPC
Meny-to-one
その他小さなデータのやり取り
commit, branch, tag等の情報取得
Unary RPC
One-to-one
リポジトリ単位で保存ストレージを分割
リポジトリ1〜3をストレージA
リポジトリ4〜6をストレージB
フロントからの通信を適切なノードへ中継
Git Dynamic Proxy
gRPCの動的プロキシ
データのセマンティクスは知らない
ヘッダ情報のみ読み取る
Metadata Store
リポジトリと保存先のノードの関連付けを永続化する外部永続機構
リポジトリ単位で保存ストレージを分割
リポジトリ1〜3をストレージA
リポジトリ4〜6をストレージB
フロントからの通信を適切なノードへ中継
Git Dynamic Proxy
gRPCの動的プロキシ
データのセマンティクスは知らない
ヘッダ情報のみ読み取る
Metadata Store
リポジトリと保存先のノードの関連付けを永続化する外部永続機構
ストレージ複製によるReaderノードの追加
リポジトリ1〜3をストレージa-2へ複製
リポジトリ4〜6をストレージb-2へ複製
イベント通知による非同期な複製
Replication Workerが複製イベントを受信
Readerノードの同期RPCを実行
Plane Gitコマンドを用いた安全なデータ同期
Writerのリポジトリへgit fetchを実行
git fetchのトランザクションにより予期しないエラー発生時もデータの一貫性を担保
冪等なので複数回実行しても差分のみ処理
イベント通知による非同期な複製
Replication Workerが複製イベントを受信
Readerノードの同期RPCを実行
Plane Gitコマンドを用いた安全なデータ同期
Writerのリポジトリへgit fetchを実行
git fetchのトランザクションにより予期しないエラー発生時もデータの一貫性を担保
冪等なので複数回実行しても差分のみ処理
1. UserからのPush
2. 中継先のノードを選択
3. 適切なノードへ中継
4. Replication Logを発行
5. Userへ成功レスポンス
6. Replication Logを受信
7. 同期処理開始
8. Replication Logを削除
1. UserからのPush
2. 中継先のノードを選択
3. 適切なノードへ中継
4. Replication Logを発行
5. Userへ成功レスポンス
6. Replication Logを受信
7. 同期処理開始
8. Replication Logを削除
1. UserからのPush
2. 中継先のノードを選択
3. 適切なノードへ中継
4. Replication Logを発行
5. Userへ成功レスポンス
6. Replication Logを受信
7. 同期処理開始
8. Replication Logを削除
1. UserからのPush
2. 中継先のノードを選択
3. 適切なノードへ中継
4. Replication Logを発行
5. Userへ成功レスポンス
6. Replication Logを受信
7. 同期処理開始
8. Replication Logを削除
1. UserからのPush
2. 中継先のノードを選択
3. 適切なノードへ中継
4. Replication Logを発行
5. Userへ成功レスポンス
6. Replication Logを受信
7. 同期処理開始
8. Replication Logを削除
1. UserからのPush
2. 中継先のノードを選択
3. 適切なノードへ中継
4. Replication Logを発行
5. Userへ成功レスポンス
6. Replication Logを受信
7. 同期処理開始
8. Replication Logを削除
1. UserからのPush
2. 中継先のノードを選択
3. 適切なノードへ中継
4. Replication Logを発行
5. Userへ成功レスポンス
6. Replication Logを受信
7. 同期処理開始
8. Replication Logを削除
1. UserからのPush
2. 中継先のノードを選択
3. 適切なノードへ中継
4. Replication Logを発行
5. Userへ成功レスポンス
6. Replication Logを受信
7. 同期処理開始
8. Replication Logを削除
grpc/grpc-go
gRPCの公式のGo実装
中継処理の中核となるGoの薄いライブラリ
L7のリバースプロキシとしての機能だけをgrpc-goのAPIに準拠して提供
プロキシ先を選択する処理を関数で柔軟に記述できる
本ライブラリはあくまでProof Of Concept
最新のgrpc-goのAPIに準拠するにはforkしてpatchをあてる必要あり
中継処理の中核となるGoの薄いライブラリ
L7のリバースプロキシとしての機能だけをgrpc-goのAPIに準拠して提供
プロキシ先を選択する処理を関数で柔軟に記述できる
本ライブラリはあくまでProof Of Concept
最新のgrpc-goのAPIに準拠するにはforkしてpatchをあてる必要あり
grpc/grpc-go
gRPCの公式のGo実装
プロキシサーバーの実装例
プロキシ用カスタムコーデックを登録する
プロキシするメッセージのエンコードとデコードに使用する
プロキシ用のgRPCのHandlerを作る
proxy.TransparentHandler
未登録のRPCを処理するHandlerを作る
grpc.UnknownServiceHandle
gRPCサーバーにHandlerを登録して Listen And Serve!
import (
"context"
"net"
"github.com/github.com/mwitkow/grpc-proxy"
"google.golang.org/grpc"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/metadata"
)
func main() {
encoding.RegisterCodec(proxy.NewCodec())
transparent := proxy.TransparentHandler(director)
unknown := grpc.UnknownServiceHandler(transparent)
server := grpc.NewServer(unknown)
lis, _ := net.Listen("tcp", ":50051")
_ = server.Serve(lis)
}
var director = func(ctx context.Context, fullMethodName string) (
context.Context, *grpc.ClientConn, error) {
md, _ := metadata.FromIncomingContext(ctx)
user := md.Get(":user")[0]
repo := md.Get(":repo")[0]
addr := getNodeAddr(fullMethodName, user, repo)
conn, err := grpc.DialContext(ctx, addr,
grpc.WithDefaultCallOptions(
grpc.ForceCodec(proxy.NewCodec())),
grpc.WithInsecure())
return ctx, conn, err
}
プロキシサーバーの実装例
import (
"context"
"net"
"github.com/github.com/mwitkow/grpc-proxy"
"google.golang.org/grpc"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/metadata"
)
func main() {
encoding.RegisterCodec(proxy.NewCodec())
transparent := proxy.TransparentHandler(director)
unknown := grpc.UnknownServiceHandler(transparent)
server := grpc.NewServer(unknown)
lis, _ := net.Listen("tcp", ":50051")
_ = server.Serve(lis)
}
var director = func(ctx context.Context, fullMethodName string) (
context.Context, *grpc.ClientConn, error) {
md, _ := metadata.FromIncomingContext(ctx)
user := md.Get(":user")[0]
repo := md.Get(":repo")[0]
addr := getNodeAddr(fullMethodName, user, repo)
conn, err := grpc.DialContext(ctx, addr,
grpc.WithDefaultCallOptions(
grpc.ForceCodec(proxy.NewCodec())),
grpc.WithInsecure())
return ctx, conn, err
}
プロキシ用カスタムコーデックを登録する
プロキシするメッセージのエンコードとデコードに使用する
プロキシ用のgRPCのHandlerを作る
proxy.TransparentHandler
未登録のRPCを処理するHandlerを作る
grpc.UnknownServiceHandle
gRPCサーバーにHandlerを登録して Listen And Serve!
プロキシサーバーの実装例
import (
"context"
"net"
"github.com/github.com/mwitkow/grpc-proxy"
"google.golang.org/grpc"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/metadata"
)
func main() {
encoding.RegisterCodec(proxy.NewCodec())
transparent := proxy.TransparentHandler(director)
unknown := grpc.UnknownServiceHandler(transparent)
server := grpc.NewServer(unknown)
lis, _ := net.Listen("tcp", ":50051")
_ = server.Serve(lis)
}
var director = func(ctx context.Context, fullMethodName string) (
context.Context, *grpc.ClientConn, error) {
md, _ := metadata.FromIncomingContext(ctx)
user := md.Get(":user")[0]
repo := md.Get(":repo")[0]
addr := getNodeAddr(fullMethodName, user, repo)
conn, err := grpc.DialContext(ctx, addr,
grpc.WithDefaultCallOptions(
grpc.ForceCodec(proxy.NewCodec())),
grpc.WithInsecure())
return ctx, conn, err
}
プロキシ用カスタムコーデックを登録する
プロキシするメッセージのエンコードとデコードに使用する
プロキシ用のgRPCのHandlerを作る
proxy.TransparentHandler
未登録のRPCを処理するHandlerを作る
grpc.UnknownServiceHandle
gRPCサーバーにHandlerを登録して Listen And Serve!
プロキシサーバーの実装例
import (
"context"
"net"
"github.com/github.com/mwitkow/grpc-proxy"
"google.golang.org/grpc"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/metadata"
)
func main() {
encoding.RegisterCodec(proxy.NewCodec())
transparent := proxy.TransparentHandler(director)
unknown := grpc.UnknownServiceHandler(transparent)
server := grpc.NewServer(unknown)
lis, _ := net.Listen("tcp", ":50051")
_ = server.Serve(lis)
}
var director = func(ctx context.Context, fullMethodName string) (
context.Context, *grpc.ClientConn, error) {
md, _ := metadata.FromIncomingContext(ctx)
user := md.Get(":user")[0]
repo := md.Get(":repo")[0]
addr := getNodeAddr(fullMethodName, user, repo)
conn, err := grpc.DialContext(ctx, addr,
grpc.WithDefaultCallOptions(
grpc.ForceCodec(proxy.NewCodec())),
grpc.WithInsecure())
return ctx, conn, err
}
プロキシ用カスタムコーデックを登録する
プロキシするメッセージのエンコードとデコードに使用する
プロキシ用のgRPCのHandlerを作る
proxy.TransparentHandler
未登録のRPCを処理するHandlerを作る
grpc.UnknownServiceHandle
gRPCサーバーにHandlerを登録して Listen And Serve!
プロキシサーバーの実装例
import (
"context"
"net"
"github.com/github.com/mwitkow/grpc-proxy"
"google.golang.org/grpc"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/metadata"
)
func main() {
encoding.RegisterCodec(proxy.NewCodec())
transparent := proxy.TransparentHandler(director)
unknown := grpc.UnknownServiceHandler(transparent)
server := grpc.NewServer(unknown)
lis, _ := net.Listen("tcp", ":50051")
_ = server.Serve(lis)
}
var director = func(ctx context.Context, fullMethodName string) (
context.Context, *grpc.ClientConn, error) {
md, _ := metadata.FromIncomingContext(ctx)
user := md.Get(":user")[0]
repo := md.Get(":repo")[0]
addr := getNodeAddr(fullMethodName, user, repo)
conn, err := grpc.DialContext(ctx, addr,
grpc.WithDefaultCallOptions(
grpc.ForceCodec(proxy.NewCodec())),
grpc.WithInsecure())
return ctx, conn, err
}
プロキシ用カスタムコーデックを登録する
プロキシするメッセージのエンコードとデコードに使用する
プロキシ用のgRPCのHandlerを作る
proxy.TransparentHandler
未登録のRPCを処理するHandlerを作る
grpc.UnknownServiceHandle
gRPCサーバーにHandlerを登録して Listen And Serve!
プロキシ先を決定するdirector関数の例
fullMethodName?
gRPCが内部で持つユニークなパス
例. /foo.BarService/baz
メタデータからプロキシ先のノードを決定するための情報を取得する
user, repo
fullMethodName, user, repo を元にプロキシ先のノードのアドレスを取得する
プロキシ先ノードへのコネクションを生成する
import (
"context"
"net"
"github.com/github.com/mwitkow/grpc-proxy"
"google.golang.org/grpc"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/metadata"
)
func main() {
encoding.RegisterCodec(proxy.NewCodec())
transparent := proxy.TransparentHandler(director)
unknown := grpc.UnknownServiceHandler(transparent)
server := grpc.NewServer(unknown)
lis, _ := net.Listen("tcp", ":50051")
_ = server.Serve(lis)
}
var director = func(ctx context.Context, fullMethodName string) (
context.Context, *grpc.ClientConn, error) {
md, _ := metadata.FromIncomingContext(ctx)
user := md.Get(":user")[0]
repo := md.Get(":repo")[0]
addr := getNodeAddr(fullMethodName, user, repo)
conn, err := grpc.DialContext(ctx, addr,
grpc.WithDefaultCallOptions(
grpc.ForceCodec(proxy.NewCodec())),
grpc.WithInsecure())
return ctx, conn, err
}
proxy.TransparentHandlerの実装
ServerStreamからfullMethodNameを取得する
director関数でプロキシ先ノードのコネクションを取得する
コネクションを元にClientStreamを生成する
// grpc-proxy/proxy/handler.go
var (
clientStreamDescForProxying = &grpc.StreamDesc{
ServerStreams: true,
ClientStreams: true,
}
)
func (s *handler) handler(srv interface{},
serverStream grpc.ServerStream) error {
fullMethodName, err := grpc.MethodFromServerStream(
serverStream)
outgoingCtx, backendConn, err := s.director(
serverStream.Context(), fullMethodName)
clientCtx, clientCancel := context.WithCancel(
outgoingCtx)
clientStream, err := grpc.NewClientStream(clientCtx,
clientStreamDescForProxying, backendConn,
fullMethodName)
// ...
※1 ServerStream:
フロントエンドとプロキシサーバーの双方向ストリーム
※2 ClientStream:
プロキシサーバーとバックエンドの双方向ストリーム
ServerStreamとClientStreamの双方向フォワーディング
forwardServerToClient
フロントエンドのアプリが送信するデータをバックエンドへ
forwardClientToServer
バックエンドのアプリが送信するデータをフロントエンドへ
// grpc-proxy/proxy/handler.go
// ...
s2cErrChan := s.forwardServerToClient(
serverStream, clientStream)
c2sErrChan := s.forwardClientToServer(
clientStream, serverStream)
for i := 0; i < 2; i++ {
select {
case s2cErr := <-s2cErrChan:
if s2cErr == io.EOF {
clientStream.CloseSend()
break
} else {
clientCancel()
return status.Errorf(
codes.Internal,
"failed proxying s2c: %v",
s2cErr)
}
case c2sErr := <-c2sErrChan:
serverStream.SetTrailer(
clientStream.Trailer())
if c2sErr != io.EOF {
return c2sErr
}
return nil
}
}
return status.Errorf(codes.Internal,
"gRPC proxying " +
"should never reach this stage.")
}
非同期で処理され結果はchannelを介して返される
2つのchannelをselectで待受け
io.EOFが返れば正常終了
ServerStreamとClientStreamの双方向フォワーディング
forwardServerToClient
フロントエンドのアプリが送信するデータをバックエンドへ
forwardClientToServer
バックエンドのアプリが送信するデータをフロントエンドへ
// grpc-proxy/proxy/handler.go
// ...
s2cErrChan := s.forwardServerToClient(
serverStream, clientStream)
c2sErrChan := s.forwardClientToServer(
clientStream, serverStream)
for i := 0; i < 2; i++ {
select {
case s2cErr := <-s2cErrChan:
if s2cErr == io.EOF {
clientStream.CloseSend()
break
} else {
clientCancel()
return status.Errorf(
codes.Internal,
"failed proxying s2c: %v",
s2cErr)
}
case c2sErr := <-c2sErrChan:
serverStream.SetTrailer(
clientStream.Trailer())
if c2sErr != io.EOF {
return c2sErr
}
return nil
}
}
return status.Errorf(codes.Internal,
"gRPC proxying " +
"should never reach this stage.")
}
非同期で処理され結果はchannelを介して返される
2つのchannelをselectで待受け
io.EOFが返れば正常終了
// grpc-proxy/proxy/handler.go
type frame struct {
payload []byte
}
func (s *handler) forwardServerToClient(src grpc.ServerStream,
dst grpc.ClientStream) chan error {
ret := make(chan error, 1)
go func() {
f := &frame{}
for i := 0; ; i++ {
if err := src.RecvMsg(f); err != nil {
ret <- err
break
}
if err := dst.SendMsg(f); err != nil {
ret <- err
break
}
}
}()
return ret
}
func (s *handler) forwardClientToServer(src grpc.ClientStream,
dst grpc.ServerStream) chan error {
// ...
}
中継処理の実装
ServerToClientとClientToServerも基本的な処理は同じ
srcのStreamから受信したメッセージをdetのStreamに送信する
frameとして定義されたバイト配列を持つ構造体へ受信したメッセージをデコードする
ブロックストレージに保持するリポジトリの複製と分散を検討
手段を概念実証としてGoとgRPCを用いて実装した
状態の有無でアプリケーションサーバを分割
ステートレスなフロントエンド
ステートフルなバックエンド
フロントエンドとバックエンドを繋ぐプロキシ
バックエンドのWriterとReaderでクラスタを構成した
WriterとReaderの非同期レプリケーション
Server Streaming RPC (One-to-many)
Client Streaming RPC (Meny-to-one)
Unary RPC (One-to-one)
GoConSendai> git commit -m "ご静聴ありがとうございましたʕ◔ϖ◔ʔ"