안녕하세요, 렌딧 Engineering 팀의 Sam입니다.

배포를 배포답게 시리즈 2번째 글에서는 앞서 소개했던 문제점 1, 2, 3번을 해결했던 방법에 대해 적어보려고 합니다.

글에 앞서, 문제점을 해결하기 위해 만든 프로젝트의 규모가 작지 않아 전체 코드를 적을 수 없습니다. 기능을 이해하는데 필수적이지 않은 코드는 삭제하여, 복사 + 붙여넣기를 한다고 해도 정상 작동하지 않을 수 있습니다. 또한, 서버나 커밋 정보 등 외부에 공개할 수 없는 내용은 적당히 편집하였습니다.

문제점 1 - 테스트 서버

렌딧은 사내 메신저로 Slack을 사용하고 있습니다. 따라서, 이전에는 테스트 서버를 사용할때 마다 개발팀 채널에 @here 테스트 서버를 사용하시는 분 계신가요? 라고 물어보고 사용했었습니다. 하지만, 서버 사용자가 열심히(?) 개발하거나 테스트를 하고 있어 답장을 못 하고 넘어가는 문제가 있었습니다. 개발팀 인원이 늘어나고, 테스트 서버가 늘어날수록 더욱 비효율적이게 되었습니다.

그 해결 방법으로 매우 단순한 Slack Bot인 Nyang Bot을 만들었습니다. django와 slackclient(==1.1.3)를 사용하여 구현하였습니다.

  1. model은 다음과 같습니다.
    1
    class ServerReservation(models.Model):
    2
        server = models.OneToOneField(Server)  # Server는 물리 / 가상 서버를 표현하는 모델입니다
    3
        name = models.CharField(max_length=30)
    4
        user_using = models.ForeignKey(User, null=True, blank=True)  # User는 django 기본 사용자 모델입니다
    5
        user_waiting = models.ForeignKey(User, null=True, blank=True)
    6
        acquire_at = models.DateTimeField(null=True, blank=True)
  2. 주요 로직은 다음과 같습니다.
    1
    # 서버를 할당합니다.
    2
    def reservation_acquire(reservation, user):
    3
        if not reservation.user_using:
    4
            # 사용하는 사람이 없으면 바로 할당합니다.
    5
            reservation.user_using = user
    6
            reservation.acquire_at = timezone.now()
    7
            return f'{reservation.name}를 할당했냥!'
    8
        elif not reservation.user_waiting:
    9
            # 사용하는 사람이 있지만 대기하고 있는 사람이 없다면
    10
            # 사용 대기 상태로 만들고 현재 사용자에게 알림을 보냅니다.
    11
            reservation.user_waiting = user
    12
            return f'할당 대기 했냥! / {reservation.user_using}: {reservation.name} 할당 해제가 필요하냥!'
    13
        return f'이미 사용중({reservation.user_using})이고 대기중({reservation.user_waiting})인 사람도 있냥 ㅠ'
    14
    15
    # 사용 중인 서버를 사용 해제합니다.
    16
    def reservation_release(reservation, user):
    17
        if reservation.user_using == user:
    18
            # 본인이면 바로 사용 해제합니다.
    19
            reservation.user_using = None
    20
            reservation.acquire_at = None
    21
            extra_msg = ''
    22
            if user_waiting:
    23
                # 해제 시 대기자가 있다면 할당합니다.
    24
                reservation.user_using = user_waiting
    25
                reservation.user_waiting = None
    26
                reservation.acquire_at = timezone.now()
    27
                extra_msg = f' / {reservation.user_waiting}: {reservation.name}를 할당했냥!'
    28
            return f'할당 해제되었냥!{extra_msg}'
    29
        return f'{reservation.user_using}: 할당 해제가 필요하냥!'
    30
    31
    # 해당 서버의 상황을 보여줍니다.
    32
    def reservation_status(reservation, user):
    33
        timediff = get_timediff(reservation.acquire_at)  # 현재 시각과 diff를 사람이 읽기 좋은 문자열로 변환합니다
    34
        return (
    35
            f'{reservation.name}: {reservation.user_using} '
    36
            f'({timediff}, 대기자 {reservation.user_waiting})'
    37
        )
    38
    39
    # 메인 - Slack RTM API를 사용하여 channel id와 사용자 id 및 메시지를 가져옵니다
    40
    def handle_reservation(slack_client, channel, uid, args):
    41
        name, mode = args[0:2]
    42
        reservation = ServerReservation.objects.get(name=name)
    43
        msg = {
    44
            'acquire': reservation_acquire,
    45
            'release': reservation_release,
    46
            '': reservation_status,
    47
        }[mode](reservation, get_user(uid))  # Slack 사용자 id로부터 User 객체를 얻어옵니다
    48
        reservation.save()
    49
    50
        # 해당 채널로 결과 메시지를 보냅니다.
    51
        slack_client.rtm_send_message(channel, msg)
    52
    53
    54
    slack_client = SlackClient(SLACK_TOKEN)
    55
    slack_client.rtm_connect()
    56
    while True:
    57
        for event in slack_client.rtm_read():
    58
            command, *args = event['text'].strip().split()
    59
            if command == '!server':
    60
                handle_reservation(slack_client, event['channel'], event['user'], args)

실제로 회사 엔지니어링 팀 채널에서 Nyang Bot을 사용하고 있는 모습입니다.

Nyang Bot 서버 예약 기능 데모

이 외에도 Nyang Bot은 매일 스탠드업 이후 리뷰하지 않은 코드를 알려주거나, 배치 수행 상태를 점검하고 오류가 있다면 알려주거나, 렌딧 사원의 정보를 알려주는 등 많은 일을 수행합니다. (렌딧에 입사하시면 Nyang Bot의 많은 활약을 보실 수 있습니다!)

문제점 2 - 중복 배포

여러 개발자가 동시에 배포할 경우 배포 스크립트로 인해 오작동이 일어날 수 있습니다. 이전에는 배포 알림 채널을 보고 배포 중인지 아닌지 확인한 다음 배포했습니다.

이 문제를 해결하기 위해 Nyang Bot 프로젝트에 배포 관리 기능을 추가하여 모든 배포 trigger가 Nyang Bot 서버를 통해 이루어지도록 했습니다. Django Rest Framework를 사용하여 API 서버를, Nuxt.js를 사용하여 프론트 화면을, Celery를 사용하여 queue를 만들었습니다. 코드는 매우 경이롭지만(?) 여백(?)이 부족한 관계로 구조도와 스크린샷으로 설명하겠습니다.

먼저, 전체적인 구조는 다음과 같습니다.

전체 구조

내부망(회사)에 있는 개발자 1은 Nuxt로 만들어진 웹 UI를 통해 (1) API 서버에 요청을 보내게 됩니다. API 서버는 단순 조회인 경우 바로 데이터를 전송하지만, 배포나 배포 취소 요청처럼 시간이 오래 걸리는 요청인 경우 (2) queue에 전달하고 빈 데이터를 전송합니다. 명령을 받은 queue는 (3) Travis CI에 배포 / 배포 취소 요청을 보냅니다. Travis CI는 여러 중간 과정을 거쳐 (4) CodeDeploy에 배포 명령을 보내게 됩니다. (중간 과정은 배포 시스템 1편에 소개되어 있습니다) 마침내 배포 시작, 성공, 실패 등의 이벤트가 발생하면 CodeDeploy는 (5) SNS와 Lambda를 통해 이벤트 내용을 API 서버로 전송합니다. 이 모든 과정에서 API 서버나 queue는 (6) 알림이 필요한 경우 Slack에 알림을 보냅니다.

한편, 외부망에 있는 개발자 2는 command line 도구를 사용하여 django API 서버로 요청을 보내게 됩니다. 이 과정에서 (7) API gateway와 Lambda가 요청을 proxying해 줍니다. 이후의 과정은 개발자 1의 과정과 같습니다.

다음으로 웹 UI를 몇 장의 스크린샷으로 소개하려고 합니다.

  1. Project, deployment group, branch를 지정하여 배포를 시작합니다.배포 시작 화면
  2. 배포 ID가 할당되고 배포 상태를 볼 수 있습니다.배포 상세보기 화면
  3. 배포 상태가 업데이트될 때마다 슬랙에 메시지가 전송됩니다.배포 상태 업데이트시 슬랙 화면
  4. 현재 진행 중인 배포와 최근 배포를 확인할 수 있습니다.배포 로그 화면

문제점 3 - 배포 내용

렌딧에서는 서비스 서버에만 하루에 10번 이상 배포를 수행합니다. 일반적으로 PR이 approved가 되면 author가 원할 때 master에 merge하고, merge 즉시 배포를 수행하지만 가끔 실수나 정책상의 이유로 배포가 즉시 이루어지지 않는 경우가 있습니다. 어느 PR이 배포되었는지 알기 어렵고, 배포 후 장애가 생겼을 때 어느 PR 때문에 장애가 발생했는지 알기 어렵습니다.

이 문제는 Github PR에 배포 대상 label을 붙이고, 배포를 수행할 때 label을 제거하고 배포 기록에 해당 내용을 저장하는 간단한 아이디어로 해결하였습니다. 예를 들어, LenditKr/awesome-project에 배포 그룹 admin과 batch가 있다고 가정해봅시다.

  1. 먼저 Github repo에 admin과 batch label을 생성합니다.
  2. Github PR을 생성할 때, PR 목적에 따라 admin 또는 batch (또는 둘 다) label을 붙입니다.
  3. 배포 시스템이 배포를 시작하기 전, Github API를 사용하여 label을 삭제하고 로그에 저장합니다.
  4. 적당히 잘 보여줍니다!

PyGithub(==1.35)를 사용하여 다음과 같이 label을 삭제할 수 있습니다!

1
def remove_labels(project_repo: str, deployment_group: str):
2
    # is:pr과 is:merged를 넣어 merge된 pr로 한정합니다
3
    q = f'repo:{project_repo} is:pr is:merged label:{deployment_group}'
4
5
    issues = {}
6
    for issue in Github(GITHUB_TOKEN).search_issues(q):
7
        issue.remove_from_labels(target.name)
8
        issues[issue.number] = issue.title
9
10
    save_github_issues_to_log(deploy_log, issues)

저는 3일 전 admin을 수정하는 #3587 PR을 만들었습니다. Admin에만 배포하면 되는 PR이므로, 저는 admin label만 붙여놓았습니다. Merge후 배포했을 때, lenditkr-bot이 자동으로 admin label을 삭제해줍니다.

Admin에 배포하는 PR의 예시

또한, PR 번호와 제목은 저장되어 배포 로그에서 확인할 수 있습니다!

배포 로그 페이지에서 PR을 확인하는 예시

이제 1~3번의 문제가 해결되었습니다! 다음 배포를 배포답게! 배포 시스템 개선하기 (3) 에서는 Java reflection을 사용해서 batch job이 진행 중일 때 배포를 어떻게 차단할 수 있었는지 다룰 예정입니다. 다음 글에서 뵙겠습니다><

+ 1편과 마찬가지로 이미지는 A팀의 Blake께서 만들어주셨습니다. 감사합니다!