출처: https://uxgjs.tistory.com/102 [UX 공작소]
스퀘어랩 기술블로그 로고
Engineering

Gerrit에 Git LFS 설정하기

전지훈|Jun 14, 2024

Git LFS(Large File System) 는 Git에서 대용량 파일을 관리하기 위한 익스텐션입니다. 저장소에 미디어, 샘플텍스트 등 용량이 큰 파일이 있는 경우 이 기능을 이용하면 대용량파일을 좀더 손쉽게, 작은 단위로 관리할 수 있습니다.

Git LFS

이 기능은 기본적으로 대용량 파일을 커밋에 직접 올리지 않고 전용 스토리지에 올려놓은 뒤, 커밋은 해당 파일에 대한 포인터만 갖고 있는다는 개념에서 출발합니다. 저장소에 직접 대용량 파일을 올리지 않고, 텍스트로 된 포인터만 올리므로 커밋 자체가 작아질테고, 저장소 자체의 크기도 대용량파일 몇개 때문에 불필요하게 커지지 않게 됩니다.

많이 사용하는 GitHub과 같은 서비스들도 LFS를 지원하므로 필요하면 간단하게 설정해서 쓸 수 있습니다.

Github은 1GB의 스토리지와, 1GB/월 의 bandwidth를 무료로 지원한다고 합니다.

저는 스퀘어랩에서 CI/CD 툴인 CircleCI의 PoC를 진행하려고 하던 중 필요에 의해 사용 중인 Gerrit에 LFS를 적용하게 되었고, 특히 기존에 쌓여 있던 4만여개의 커밋들을 LFS가 적용된 버전으로 migration하는 과정에서 꽤나 고통을 겪었기에, 그 과정을 위주로 공유하려고 합니다.

Install

LFS의 로컬 설치는 매우 간단합니다.

특히 맥을 사용한다면 아래 두 줄로 로컬에 git-lfs를 설치하고, 사용할 repository에 install하는 것이 끝입니다.

$ brew install git-lfs
$ git lfs install
Updated Git hooks.
Git LFS initialized.

install 과정에서 git hook이 자동으로 설치되는데, 아마 대용량파일을 저장소에 올리거나, 포인터정보만 있는 lfs 파일정보를 fetch할 때 lfs 스토리지에서 실제 lfs object를 받아오기 위한 훅을 설정해 주는 것으로 생각됩니다. 자세히 찾아보진 않았지만 더 잘 아시는 분이 계신다면 커멘트 주시면 감사하겠습니다.

이 과정이 없다면, 혹은 제대로 동작하지 않는다면, 대용량파일을 lfs object로 트래킹하기 위한 git lfs track이나 포인터로부터 lfs object를 받아오기 위한 git lfs pull 을 수동으로 수행해 주어야 합니다.

이렇게 설정이 끝났다면 이후 추가되는 대용량 파일들은 git hook을 통해 알아서 lfs 스토리지에 업로드된 후 커밋의 원본파일은 포인터 정보만 가지고 있도록 변환됩니다. 물론 fetch할 때에도 이 포인터 정보를 가지고 lfs 스토리지에서 object를 받아오게 되고요.

Gerrit

스퀘어랩은 VCS로 Gerrit을 사용하고 있습니다. Gerrit은 다행히 자체 LFS 플러그인을 지원하므로 설정의 난이도가 높지는 않습니다.

Plugin install

Gerrit의 상단 메뉴 Plugins > Manage 에 들어가면 아래와 같이 클릭 한 번으로 LFS 플러그인을 설치할 수 있습니다.

install

이제 Gerrit에 lfs config을 해주고, lfs request를 플러그인을 통해 처리하도록 해 주면 됩니다.

LFS config

Gerrit은 플러그인들에 대한 컨픽을 All-Projects 프로젝트의 refs/meta/config 브랜치에 @{플러그인명}.config 파일로 관리하도록 하고 있습니다.

위와 같이 설정하게 되면 프로젝트별로 플러그인 설정을 할 수 있고, 글로벌한 플러그인 설정은 $GERRIT_SITE/etc/@PLUGIN@.config 을 통해 할 수도 있습니다.

이에 따라 아래와 같은 파일을 lfs.config 에 올려두면 됩니다.

[lfs "^.*"] # 플러그인이 모든 프로젝트에 적용됨
  enabled = true
  maxObjectSize = 500m # LFS가 500Mb까지 파일을 처리할 수 있음
  backend = fs # LFS가 파일을 자체 filesystem에 저장함(s3를 선택할 수도 있음)

여기까지 설정을 마치면 Browse > All-Projects > General의 최하단 정보를 통해 Gerrit에 LFS plugin이 설정되었음을 알 수 있습니다.

projects

plugins

LFS plugin config

이후 Gerrit이 lfs plugin을 사용해 lfs request를 처리하도록 설정해 주어야 하는데요, Gerrit의 Core config인 @GERRIT_SITE/etc/gerrit.config 에 아래 lfs 섹션을 추가해 줍니다.

[lfs]
  plugin = lfs

Migration

LFS를 새로 런칭하는 프로젝트의 깨끗한 저장소에 처음부터 설치한다면 이후로의 내용은 (운좋게도) 해당사항이 없습니다. 하지만 이미 4만여개의 커밋이 쌓여 있고 서비스 중인 프로젝트의 코드베이스에 LFS를 적용하는 것은 꽤나 골치아픈 일입니다.

간단하게 생각해 봐도 아래 과정을 거쳐야 하죠.

  1. 기존 커밋들에 이미 존재하는 대용량파일들 파악
  2. 해당 파일들을 LFS object로 track하도록 커밋을 수정 - 이 부분이 가장 골치가 아픕니다
  3. 다른 작업자가 LFS object를 신경쓰지 않고 transparent하게 계속해서 작업할 수 있도록 알맞은 방법으로 저장소에 반영

하나 다행인건 git-lfs에는 migrate라는 기능이 있어 이를 활용하면 한땀 한땀 해야 하는건 아닙니다.

git lfs migrate --help 명령어를 통해 유용한 옵션들을 확인할 수 있습니다.

Inspection

우선 우리가 트래킹해야 하는 대용량 파일들을 찾아내 봅시다.

git lfs migrate info 명령어를 사용하면 쉽게 찾아낼 수 있는데요, 아래 몇 가지 옵션들만 알고 있으면 사용에 무리는 없습니다.

  • --include-ref
    • 어떤 브랜치를 기준으로 할지 결정합니다. 스퀘어랩은 single branch 전략을 쓰고 있어 master브랜치만 확인하면 되었기에 --include-ref=master 옵션을 주었습니다. 브랜치가 여러개라면 --include-ref=master,staging 과 같이 적어주면 됩니다.
  • --everything
    • 존재하는 모든 브랜치들을 대상으로 합니다.
  • --pointers=ignore
    • 이미 존재하는 LFS object pointer는 무시합니다.
  • --above
    • 특정 크기 이상의 파일만 inspect하는 아주 유용한 기능입니다. 보통은 Github에서 100MB까지만 대용량파일을 허용하니 --above=100MB 와 같이 적어주면 되겠습니다.
  • --include/--exclude
    • 포함, 제외할 glob 패턴을 --include="*.json,*.wav"와 같이 지정할 수 있습니다. 보통은 미디어 등 특정 확장자의 파일들이 주로 LFS의 대상이 되므로 유용한 옵션입니다. --above 옵션과는 함께 사용할 수 없습니다.
$ git lfs migrate info --include-ref=master --above=100MB
migrate: Sorting commits: ..., done.
migrate: Examining commits: 100% (44559/44559), done.
*.json     	150 MB	1/13652 files	0%

LFS Objects	0 B   	  0/545 files	0%

스퀘어랩의 마스터 브랜치에는 성가시게도 150메가짜리 json파일이 딱 한개 있는 것으로 확인되었습니다.

이 파일은 현재는 존재하지 않지만, 과거의 CL에 한번 commit되었던 적이 있었던 것으로, 이후 여러개의 파일로 쪼개어 다시 commit 된 것으로 판단됩니다.

Handle

여기에서 어떻게 이 파일을 처리할지 갈림길이 나타나는데 저는 크게 세 가지 옵션을 고려했습니다.

앞의 두 가지 옵션은 git lfs migrate import 옵션을 이용한 방식이고, 마지막 세 번째는 별도의 툴을 이용한 방식입니다.

영향 받는 저장소의 전체 commit을 rewrite

말 그대로 영향 받은 파일이 속하는 커밋 이후의 커밋들을 모두 한번더 쓰는 방식입니다. 태초로 돌아가서 커밋들을 하나하나 훑으며 조건에 맞는 대용량 파일이 있으면 git lfs track 처리를 해서 LFS object로 처리해 주고 넘어가는 식이죠.

다행히도 아래 명령어 한방이면 알아서 앞서 말한 처리를 해주므로, 해당 시점에서 오브젝트를 트래킹한 것과 같은 효과를 볼 수 있습니다.

$ git lfs migrate import --include-ref=master --above=100MB
// 모든 태그들을 새로운 커밋 기준으로 새로 붙여줍니다
migrate: Updating refs: ..., done.
migrate: checkout: ..., done.

이 때에도 info와 동일한 옵션들을 사용할 수 있고, 추가로 아래에서 설명할 특별한 옵션들이 있습니다.

diff

오른쪽이 명령어 수행 전, 왼쪽이 명령어 수행 후의 로그이다. 완전히 동일한 커밋이 해쉬만 다르게 새로 생성되고, 태그까지 동일하게 달려있는 것을 확인할 수 있다.

명령어를 수행하고 나면 조금의 시간이 흐른 후 위와 같이 완전히 새로운 커밋들의 복사본이 기존의 HEAD 위에 쌓이게 됩니다. 스퀘어랩 저장소의 경우 원래 44875개였던 커밋 갯수가 51861개로 늘어난 것을 확인할 수 있었는데요, 6986 번째 최신 CL 안에 영향받는 파일이 존재했던 것으로 추측 해볼 수 있습니다.

이 상태에서 그대로 저장소에 git push -f 를 해주면 끝입니다. 이제 master 브랜치를 fetch받게 되면 방금 추가로 쌓아둔 커밋들을 포함해 꽤나 많은 수의 커밋을 한번에 받아오게 되고, 로컬 저장소도 LFS가 적용된 상태가 됩니다.

이 방법은 현재 저장소의 상태를 깨뜨리지 않는 가장 안전한 방법으로 생각됩니다. 하지만 스퀘어랩과 같이 커밋의 수가 많은 경우, 혹은 더 많은 경우에는 한번에 처리하는 커밋의 양이 많기 때문에 저장소의 크기가 최대 두배가 되고, 다른 작업자들이 한 번에 많은 커밋을 받아 rebase해야 하는 등 단점이 있습니다.

사실 4만여개의 커밋을 한번에 push할 때의 심적 쫄림이 큽니다.

무엇보다도 이 방법을 채택하지 않은 이유는 몇 가지가 있는데요, 위 과정을 모두 거친 이후 git push -f 를 해야 하는데, 최초에 저장소를 clone할 때 ssh로 했다면 아래와 같은 에러가 발생합니다.

$ git push -f (실제로는 git lfs push 과정에서 나는 에러입니다)
Pushing to ssh://repository.squarelab.co/squarelab
Uploading LFS objects:   0% (0/1), 0 B | 0 B/s, done.
batch response: Repository squarelab not found
error: failed to push some refs to 'ssh://repository.squarelab.co/squarelab'

이는 git lfs가 http 위에서만 동작하기 때문인데요 (최근에 ssh에서도 동작하도록 바뀌었다는 이야기가 있지만 저는 여전히 되지 않았습니다), 이를 해결하기 위해서는 http로 clone 받은 별도의 저장소에서 위 과정을 다시 수행해야 합니다.

하지만 그렇게 해봤을 때에도 문제가 있었는데요, git lfs hook이 커밋을 하나하나 뒤져가며 lfs object가 있는지 없는지 확인을 하기 때문에 4만개가 넘는 커밋을 처리하는데 너무 시간이 오래 걸렸습니다. 저의 경우 12시간 까지는 돌려 봤는데 너무 심하게 오래걸려서 중간에 포기해야 했습니다.

영향 받는 lfs object만 모아서 별도로 commit

스퀘어랩의 저장소에는 7년여간 쌓여온 44000여개의 커밋들이 존재합니다. 위 migration을 돌리게 되면 파일 하나 빼놓고 아예 동일한 커밋들이 44000개 더 생기는거라 좀 부담이 되었습니다. 그래서 생각한 두 번째 옵션이 바로 --no-rewrite 옵션입니다. git lfs migrate 에 이 옵션을 주게 되면 모든 커밋에서 영향 받은 파일들을 LFS object로 트래킹하는 커밋 한개를 만들어 줍니다.

아마 저의 케이스처럼 영향 받는 파일이 적은 경우에 유용할 옵션이라고 생각되는데요, 처음 생각한 명령어는 아래와 같습니다.

$ git lfs migrate import --no-rewrite --above=100MB "*.json"
migrate: override changes in your working copy?  All uncommitted changes will be lost! [y/N] y
migrate: changes in your working copy will be overridden ...
No Git LFS filters found in '.gitattributes'

하지만 처음으로 LFS를 적용할 때 이 명령어를 수행하면 위와 같은 에러가 발생합니다.

이 에러는 대상 파일 패턴이 .gitattributes 에 명시되어 있지 않다는 뜻인데요, 다시 말해 --no-rewrite 옵션을 사용하려면 대상 패턴을 한 번은 먼저 track 해 주어야 한다는 뜻입니다. 이렇게 되면 한 번에 커밋 하나로 대상을 import하려는 목적에 맞지 않게 되므로 목적에 맞지 않게 되어 버립니다. (어떨 때 쓰라고 만든 기능인지 모르겠네요)

가장 깔끔한 해결책이 될 뻔했던 이 방법은 아쉽게도 드랍되었습니다.

문제가 되는 파일 자체를 옛날 커밋에서 제거 후 다시 커밋

마지막은 아예 문제가 되는 대용량 파일을 repository에서 없애버리는 방법입니다. 이번에 저희는 git-filter-repo 라는 툴을 이용했습니다.

스퀘어랩 저장소에는 현재 문제가 되는 파일이 딱 한개 존재하는데요, 그것도 현재의 HEAD에는 이미 존재하지 않고, 아주 옛날에 올라온 적이 있었던 커밋에 들어가 있습니다. 이런 경우에는 파일을 커밋에서 제거해도 현재 상태에 전혀 영향을 주지 않기 때문에 오히려 지워버리는 것이 안전합니다.

이는 결과적으로는 첫 번째 방법과 동일하게 타겟 파일이 존재하는 커밋에서 파일을 삭제하고 다시 커밋한 뒤, 영향 받은 그 뒤의 커밋을 새로 써주는 결과를 만들어 줍니다.

result

위가 적용 전, 아래가 적용 후. 목적 커밋이었던 6c85cce7a7f 부터 hash값이 바뀐 것을 확인할 수 있다

이후 바뀐 HEAD를 force push 해주게 되면 repository에 변경사항이 반영됩니다. (gerrit은 기본적으로 force push를 허용하지 않으므로 특정 유저에게 force push 권한을 준 뒤 수행해 줘야 합니다.)

단, 이 방법을 쓰게 되면 많은 커밋들이 rewrite되어 hash값이 바뀌게 되므로, 작업 전후로 작업자들은 최신 버전으로 fetch를 받아 새로운 커밋 기준으로 작업할 수 있도록 해야 합니다.

Next

이 작업을 함으로써 Gerrit > Github mirroring에 필요한 LFS 기능이 갖춰졌습니다. 꽤나 시행착오를 많이 겪었지만 팀원 네일러님의 도움으로 무사히 플러그인을 적용할 수 있었네요.

이후 포스트에서는 Gerrit repository를 어떻게 Github에 mirroring하고 이를 이용해 어떤 서비스들을 이용할 수 있었는지 이야기하도록 할게요.

References

Plugin @PLUGIN@ configuration