Git LFS in Go

Yuichi Watanabe at Nulab Inc.

BacklogにおけるGoの活用事例

Who Am I?

me := Profile{
  Name:    "Yuichi Watanabe",
  Org:     "Nulab Inc.",
  Job:     "SRE/Backend",
  Twitter: "vvvatanabe",
  GitHub:  "vvatanabe",
  Lives:   "Fukuoka",
}

Agenda

  • BacklogにおけるGoの活用事例

  • Git LFSとは

  • Goを採用したモチベーション

  • アーキテクチャ概観

  • 開発Tips

  • まとめ

BacklogにおけるGoの活用例

  • 一枚岩なScala製のアプリが大黒柱

  • 機能によって小規模なアプリが複数

  • 特にGitホスティング周りでGoを多用

    • net/http

    • grpc/grpc-go

    • x/crypt/ssh

    • cgo

  • 先月Go1.16へアップデート完了:beer: 

Gitホスティング in Go

詳細は以下のスライドへ

補助ツール in GO

BacklogにおけるGoの活用例

Agenda

  • BacklogにおけるGoの活用例

  • Git LFSとは

  • Goを採用したモチベーション

  • アーキテクチャ概観

  • 開発Tips

  • まとめ

Git LFSとは

Gitが苦手な大きなファイルを効率的に取り扱うための拡張


特徴

  • 大きなファイルを必要な分だけダウンロード

    • ワークツリーに展開する時にそのバージョンで必要なファイルだけダウンロード

  • 大きなファイルの実体をリポジトリの外で管理

    • リポジトリにはポインタファイルのみ

    • 実体は任意のストレージへ分けて管理

Git LFSの仕組み

  • Git LFSコマンドとGit LFSサーバーがHTTPで通信する

  • Git LFSコマンドはGitのフィルター機能とGitフックを使って適切な処理を挟む

    • clean、smudgeフィルター

    • pre-pushフック

  • Git LFSサーバーは規約に従いAPIを提供する

    • Batch API

    • Basic Transfer API

    • Locking API [Option]

       

  • ​上記のGit LFSサーバーをGoで実装した

Git LFSとは

Agenda

  • BacklogにおけるGoの活用例

  • Git LFSとは

  • Goを採用したモチベーション

  • アーキテクチャ概観

  • 開発Tips

  • まとめ

Goを採用したモチベーション

  • Gitホスティングの各サービスを単一言語で構成したい

    • 今までの知見を活かす

      • 監視、デバッグ、開発Tips...
         

  • OSSで公開されているGit LFSコマンドがGoで実装されている

    • ソースコードレベルで挙動を把握しやすい
       

  • コンテナで稼働させやすい

    • ベースイメージにバイナリを含めるだけ

    • 省メモリ、効率的なCPUの使用
       

  • 簡潔な並列処理で大きなファイルの効率よく捌きたい

    • goroutine、channel、syncパッケージ

Agenda

  • BacklogにおけるGoの活用例

  • Git LFSとは

  • Goを採用したモチベーション

  • アーキテクチャ概観

  • 開発Tips

  • まとめ

アーキテクチャ概観

  • 実行環境:

    • Amazon EKS

  • ファイルの保存先:

    • Amazon S3 (実体)

    • Amazon Aurora (メタ情報)

  • 監視:

    • Prometeus

    • Grafana

    • Mackerel

※その他のEKS上で稼働しているアプリは省略

Agenda

  • BacklogにおけるGoの活用例

  • Git LFSとは

  • Goを採用したモチベーション

  • アーキテクチャ概観

  • 開発Tips

  • まとめ

  • Git LFSサーバーは大きなファイルの取り扱いが前提
  • 送受信するデータをメモリを一度に展開するとリソースの枯渇に繋がる

io.CopyBufferとsync.Poolを使ったデータ転送の効率化

// global vars
var bufPool = sync.Pool{
  New: func() interface{} {
  	return make([]byte, size)
  },
}

func main() {
  // プールになければ新規生成される
  buf := bufPool.Get().([]byte)
  
  // 取得したオブジェクトはdeferで確実にプールへ返却する
  defer bufPool.Put(bufIn)

  // srcからbufのサイズずつ読み取りdstに書き込む
  size, err := io.CopyBuffer(dst, src, buf)
  
  //...
}
  • 「コピー元」src から「バッファ」buf のサイズずつ読み取り「コピー先」dst に書き込む

  • src を一度に展開しないのでアロケーションを抑えられメモリ効率が良い

  • buf のサイズをチューニングする余地もある

io.CopyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error)
sync.Pool
  • スレッドセーフなオブジェクトプール

  • フィールドのNewにオブジェクトを生成する関数を定義する

    • Getメソッド呼び出し時にオブジェクトがNewで生成される

  • io.CopyBufferへ渡すbufをこのオブジェクトプールに持たせる

マルチアップロード by aws-sdk-go

  • ファイルの実体はS3に保持しているためaws-sdk-goを使用している

  • aws-sdk-goには大きなファイルを効率的に転送する仕組みがある

  • 転送するファイルを分割してマルチアップロードする仕組みを追ってみる

func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }

マルチアップロード by aws-sdk-go

  • 設定値をcapacityにしたchannelを作る

  • 設定値の数だけgoroutineを作る

  • goroutineはreadChunk()にchannelを渡す

    • channel経由でchunkが伝播届くのを待つ

    • chunkが届いたらsendする

func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }

マルチアップロード by aws-sdk-go

  • 設定値をcapacityにしたchannelを作る

  • 設定値の数だけgoroutineを作る

  • goroutineはreadChunk()にchannelを渡す

    • channel経由でchunkが伝播届くのを待つ

    • chunkが届いたらsendする

func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }

マルチアップロード by aws-sdk-go

  • 設定値をcapacityにしたchannelを作る

  • 設定値の数だけgoroutineを作る

  • goroutineはreadChunk()にchannelを渡す

    • channel経由でchunkが伝播届くのを待つ

    • chunkが届いたらsendする

func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }
func (u *multiuploader) readChunk(ch chan chunk) {
  defer u.wg.Done()
  for {
    data, ok := <-ch
    if !ok {
      break
    }
    if u.geterr() == nil {
      if err := u.send(data); err != nil {
        u.seterr(err)
      }
    }
    data.cleanup()
  }
}

マルチアップロード by aws-sdk-go

  • 設定値をcapacityにしたchannelを作る

  • 設定値の数だけgoroutineを作る

  • goroutineはreadChunk()にchannelを渡す

    • channel経由でchunkが伝播届くのを待つ

    • chunkが届いたらsendする

func (u *multiuploader) readChunk(ch chan chunk) {
  defer u.wg.Done()
  for {
    data, ok := <-ch
    if !ok {
      break
    }
    if u.geterr() == nil {
      if err := u.send(data); err != nil {
        u.seterr(err)
      }
    }
    data.cleanup()
  }
}
func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }

マルチアップロード by aws-sdk-go

  • 設定値をcapacityにしたchannelを作る

  • 設定値の数だけgoroutineを作る

  • goroutineはreadChunk()にchannelを渡す

    • channel経由でchunkが伝播届くのを待

    • chunkが届いたらsendする

func (u *multiuploader) readChunk(ch chan chunk) {
  defer u.wg.Done()
  for {
    data, ok := <-ch
    if !ok {
      break
    }
    if u.geterr() == nil {
      if err := u.send(data); err != nil {
        u.seterr(err)
      }
    }
    data.cleanup()
  }
}
func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }

マルチアップロード by aws-sdk-go

  • forループ内で送信するデータを読み続ける

    • nextReaderでreq.Bodyを分割して取得

    • shouldContinueで処理を継続するか判定

    • 分割取得したデータをchunkとしてchannel経由で送信用のgoroutineに渡す

  • データの分割読み取りが完了したらループを抜ける

  • channelをcloseする

    • 読み取り先のgoroutineに読み取り終了を通知

  • sync.WaitGroupのWait()を実行する

    • データ転送中の全てのgoroutineの終了を待つ

func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }

マルチアップロード by aws-sdk-go

  • forループ内で送信するデータを読み続ける

    • nextReaderでreq.Bodyを分割して取得

    • shouldContinueで処理を継続するか判定

    • 分割取得したデータをchunkとしてchannel経由で送信用のgoroutineに渡す

  • データの分割読み取りが完了したらループを抜ける

  • channelをcloseする

    • 読み取り先のgoroutineに読み取り終了を通知

  • sync.WaitGroupのWait()を実行する

    • データ転送中の全てのgoroutineの終了を待つ

func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }

マルチアップロード by aws-sdk-go

  • forループ内で送信するデータを読み続ける

    • nextReaderでreq.Bodyを分割して取得

    • shouldContinueで処理を継続するか判定

    • 分割取得したデータをchunkとしてchannel経由で送信用のgoroutineに渡す

  • データの分割読み取りが完了したらループを抜ける

  • channelをcloseする

    • 読み取り先のgoroutineに読み取り終了を通知

  • sync.WaitGroupのWait()を実行する

    • データ転送中の全てのgoroutineの終了を待つ

func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }

マルチアップロード by aws-sdk-go

  • forループ内で送信するデータを読み続ける

    • nextReaderでreq.Bodyを分割して取得

    • shouldContinueで処理を継続するか判定

    • 分割取得したデータをchunkに包み、channel経由で送信用のgoroutineに渡す

  • データの分割読み取りが完了したらループを抜ける

  • channelをcloseする

    • 読み取り先のgoroutineに読み取り終了を通知

  • sync.WaitGroupのWait()を実行する

    • データ転送中の全てのgoroutineの終了を待つ

func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }

マルチアップロード by aws-sdk-go

  • forループ内で送信するデータを読み続ける

    • nextReaderでreq.Bodyを分割して取得

    • shouldContinueで処理を継続するか判定

    • 分割取得したデータをchunkに包み、channel経由で送信用のgoroutineに渡す

  • ​データの分割読み取りが完了したらループを抜ける

  • channelをcloseする

    • 読み取り先のgoroutineに読み取り終了を通知

  • sync.WaitGroupのWait()を実行する

    • ​データ転送中の全てのgoroutineの終了を待つ

func (u *multiuploader) upload(
  firstBuf io.ReadSeeker,
  cleanup func()) (*UploadOutput, error) {
  
  // ... 省略
  ch := make(chan chunk, u.cfg.Concurrency)
  for i := 0; i < u.cfg.Concurrency; i++ {
    u.wg.Add(1) // sync.WaitGroup
    go u.readChunk(ch)
  }
  
  // ... 省略
  for u.geterr() == nil && err == nil {
    var (
      reader       io.ReadSeeker
      nextChunkLen int
      ok           bool
    )
    reader, nextChunkLen, cleanup, err = u.nextReader()
    ok, err = u.shouldContinue(num, nextChunkLen, err)
    if !ok {
      cleanup()
      if err != nil {
        u.seterr(err)
      }
      break
    }
    num++
    ch <- chunk{buf: reader, num: num, cleanup: cleanup}
  }
  
  close(ch)
  u.wg.Wait()
  // ... 省略
 }

再起動時の瞬断をできるだけ避ける

  • 大きなファイルの処理が多いので時間がかかりやすい

  • リリースでコンテナを入れ替える時の切断を減らしたい

    • 古いコンテナの処理をできるだけ継続させる

      • Pod停止時のTERMシグナルを起点にGraceful Shutdownする

// go versio >= 1.16
srv := &http.Server{
  // ..
} 
go srv.Serve(ln)

// got 1.16から追加された
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
<-ctx.Done()

err = srv.Shutdown(context.Background())
// go version < 1.16
srv := &http.Server{
  // ..
} 
go srv.Serve(ln)

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM)

<-sigCh

err = srv.Shutdown(context.Background())

Goプロセスの監視

 

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
    "github.com/gorilla/mux"
)

r := mux.NewRouter()
r.Path("/metrics").
    Methods(http.MethodGet).Handler(promhttp.Handler())


prometheus.MustRegister(&DBStatsCollector{DB: db})
  • BacklogにおけるGoの活用例

  • Git LFSとは

  • Goを採用したモチベーション

  • アーキテクチャ概観

  • 開発Tips

  • まとめ

Agenda

まとめ

  • Backlogの「Gitホスティング」はGoに支えられている

  • Gitの「大きなファイル苦手」問題の緩和策としてGit LFS サーバーをGoで実現した

  • Git LFSコマンド自体もGoで開発されている

  • Go標準の強力な標準パッケージで大きなファイルも十分効率的に取り扱える

  • aws-sdk-goのマルチアップロードの仕組みにgoroutineとchannelの理解が深まった

  • 各種ミドルウェアの手厚いサポートでGoプロセスの状態を監視・可視化が捗った

    • Prometeusの公式Goエクスポーター

    • GrafanaのGoダッシュボードテンプレート

WE ARE HIRING

ヌーラボやBacklogの開発に興味を持っていただいた方!

是非お話ししてみませんか?

shuuu-mai> git commit -m "ご静聴ありがとうございましたʕ◔ϖ◔ʔ"

Git LFS in Go

By Yuichi Watanabe