리포지토리를 증분으로 마이그레이션하기 - AWS CodeCommit

AWS CodeCommit 신규 고객은 더 이상 사용할 수 없습니다. AWS CodeCommit 의 기존 고객은 정상적으로 서비스를 계속 이용할 수 있습니다. 자세히 알아보기

기계 번역으로 제공되는 번역입니다. 제공된 번역과 원본 영어의 내용이 상충하는 경우에는 영어 버전이 우선합니다.

리포지토리를 증분으로 마이그레이션하기

AWS CodeCommit로 마이그레이션할 때는 리포지토리를 증분 또는 청크로 푸시하여, 간헐적인 네트워크 문제나 네트워크 성능 저하로 인해 전체 푸시가 실패할 가능성을 줄이는 것이 좋습니다. 여기에 포함된 것과 같은 스크립트로 증분 푸시를 사용하면, 마이그레이션을 다시 시작하고 이전 시도에서 성공하지 못한 커밋만 푸시할 수 있습니다.

이 항목에 소개된 절차는 리포지토리를 증분으로 마이그레이션하고 그중 성공하지 못한 증분만 마이그레이션이 완료될 때까지 다시 푸시하는 스크립트를 만들고 실행하는 방법을 보여 줍니다.

이 지침들은 사용자가 설정 리포지토리 생성의 단계를 이미 완료했다는 가정하에 작성되었습니다.

0단계: 증분 마이그레이션 여부 결정

리포지토리의 전체 크기와 증분 마이그레이션의 여부를 결정할 때는 몇 가지 요소를 고려해야 합니다. 이 중에 가장 눈에 띄는 요소는 리포지토리에 있는 아티팩트의 전체 크기입니다. 리포지토리의 누적 기록과 같은 요소도 크기에 영향을 미칠 수 있습니다. 수년간의 기록과 브랜치가 담긴 리포지토리는 각각의 자산이 크지 않더라도 전체적으로는 매우 클 수 있습니다. 이러한 리포지토리를 더 간단하고 효율적으로 마이그레이션하기 위해 취할 수 있는 전략은 여러 가지가 있습니다. 예를 들어, 개발 기간이 긴 리포지토리를 복제할 때 얕은 복제 전략을 사용하거나, 대용량 바이너리 파일의 경우 델타 압축을 비활성화할 수 있습니다. 또 Git 설명서를 참조하여 옵션을 분석할 수 있으며, 이 항목에 포함된 샘플 스크립트 incremental-repo-migration.py를 사용하여 리포지토리를 마이그레이션하기 위한 증분 푸시를 설정 및 구성할 수도 있습니다.

다음 조건 중 하나 이상에 해당하면 증분 푸시를 구성하는 것이 좋을 수도 있습니다.

  • 마이그레이션하려는 리포지토리에 5년 이상의 기록이 담겨 있습니다.

  • 인터넷 연결이 간헐적인 끊김, 패킷 손실, 느린 응답 속도, 기타 서비스 중단 등의 문제를 일으킵니다.

  • 리포지토리의 전체 크기가 2GB를 넘으며 리포지토리 전체를 마이그레이션하려 합니다.

  • 리포지토리에, 압축이 잘 되지 않는 대용량 아티팩트나 바이너리(예: 추적된 버전이 5개 이상인 대용량 이미지 파일)가 포함되어 있습니다.

  • 이전에 CodeCommit으로 마이그레이션을 시도한 적이 있는데 “내부 서비스 오류” 메시지를 받았습니다.

상기 조건에 해당하지 않더라도 증분 푸시를 선택할 수 있습니다.

1단계: 필수 구성 요소를 설치하고 CodeCommit 리포지토리를 원격으로 추가

자체적인 필수 요소가 있는 고유의 사용자 지정 스크립트를 생성할 수 있습니다. 이 항목에 포함된 샘플을 사용하는 경우 다음을 수행해야 합니다.

  • 필수 구성 요소를 설치합니다.

  • 리포지토리를 로컬 컴퓨터에 복제하려면

  • CodeCommit 리포지토리를, 마이그레이션하려는 리포지토리의 원격 리포지토리로 추가합니다.

incremental-repo-migration.py 실행 설정
  1. 로컬 컴퓨터에 Python을 2.6 이상으로 설치합니다. 자세한 내용과 최신 버전은 Python 웹사이트를 참조하세요.

  2. 동일한 컴퓨터에 GitPython을 설치합니다. GitPython은 Git 리포지토리와 상호작용하는 데 사용되는 Python 라이브러리입니다. 자세한 내용은 GitPython 설명서를 참조하세요.

  3. git clone --mirror 명령을 사용하여, 마이그레이션하려는 리포지토리를 로컬 컴퓨터로 복제합니다. 터미널(Linux, macOS, Unix) 또는 명령 프롬프트(Windows)에서 git clone --mirror 명령을 사용하여 해당 리포지토리의 로컬 리포지토리를 생성합니다. 이때 로컬 리포지토리를 생성할 디렉터리도 생성합니다. 예를 들어, URL이 https://example.com/my-repo/이고 이름이 MyMigrationRepo인 Git 리포지토리를 my-repo라는 디렉터리에 복제하려면 다음과 같이 합니다.

    git clone --mirror https://example.com/my-repo/MyMigrationRepo.git my-repo

    다음과 비슷한 출력이 표시될 것입니다. 이는 리포지토리가 my-repo라는 이름의 베어 로컬 리포지토리에 복제되었음을 나타냅니다.

    Cloning into bare repository 'my-repo'... remote: Counting objects: 20, done. remote: Compressing objects: 100% (17/17), done. remote: Total 20 (delta 5), reused 15 (delta 3) Unpacking objects: 100% (20/20), done. Checking connectivity... done.
  4. 방금 복제한 리포지토리의 로컬 리포지토리로 디렉터리를 변경합니다(예: my-repo). 해당 디렉터리에서 git remote add DefaultRemoteName RemoteRepositoryURL 명령을 사용하여 CodeCommit 리포지토리를 로컬 리포지토리의 원격 리포지토리로 추가합니다.

    참고

    대규모 리포지토리를 푸시할 때는 HTTPS 대신 SSH를 사용하는 것이 좋습니다. 대규모 변경 사항, 대량의 변경 사항 또는 대규모 리포지토리 푸시할 때는 장시간 실행 중인 HTTPS 연결이 네트워킹 문제나 방화벽 설정으로 인해 조기에 종료되는 경우가 많습니다. SSH를 사용하는 CodeCommit 설정에 대해 자세히 알아보려면 Linux, macOS, Unix에서 SSH 연결 또는 Windows에서 SSH 연결 섹션을 참조하세요.

    예를 들어, 다음 명령을 사용하여 MyDestinationRepo라는 CodeCommit 리포지토리의 SSH 엔드포인트를 codecommit라는 이름의 원격 리포지토리로 추가합니다.

    git remote add codecommit ssh://git-codecommit.us-east-2.amazonaws.com/v1/repos/MyDestinationRepo
    작은 정보

    이는 복제이기 때문에, 기본 원격 이름인 (origin)은 이미 사용되고 있습니다. 다른 원격 이름을 사용해야 합니다. 예제에서는 codecommit을 사용하지만, 원하는 이름을 사용해도 됩니다. git remote show 명령을 사용하여 로컬 리포지토리에 설정된 원격 목록을 검토합니다.

  5. git remote -v 명령을 사용하여 로컬 리포지토리의 페치 및 푸시 설정을 표시하고 올바르게 설정되었는지 확인합니다. 예:

    codecommit ssh://git-codecommit.us-east-2.amazonaws.com/v1/repos/MyDestinationRepo (fetch) codecommit ssh://git-codecommit.us-east-2.amazonaws.com/v1/repos/MyDestinationRepo (push)
    작은 정보

    다른 원격 리포지토리의 페치 및 푸시 항목(예: origin 항목)이 계속 표시되면, git remote set-url --delete 명령을 사용하여 제거합니다.

2단계: 증분 마이그레이션에 사용할 스크립트 생성

이 단계는 incremental-repo-migration.py 샘플 스크립트를 사용하고 있다는 가정하에 작성되었습니다.

  1. 텍스트 편집기를 열고 샘플 스크립트의 내용을 빈 문서에 붙여 넣습니다.

  2. 문서를 문서 디렉터리(로컬 리포지토리의 작업 디렉터리가 아님)에 저장하고 이름을 incremental-repo-migration.py으로 지정합니다. 선택한 디렉터리는 로컬 환경 또는 경로 변수에 구성된 디렉터리여야 합니다. 그래야 명령줄이나 터미널에서 Python 스크립트를 실행할 수 있습니다.

3단계: 스크립트 실행 및 CodeCommit으로 증분 마이그레이션

이제 incremental-repo-migration.py 스크립트를 만들었으니, 이를 사용하여 로컬 리포지토리를 CodeCommit 리포지토리로 증분 마이그레이션할 수 있습니다. 기본적으로 스크립트는 1,000개의 커밋을 일괄적으로 푸시하며, 자신이 실행되는 디렉터리의 Git 설정을 로컬 리포지토리와 원격 리포지토리의 설정으로 사용하려고 시도합니다. 필요한 경우 incremental-repo-migration.py에 포함된 옵션을 사용하여 다른 설정을 구성할 수 있습니다.

  1. 터미널 또는 명령 프롬프트에서, 마이그레이션하려는 로컬 리포지토리로 디렉터리를 변경합니다.

  2. 해당 디렉터리에서 다음 명령을 실행합니다.

    python incremental-repo-migration.py
  3. 스크립트가 실행되며, 터미널 또는 명령 프롬프트에 진행 상황이 표시됩니다. 일부 대형 리포지토리는 진행 상황이 느리게 표시됩니다. 단일 푸시가 세 번 실패하면 스크립트는 중단됩니다. 그런 다음 스크립트를 다시 실행하면 실패한 배치부터 다시 시작할 수 있습니다. 스크립트 재실행은 모든 푸시가 성공하고 마이그레이션이 완료될 때까지 계속할 수 있습니다.

작은 정보

-l-r 옵션을 활용해 로컬 및 원격 설정을 지정하기만 하면 모든 디렉터리에서 incremental-repo-migration.py를 실행할 수 있습니다. 예를 들어, 임의의 디렉터리의 스크립트를 사용하여, /tmp/my-repo에 위치한 로컬 리포지토리를 codecommit이라는 원격 리포지토리로 마이그레이션하려면 다음과 같이 합니다.

python incremental-repo-migration.py -l "/tmp/my-repo" -r "codecommit"

-b 옵션을 사용하여, 증분 푸시할 때 사용되는 기본 배치 크기를 변경하는 것이 좋을 수 있습니다. 예를 들어, 자주 변경되는 대용량 바이너리 파일이 있는 리포지토리를 정기적으로 푸시하고 네트워크 대역폭이 제한된 장소에서 작업하는 경우, -b 옵션을 사용하여 배치 크기를 1,000이 아닌 500으로 변경하는 것이 좋을 수 있습니다. 예:

python incremental-repo-migration.py -b 500

이렇게 하면 로컬 리포지토리를 커밋 500개씩 묶어서 증분 푸시합니다. 리포지토리를 마이그레이션할 때 배치 크기를 다시 변경하려는 경우(예: 시도를 실패한 후에 배치 크기를 줄이기로 결정했을 때), -b로 배치 크기를 재설정하기 전에 -c 옵션으로 배치 태그를 제거해야 합니다.

python incremental-repo-migration.py -c python incremental-repo-migration.py -b 250
중요

실패 후 스크립트를 다시 실행하려면 이 -c 옵션은 사용하지 않습니다. -c 옵션은 커밋을 일괄 처리하는 데 사용된 태그를 제거합니다. 배치 크기를 변경하고 다시 시작하려는 경우나 스크립트를 더 이상 사용하지 않으려는 경우에만 -c 옵션을 사용합니다.

부록: 샘플 스크립트 incremental-repo-migration.py

편의를 위해, 리포지토리를 증분 푸시해 주는 샘플 Python 스크립트 incremental-repo-migration.py를 개발했습니다. 본 샘풀은 오픈 소스이며 있는 그대로 제공됩니다.

# Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved. Licensed under the Amazon Software License (the "License"). # You may not use this file except in compliance with the License. A copy of the License is located at # http://aws.amazon.com/asl/ # This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for # the specific language governing permissions and limitations under the License. #!/usr/bin/env python import os import sys from optparse import OptionParser from git import Repo, TagReference, RemoteProgress, GitCommandError class PushProgressPrinter(RemoteProgress): def update(self, op_code, cur_count, max_count=None, message=""): op_id = op_code & self.OP_MASK stage_id = op_code & self.STAGE_MASK if op_id == self.WRITING and stage_id == self.BEGIN: print("\tObjects: %d" % max_count) class RepositoryMigration: MAX_COMMITS_TOLERANCE_PERCENT = 0.05 PUSH_RETRY_LIMIT = 3 MIGRATION_TAG_PREFIX = "codecommit_migration_" def migrate_repository_in_parts( self, repo_dir, remote_name, commit_batch_size, clean ): self.next_tag_number = 0 self.migration_tags = [] self.walked_commits = set() self.local_repo = Repo(repo_dir) self.remote_name = remote_name self.max_commits_per_push = commit_batch_size self.max_commits_tolerance = ( self.max_commits_per_push * self.MAX_COMMITS_TOLERANCE_PERCENT ) try: self.remote_repo = self.local_repo.remote(remote_name) self.get_remote_migration_tags() except (ValueError, GitCommandError): print( "Could not contact the remote repository. The most common reasons for this error are that the name of the remote repository is incorrect, or that you do not have permissions to interact with that remote repository." ) sys.exit(1) if clean: self.clean_up(clean_up_remote=True) return self.clean_up() print("Analyzing repository") head_commit = self.local_repo.head.commit sys.setrecursionlimit(max(sys.getrecursionlimit(), head_commit.count())) # tag commits on default branch leftover_commits = self.migrate_commit(head_commit) self.tag_commits([commit for (commit, commit_count) in leftover_commits]) # tag commits on each branch for branch in self.local_repo.heads: leftover_commits = self.migrate_commit(branch.commit) self.tag_commits([commit for (commit, commit_count) in leftover_commits]) # push the tags self.push_migration_tags() # push all branch references for branch in self.local_repo.heads: print("Pushing branch %s" % branch.name) self.do_push_with_retries(ref=branch.name) # push all tags print("Pushing tags") self.do_push_with_retries(push_tags=True) self.get_remote_migration_tags() self.clean_up(clean_up_remote=True) print("Migration to CodeCommit was successful") def migrate_commit(self, commit): if commit in self.walked_commits: return [] pending_ancestor_pushes = [] commit_count = 1 if len(commit.parents) > 1: # This is a merge commit # Ensure that all parents are pushed first for parent_commit in commit.parents: pending_ancestor_pushes.extend(self.migrate_commit(parent_commit)) elif len(commit.parents) == 1: # Split linear history into individual pushes next_ancestor, commits_to_next_ancestor = self.find_next_ancestor_for_push( commit.parents[0] ) commit_count += commits_to_next_ancestor pending_ancestor_pushes.extend(self.migrate_commit(next_ancestor)) self.walked_commits.add(commit) return self.stage_push(commit, commit_count, pending_ancestor_pushes) def find_next_ancestor_for_push(self, commit): commit_count = 0 # Traverse linear history until we reach our commit limit, a merge commit, or an initial commit while ( len(commit.parents) == 1 and commit_count < self.max_commits_per_push and commit not in self.walked_commits ): commit_count += 1 self.walked_commits.add(commit) commit = commit.parents[0] return commit, commit_count def stage_push(self, commit, commit_count, pending_ancestor_pushes): # Determine whether we can roll up pending ancestor pushes into this push combined_commit_count = commit_count + sum( ancestor_commit_count for (ancestor, ancestor_commit_count) in pending_ancestor_pushes ) if combined_commit_count < self.max_commits_per_push: # don't push anything, roll up all pending ancestor pushes into this pending push return [(commit, combined_commit_count)] if combined_commit_count <= ( self.max_commits_per_push + self.max_commits_tolerance ): # roll up everything into this commit and push self.tag_commits([commit]) return [] if commit_count >= self.max_commits_per_push: # need to push each pending ancestor and this commit self.tag_commits( [ ancestor for (ancestor, ancestor_commit_count) in pending_ancestor_pushes ] ) self.tag_commits([commit]) return [] # push each pending ancestor, but roll up this commit self.tag_commits( [ancestor for (ancestor, ancestor_commit_count) in pending_ancestor_pushes] ) return [(commit, commit_count)] def tag_commits(self, commits): for commit in commits: self.next_tag_number += 1 tag_name = self.MIGRATION_TAG_PREFIX + str(self.next_tag_number) if tag_name not in self.remote_migration_tags: tag = self.local_repo.create_tag(tag_name, ref=commit) self.migration_tags.append(tag) elif self.remote_migration_tags[tag_name] != str(commit): print( "Migration tags on the remote do not match the local tags. Most likely your batch size has changed since the last time you ran this script. Please run this script with the --clean option, and try again." ) sys.exit(1) def push_migration_tags(self): print("Will attempt to push %d tags" % len(self.migration_tags)) self.migration_tags.sort( key=lambda tag: int(tag.name.replace(self.MIGRATION_TAG_PREFIX, "")) ) for tag in self.migration_tags: print( "Pushing tag %s (out of %d tags), commit %s" % (tag.name, self.next_tag_number, str(tag.commit)) ) self.do_push_with_retries(ref=tag.name) def do_push_with_retries(self, ref=None, push_tags=False): for i in range(0, self.PUSH_RETRY_LIMIT): if i == 0: progress_printer = PushProgressPrinter() else: progress_printer = None try: if push_tags: infos = self.remote_repo.push(tags=True, progress=progress_printer) elif ref is not None: infos = self.remote_repo.push( refspec=ref, progress=progress_printer ) else: infos = self.remote_repo.push(progress=progress_printer) success = True if len(infos) == 0: success = False else: for info in infos: if ( info.flags & info.UP_TO_DATE or info.flags & info.NEW_TAG or info.flags & info.NEW_HEAD ): continue success = False print(info.summary) if success: return except GitCommandError as err: print(err) if push_tags: print("Pushing all tags failed after %d attempts" % (self.PUSH_RETRY_LIMIT)) elif ref is not None: print("Pushing %s failed after %d attempts" % (ref, self.PUSH_RETRY_LIMIT)) print( "For more information about the cause of this error, run the following command from the local repo: 'git push %s %s'" % (self.remote_name, ref) ) else: print( "Pushing all branches failed after %d attempts" % (self.PUSH_RETRY_LIMIT) ) sys.exit(1) def get_remote_migration_tags(self): remote_tags_output = self.local_repo.git.ls_remote( self.remote_name, tags=True ).split("\n") self.remote_migration_tags = dict( (tag.split()[1].replace("refs/tags/", ""), tag.split()[0]) for tag in remote_tags_output if self.MIGRATION_TAG_PREFIX in tag ) def clean_up(self, clean_up_remote=False): tags = [ tag for tag in self.local_repo.tags if tag.name.startswith(self.MIGRATION_TAG_PREFIX) ] # delete the local tags TagReference.delete(self.local_repo, *tags) # delete the remote tags if clean_up_remote: tags_to_delete = [":" + tag_name for tag_name in self.remote_migration_tags] self.remote_repo.push(refspec=tags_to_delete) parser = OptionParser() parser.add_option( "-l", "--local", action="store", dest="localrepo", default=os.getcwd(), help="The path to the local repo. If this option is not specified, the script will attempt to use current directory by default. If it is not a local git repo, the script will fail.", ) parser.add_option( "-r", "--remote", action="store", dest="remoterepo", default="codecommit", help="The name of the remote repository to be used as the push or migration destination. The remote must already be set in the local repo ('git remote add ...'). If this option is not specified, the script will use 'codecommit' by default.", ) parser.add_option( "-b", "--batch", action="store", dest="batchsize", default="1000", help="Specifies the commit batch size for pushes. If not explicitly set, the default is 1,000 commits.", ) parser.add_option( "-c", "--clean", action="store_true", dest="clean", default=False, help="Remove the temporary tags created by migration from both the local repo and the remote repository. This option will not do any migration work, just cleanup. Cleanup is done automatically at the end of a successful migration, but not after a failure so that when you re-run the script, the tags from the prior run can be used to identify commit batches that were not pushed successfully.", ) (options, args) = parser.parse_args() migration = RepositoryMigration() migration.migrate_repository_in_parts( options.localrepo, options.remoterepo, int(options.batchsize), options.clean )