# BacklogのGitを支える技術
SecHack365 2019 in Fukuoka
Yuichi Watanabe @Nulab Inc.
### Backlogが提供するGitホスティング
- 「Backlog」はGitHubのように、Gitのリポジトリをホスティングして提供している。
- Web上でリポジトリのツリーや、コミットログ、ブランチ、タグ等の情報を閲覧できる。
- プルリクエスト機能も提供しており、Web上でのチーム間のコードレビューも可能。
- Webhook機能によりリポジトリの更新情報を外部のサーバに通知して連携できる。
## Gitホスティングを支えるバックエンドの全体像
### アーキテクチャ概観
- 「Backlog」が提供するGitホスティングは、役割の異なる複数のサービスによって構成されている。
- それらのサービスの中核を担うのはGo言語である。全てのGitサービスはGoで実装されており、サービス特有のプロトコルに併せて、Goが提供する機能を活用している。
### リモートリポジトリへのHTTPアクセスを捌くGitHTTPサービス
-
git clone、push、fetch等で発生するリモートリポジトリへのHTTP通信を捌く。
-
スマートプロトコルと呼ばれる、Gitでネットワーク越しにデータを受け渡しするための規約を実装している。
-
Gitリポジトリの読み書きは、Gitコマンドのupload-packとreceive-packを実行することで実現している。
-
HTTPのBASIC認証で認証処理を行う。
### リモートリポジトリへのSSHアクセスを捌くGit SSHサービス
-
Git HTTP ServiceのSSHプロトコル版。リモートリポジトリへのSSHを使用したアクセスを捌く。
-
ユーザがBacklogにアップロードした公開鍵をもとに公開鍵認証を行う。
### リモートリポジトリへのRPCを提供するGitRPCサービス
-
Backlog Webからリモートリポジトリを操作するためののRPCを提供する。
-
提供するRPCの例
-
ブランチ一覧の取得
-
タグ一覧の取得
-
コミットログの取得
-
コミットDiffの計算
-
ツリー情報の取得
-
プルリクエスト作成
-
マージ等...
-
### Gitフックを遅延実行するGitフックワーカ
-
Gitフック(※特定のGitコマンドが実行された時に、スクリプトを実行する仕組み)の処理をジョブとしてキューに投入し、Git Hook Workersが遅延実行する。
-
ジョブの種類によってワーカを分けている。
-
ユーザのディスク使用量の確認
-
Webフックの実行
-
コミット情報の登録
-
お知らせメール送信等...
-
## 各Gitサービスの技術的な特徴
### Git HTTPの裏側、Gitスマートプロトコル
スマートプロトコルによるgit cloneの通信フロー
### Git HTTPの裏側、Gitスマートプロトコル
スマートプロトコルによるgit pushの通信フロー
### Go言語で実装するSSHサーバ
SSHプロトコルの通信フローの概観
### Go言語で実装するSSHサーバ
-
SSHの中核を担うSSHの3つのプロトコルを実装した、準標準パッケージのx/crypto/ssh を使用。
-
x/crypto/sshはサーバとしての機能は提供していない。そのため、複数のSSHのリクエストを並行に捌くデーモンプロセスとして、net/httpを参考にSSHサーバのフレームワークを実装。
-
メインループ内でTCP接続を確立したら、ゴルーチンを生成して非同期にネゴシエーション、認証、データ転送を実行する。
-
SSHのリクエストの種類としては、コマンドの単体実行を許可するexecのみをサポートする。一般的なshellアクセスは受け付けない。
func main() {
ln := createListener(port)
cfg := &ssh.ServerConfig{
PublicKeyCallback: func(conn ssh.ConnMetadata,
key ssh.PublicKey) (*ssh.Permissions, error) {
// handle public key auth
},
}
cfg.AddHostKey(privateKey)
for {
conn, err := ln.Accept()
if err != nil {
return err
}
go func() {
sConn, chans, gReqs, err := ssh.NewServerConn(
conn, cfg)
go ssh.DiscardRequests(gReqs)
for newChan := range chans {
if newChan.ChannelType() != "session" {
newChan.Reject(ssh.UnknownChannelType,
"unknown channel type")
continue
}
ch, reqs, err := newChan.Accept()
for req := range reqs {
if req.Type != "exec" {
continue
}
// handling exec request
}
}
}()
}
}
※ 簡略化したサンプルコード
### [TIPS] SSHコネクションリークを阻止するために
-
ある日、GOプロセスが保有しているゴルーチンのメトリクスを見ていると、徐々に増加していることに気づく。
-
同時にサーバ全体でESTABLISHEDなコネクションも徐々に増加していた。
-
サーバに入りnetstatでコネクションの状態とIPとその数を見ると、特定IPからの接続が多数ESTABLISHEDのままになっていた。
- 対象のIPをGit SSH サービスのログから検索すると、SSHのハンドシェイクで段階で相手が応答せずにタイムアウトしている。正常なアクセスのログはヒットしない。
// is dummy IP address
$ netstat -tan | grep ${PORT} | grep 'ESTABLISHED' | awk '{print $5}' | cut -d : -f1 | sort -n | uniq -c | sort -n
...
79 198.51.100.24
### [TIPS] SSHコネクションリークを阻止するために
- さらに、対象のIPを調査するとウクライナを示しており、abuseipdb.com でも、このIPアドレスからの脅威が多数報告されていた。
- SYNフラッド攻撃で意図的に応答を返さず、サーバリソースを枯渇させようとしている可能性あると推測。
- 対処法として、クライアントから一定時間応答が無い場合に、それを検知してコネクションを切断したい。
### [TIPS] SSHコネクションリークを阻止するために
- もともとGit SSH サービスのベースとして参考にしていたnet/httpはなにか対処しているのか?コードを読み直す。
- すると、TcpKeepAliveListenerなるリスナーを発見。カーネルの機能であるTCP KeepAliveを有効にしてくれる。
- TCP KeepAliveは、TCPで接続されたホスト間で、相手が生きているか定期的に確認し、もし一定期間応答が無ければコネクションを切断してくれる。
- net/httpのTcpKeepAliveListenerを流用して、Git SSH サービスに適用。
- 不正に滞留するコネクションはなくなり平和が戻る。
type tcpKeepAliveListener struct {
*net.TCPListener
}
func (ln tcpKeepAliveListener) Accept()
(net.Conn, error) {
tc, err := ln.AcceptTCP()
if err != nil {
return nil, err
}
tc.SetKeepAlive(true)
tc.SetKeepAlivePeriod(3 * time.Minute)
return tc, nil
}
type SSHServer struct {
// ...
}
func (srv *SSHServer) ListenAndServe(port int)
error {
ln, _ := net.Listen("tcp",
fmt.Sprintf(":%d", port))
tcpln := TcpKeepAliveListener{
ln.(*net.TCPListener)}
return srv.Serve(ln)
}
### Backlog本体とGit RPCサービスを繋ぐgRPC
なぜgRPCを使うのか
「自動生成する強い型付けのサービス間インターフェイス」
- gRPCは、デフォルトでProtocol Buffersを、型付けされたIDL(インタフェース定義言語)としてサポート。
- そのIDLで定義したデータやメソッドから各プラグラミング言語のコードへコンパイルするツールとして使用できる。
- クライアントスタブとサーバインタフェイスをProtocol Buffersで自動生成できる。
- サーバー間で通信するためのめんどくさいコードを人力で書く必要がなくなり、より効率的で安全なサービス間の通信が期待できる。
### Backlog本体とGit RPCサービスを繋ぐgRPC
「様々な言語およびプラットフォームで使用できる柔軟性」
- gRPCのクライアントとサーバーは、Windows、Linux、およびMacといった様々な環境で動作し通信できる。
- また、様々なプログラミング言語をサポートしており、一定のプラットフォームや言語にロックインされにくい。
- クライアントをScalaで実装して、サーバーはGoといった他の言語を使用することも可能。
「HTTP/2によるサービス間の効率的な通信」
-
gRPCはトランスポートプロトコルとして、HTTP/2を使用している。
-
特徴として、1つのコネクション上で複数の並列要求を多重化できることや、クライアントとサーバーの双方向で通信ができること等が上げられる。
-
gRPCはそのようなHTTP/2の機能をベースに、サービス間のストリーミングによる通信を実現してる。
-
バックエンドのBacklog本体とGit RPC サービス間の通信がより柔軟で高速になることを期待している。
### Backlog本体とGit RPCサービスを繋ぐgRPC
gRPCストリーミング
「Server-side streaming RPC」
- クライアントから1以上のリクエストを送信して、サーバーは複数のレスポンスを返却できる
「Client-side streaming RPC」
- クライアントから複数のリクエストを送信して、サーバーは一つのレスポンスを返却する。
### Backlog本体とGit RPCサービスを繋ぐgRPC
「Bidirectional streaming RPC」
- クライアントとサーバー間でから任意の数のメッセージをやりとりできる。
### Backlog本体とGit RPCサービスを繋ぐgRPC
「Bidirectional streaming RPC」
- クライアントとサーバー間でから任意の数のメッセージをやりとりできる。
チェックした商品の関連商品
### CGOを用いたCプログラム呼び出し
- Gitコマンドを直接使うと、なにかしら処理する度に毎回外部プロセスを作ることになりオーバーヘッドが発生する。また、コマンドのテキスト出力の解析も辛い。
- GitRPCサービスの役割として、コミットDiffの計算、ツリー情報の取得、プルリクエスト作成、マージ等、計算量の多い処理や、そもそもGitコマンドを使用できないもの(git mergeや プルリクエスト)もある。
-
できればGoのプログラムからGitのローレベルなAPIを使って少ないオーバーヘッドでGitリポジトリを操作したい。
- Git RPCサービスはGitリポジトリを操作するためのコアライブラリとして、C言語で開発されているlibgit2を使用している。
なぜlibgit2を使うのか
「Gitコマンドによるオーバーヘッド」
### CGOを用いたCプログラム呼び出し
「libgit2をGo言語(cgo)から実行するという選択」
package main
// #include <stdio.h>
// #include <stdlib.h>
//
// static void myprint(char* s) {
// printf("%s\n", s);
// }
import "C"
import "unsafe"
func main() {
cs := C.CString("Hello from stdio")
C.myprint(cs)
C.free(unsafe.Pointer(cs))
}
-
libgit2ならCプログラムから使える。
-
Cプログラムから使えればGo言語のcgoから使える
-
cgoとはGoからCのプログラムにアクセスするための標準パッケージ。
-
さらに、cgoを使った公式のバインディングライブラリも提供されている。
-
また、Cプログラム自体も静的リンクで1つのバイナリに含めることが可能。
### ジョブキューとしてのRabbitMQ
なぜRabbitMQを使用するのか
- 多数の言語をサポート、また、Pub-Sub、Routing、Request-Reply等の様々な機能を提供しており、GOで実装されたGitのサービス以外でも使用できる。
- 管理画面、管理APIが充実しているので、キューの状態も監視しやすい。
- 永続化できるので、ロストを許容できないジョブでも安心して使える。
- クラスタリングできるので拡張性と可用性を担保しやすい。
- 公式ドキュメントが充実しているので、正しい使い方やノウハウをキャッチアップしやすい。
- Backlogはエンタープライズ版もあるので、クラウドサービスに依存しない製品を使用したい。
- ヌーラボの他のサービスでも使用しており、得た知見をサービスを越えて伝播できる。
-
RabbitMQ(ラビットエムキュー)は、Advanced Message Queuing Protocol (AMQP) を使用した、オープンソースのメッセージ指向ミドルウェア。RabbitMQ Serverは、Erlang言語で記述されている。
### [TIPS] GO言語のチャンネルを用いたワーカの同時実行数制御
-
Goで実装されたGitフックワーカは、Goのchannel経由で受信するジョブを取得して、ゴルーチンで並行に処理する。
-
同時実行数はスケールしやすくなるが、Gitフックワーカの処理の中には一部サーバに負荷をかける重たい処理もある。それらの同時実行数を制限しなければならない。ゴルーチンは作り放題ではない。
func (w *Worker) Work() {
w.conn, _ = amqp.Dial(amqpURI)
w.ch, _ = w.conn.Channel()
// 省略...
jobs, _ := w.ch.Consume(q.Name,
consumerName, true, false,
false, false, nil)
// limiter channel
limit := make(chan struct{}, limitSize)
for job := range jobs {
limit <- struct{}{} // put in
go func(j *amqp.Delivery) {
w.Handler(j)
<-limit // put out
}(&job)
}
}
-
そこで、Go言語が提供するchannelのキャパシティを最大同時実行数として活用する。
-
キャパシティに到達したchannelに対して、さらに値を追加すると、キャパシティが空くまでブロックされる。そのため、それ以上ジョブを処理しない。
-
ほんの数行、シンプルな構文でスレッドセーフなブロッキングを実現できるのも、GO言語の強みでもある。
## まとめ
-
「Backlog」が提供するGitホスティングは、役割の異なる複数のサービスによって構成されている。
-
それらのサービスの中核を担うのはGo言語である。全てのGitサービスはGoで実装されており、サービス特有のプロトコルに併せて、Goが提供する機能を活用している。
-
net/httpパッケージをベースに、Gitのスマートプロコトルを実装したGit HTTPサービス
-
crypto/sshパッケージをベースに、SSHサーバを実装したGit SSHサービス
-
cgoでCプログラムと連携して、 gRPCでサービス間通信を行うGit RPCサービス
-
Gitフックの処理をジョブとしてRabbitMQに投入し、遅延実行するGitフックワーカ。
-
現在も新たなサービスをGo言語で開発中 三GO三GO三GO
### GO言語に支えられたGitサービス
BacklogのGitを支える技術
By Yuichi Watanabe
BacklogのGitを支える技術
- 2,695