Bitbucket CloudのプライベートリポジトリをすべてGithubへ移行する | SEEDS Creators' Blog | 株式会社シーズ

Bitbucket CloudのプライベートリポジトリをすべてGithubへ移行する

クラウド事業部エンジニアの川勝です。

弊社git管理は今までBitbucket Cloudを使用していましたが、この度すべてのリポジトリをGithubへ移行いたしました。
Githubのコア機能が無料で利用可能に や有料プランも見直されて値下げがあったことで社内でGithub使いたい!との声が大きくなったのがきっかけで、
そのGithub使いたい有志でチームを組み移行作業にとりかかりました。

その中で川勝は実際にリポジトリを移す作業を担当したので今回はその方法をまとめておきたいと思います。

手順

  • Bitbucketからリポジトリ一覧取得
  • Githubにリポジトリを作成
  • BitbucketからクローンしてGithubにプッシュ
  • スクリプトにしてAmazon ECS のタスクで実行

Githubには他のバージョン管理システムからリポジトリを取り込む Github Importer という機能があります。
ただし、1リポジトリずつ手作業が必要(APIがあるのでプログラムから実行できますが)なのとプライベートリポジトリの場合は 上記にも記載されていますが、コマンドラインを使用したインポート が必要です。

したがってコマンドラインを使用したインポートの方法に沿って実行していきました。

Bitbucketからリポジトリ一覧取得

最初に対象のリポジトリ名を取得します。
サイトの画面から見てもいいのですが、330リポジトリほどあったのとこの際棚卸ししようとリポジトリの削除も実行したので簡単に一覧を取得を複数回できるようにAPIから取得しました。

#!/bin/bash

# get-repositories.sh
# 実行サンプル
# export ACCESS_TOKEN = {Bitbucketアクセストークン}
# export ORG = {リポジトリのオーガナイザー}
# bash get-repositories.sh > repositories.txt

token=${ACCESS_TOKEN}
org=${ORG}

res=$(curl -s -H "Authorization: Bearer $token" "https://api.bitbucket.org/2.0/repositories/${ORG}")
echo $res | tr -d '[:cntrl:]' | jq -r '.values[] | .name'
next=$(echo $res | tr -d '[:cntrl:]' | jq -r '.next')

while [ "$next" != "$null" ]
do
    res=$(curl -s -H "Authorization: Bearer $token" "$next")
    echo $res | tr -d '[:cntrl:]' | jq -r '.values[] | .name'
    next=$(echo $res | tr -d '[:cntrl:]' | jq -r '.next')
done

上記スクリプトを実行するまえにBitbucketからアクセストークンを取得しておく必要があります。
ドキュメント を参考に取得しておきます。

これで一覧がとれるので次はGithubへのリポジトリ作成方法です。

Githubにリポジトリ作成

リポジトリの作成は数が多いのでGithub APIから作成しました。
APIの操作ライブラリは google/go-github を使用しています。

package githubapi

import (
	"context"
	"sync"

	"github.com/google/go-github/github"
	"golang.org/x/oauth2"
)

// Github type GithubClient.
type Github struct {
	client *github.Client
}

// NewGithub new github client.
func NewGithub(token string) *Github {
	return &Github{
		client: getClient(token),
	}
}

var client *github.Client
var once sync.Once

func getClient(token string) *github.Client {
	once.Do(func() {
		ts := oauth2.StaticTokenSource(
			&oauth2.Token{AccessToken: token},
		)
		ctx := context.Background()
		tc := oauth2.NewClient(ctx, ts)
		client = github.NewClient(tc)
	})
	return client
}

// CreateRepo create repository.
func (c *Github) CreateRepo(org, name string) error {
	ctx := context.Background()
	isPrivate := true
	repo := &github.Repository{
		Name:    &name,
		Private: &isPrivate,
	}
	_, _, err := c.client.Repositories.Create(ctx, org, repo)
	if err != nil {
		return err
	}
	return nil
}

CreateRepo が実際にAPIからリポジトリを作成している箇所です。
そしてGithubもまたまた事前にアクセストークンを作成しておく必要がありますので作っておきましょう。
getClientの引数で渡しているものですね。

アクセストークンの作り方

リポジトリはできたので次はソースのクローン、プッシュを行います。

BitbucketからクローンしてGithubにプッシュ

コマンドラインから以下の手順で移行ができます。

Bitbucketからクローン

git clone --bare git@bitbucket.org:{オーガナイザー}/{リポジトリ}

Githubにpush

git push --mirror git@github.com:{オーガナイザー}/{リポジトリ}

通常は上記でOKですが、git lfs を使用している場合は以下も追加で行います。

git lfsからfetchしてpush

# git cloneしたディクレクトリで
git lfs fetch --all
git lfs push --all git@github.com:{オーガナイザー}/{リポジトリ}

これらのコマンドをプログラムから実行できるようにします。

package git

import (
	"bytes"
	"fmt"
	"os/exec"
)

const (
	bitbucketURL    = "git@bitbucket.org:%s/%s.git"
	pushCommand     = "cd %s && git push --mirror git@github.com:%s/%s.git"
	lfsFetchCommnad = "cd %s && git lfs fetch --all"
	lfsPushCommand  = "cd %s && git lfs push --all git@github.com:%s/%s.git"
)

// Clone clone --bare
func Clone(org, repo, tmp string) error {
	url := fmt.Sprintf(bitbucketURL, org, repo)
	cmd := exec.Command("git", "clone", "--bare", url, tmp)
	return run(cmd)
}

// Push push --mirror
func Push(org, repo, tmp string) error {
	pushCmd := fmt.Sprintf(pushCommand, tmp, org, repo)
	cmd := exec.Command("bash", "-c", pushCmd)
	return run(cmd)
}

// LfsFetch lfs fetch --all
func LfsFetch(tmp string) error {
	fetchCmd := fmt.Sprintf(lfsFetchCommnad, tmp)
	cmd := exec.Command("bash", "-c", fetchCmd)
	return run(cmd)
}

// LfsPush lfs push --all
func LfsPush(org, repo, tmp string) error {
	pushCmd := fmt.Sprintf(lfsPushCommand, tmp, org, repo)
	cmd := exec.Command("bash", "-c", pushCmd)
	return run(cmd)
}

func run(cmd *exec.Cmd) error {
	var out bytes.Buffer
	var stderr bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		fmt.Println(stderr.String())
		return err
	}
	return nil
}

あとはこれらを呼び出して実行したら完成です!

package action

import (
	"fmt"
	"os"
	"path/filepath"
	"sync"

	"github.com/seeds-std/migration-from-bitbucket/git"
	"github.com/seeds-std/migration-from-bitbucket/githubapi"
	"github.com/seeds-std/migration-from-bitbucket/slackwebhook"
)

// One exec one repositry.
func One(c *githubapi.Github, org, repo string, haveLfs bool) error {
	fmt.Printf("create repository: %s\n", repo)
	if err := c.CreateRepo(org, repo); err != nil {
		return fmt.Errorf("error: do not create repository. %+v", err)
	}
	tmp := filepath.Join("/tmp", repo)
	if err := os.Mkdir(tmp, 0777); err != nil {
		return fmt.Errorf("error: do not create tmp directory. %+v", err)
	}
	defer os.RemoveAll(tmp)

	fmt.Printf("clone repository: %s\n", repo)
	if err := git.Clone(org, repo, tmp); err != nil {
		return fmt.Errorf("error: do not clone repository. %+v", err)
	}

	fmt.Printf("push repository: %s\n", repo)
	if err := git.Push(org, repo, tmp); err != nil {
		return fmt.Errorf("error: do not push repository. %+v", err)
	}

	if haveLfs {
		fmt.Printf("lfs fetch repository: %s\n", repo)
		if err := git.LfsFetch(tmp); err != nil {
			return fmt.Errorf("error: do not lfs fetch repository. %+v", err)
		}

		fmt.Printf("lfs push repository: %s\n", repo)
		if err := git.LfsPush(org, repo, tmp); err != nil {
			return fmt.Errorf("error: do not lfs push repository. %+v", err)
		}
	}

	return nil
}

// All exec all repositories.
func All(
	c *githubapi.Github,
	org string,
	target []string, // Bitbucketから取得したリポジトリ一覧
	lfs map[string]bool, // lfsを使用しているリポジトリ
	ignore map[string]bool, // テストで移行済みのものがあるので、処理しないリポジトリ
	webhook *slackwebhook.SlackWebhook,
) {
	limit := make(chan struct{}, 5)
	var wg sync.WaitGroup
	for _, repo := range target {
		wg.Add(1)
		go func(repo string) {
			limit <- struct{}{}
			defer wg.Done()
			if _, ok := ignore[repo]; ok {
				fmt.Printf("skip ignore repository: %s\n", repo)
				<-limit
				return
			}
			_, haveLfs := lfs[repo]
			if err := One(c, org, repo, haveLfs); err != nil {
				fmt.Printf("%s\n", err.Error())
				webhook.NotifyError(org, repo, haveLfs, err)
				<-limit
				return
			}
			webhook.NotifySuccess(org, repo, haveLfs)
			<-limit
		}(repo)
	}
	wg.Wait()
}

Oneで1つずつリポジトリを移行、Allで受け取った一覧からループして実行しています。
ちなみにAllの引数にリポジトリ名の一覧がありますが、今回は一度しか実行しないので予めslice or mapにハードコードしておいて渡してたりします。

これをmainでaction.Allを実行する形にしておきます。

Amazon ECS のタスク

さて最後に実行方法ですが、ローカルのPCでやるのは時間かかるし、git クローンとpushでネットワーク回線を圧迫しそうだったのでAWSのサービスを使用したいと考えました。

  • AWS Lambda
  • AWS Batch
  • Amazon ECS

AWS Lambda

料金的に抑えられそう。
最大実行時間が15分なので全リポジトリを一回の実行では不可能だが、
ただ1リポジトリごとの実行であれば並列で一気に実行できそう…?
と考えましたが、git cloneするには /tmp に展開する必要がありますがAWS Lambdaでは /tmp は500MBまで、という制限がありました。
git lfs使っているくらい巨大なリポジトリもあるので無理だな、、、ということで没。

AWS Batch

バッチ処理といえばAWS Batchがあるな、と設定を始めました。
AWS BatchはAmazon EC2 インスタンスを立ち上げないといけないのですが、
スペックどうしようかな、、というのとどうも無料枠で使えるインスタンスタイプがなさそうだぞ、、、ということで途中でやめてしまいました。
今回のような1つのスクリプト実行するだけにはちょっと設定が多い感じですね。

Amazon ECS

Amazon ECS で AWS Fargateを使用すればインスタンスの立ち上げとか考える必要がない、dockerを起動したら実行されるとだけしておけばよい、、、で、最終的にこちらにしました。

Amazon ECSで実行するにはDockerコンテナが必要です。(AWS Batchもですが)
Dockerfileをつくりましょう。

FROM golang:1.14.4-alpine3.12

RUN apk add --no-cache \
    git \
    git-lfs \
    make \
    bash \
    gcc \
    libc-dev \
    openssl \
    curl \
    openssh

WORKDIR /go/src/migration-from-bitbucket
COPY . .

RUN go get -d -v ./...
RUN go install -v ./...

RUN mkdir -p /root/.ssh && cp -r ./id_rsa ~/.ssh/id_rsa && chmod 600 /root/.ssh/id_rsa
RUN echo "StrictHostKeyChecking no" >> /root/.ssh/config

CMD ["migration-from-bitbucket", "all"]

migration-from-bitbucket all
と実行すればOKなようになっています。
gitのプライベートリポジトリの操作が必要なので、SSH秘密鍵を配置しています。BitbucketとGithubを同じ鍵で操作できるようにしています。

これでdocker build できたらAmazon ECR にdocker pushしてAmazon ECSタスクから使えるようしたら準備完了です。
Amazon ECRの作成は割愛しますが、起動タイプをFargateにしてタスクを実行するだけになります。

以下記事が参考になると思います。
CircleCI + GitHub + Amazon Elastic Container Registry (Amazon ECR) + Amazon Elastic Container Service (Amazon ECS) (+ AWS Fargate) で継続的デリバリー環境を構成する

コンテナのvCPUとかmemoryは最低にしていましたが、実行したら2時間弱くらいで完了しました。
(ちなみに終わってからGithubだと1ファイルの容量上限が100MBなようで、
該当するリポジトリはエラーになってたのですが… 大容量ファイルの制限

まとめ

調べているとBitbucketからGithubへ移行したという記事は結構みかけました。
そのなかで全てのプライベートリポジトリを一気に移行した、ということで今回記事にまとめてみました。

実際この記事執筆時点ではリポジトリは移したけど各人は絶賛作業中なので、
弊社としての使いがってがどうなったかなどはまだこれからという感じですが
個人的には良好です。

CI機能のGithub actionsをこれから使っていきたいのでまたそちらも記事にしたいと思います。

以上、川勝でした。