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

대략 4개의 글로 렌딧의 배포 시스템을 소개해보려고 합니다.

렌딧 서버 구조와 배포 문화(?)

렌딧에서는 대규모의 기능 또는 schema 변경이나 정책상의 문제가 있지 않은 한, 코드리뷰를 받고 수정이 끝나면 즉시 master branch에 merge 후 배포하고 있습니다. 렌딧은 대략 30개의 시스템이 있으니 정말 많은 배포가 수행됩니다. 글을 쓴 시점에서 어제 하루 동안 테스트 서버를 제외하고 약 20건의 배포가 이루어졌습니다.

하루에도 이렇게 많은 배포를 할 수 있는 이유는 batch 서버를 제외한 모든 서비스 서버는 무중단 배포가 가능한 구조이기 때문입니다. 한 서버는 두 개의 Java (또는 Python) instance가 존재하고, nginx가 최신 버전의 instance로 요청을 proxying 합니다. 배포할 때에는 (1) proxying되지 않는 이전 버전의 instance를 죽이고 (2) 새 버전으로 대체한 다음 (3) nginx가 proxying하는 instance를 바꿔주는 단순한 구조입니다. 그림으로 좀 더 쉽게 설명하면 다음과 같습니다.

무중단 배포를 위한 서버 구조와 배포 예시

웹과 심사용 back office 서버는 매우 중요하므로, 위와 같은 구성의 서버를 여러 대 만들고 load balancer를 붙여 관리하고 있습니다.

장애 방지를 위한 여러 대의 서버 구성

기존 배포 시스템

렌딧은 Github와 Travis CI, CodeDeploy를 배포에 사용하고 있습니다. 순서는 다음과 같습니다.

배포 방법
  1. Shell script를 실행하여 Travis CI에 배포 요청 및 빌드
  2. Travis CI가 빌드 후 S3에 파일을 올리고 CodeDeploy로 배포 요청 전송
  3. CodeDeploy는 EC2 instance에 파일을 복사하고 각 instance에 배포 script 실행

이 글에서는 LenditKr/my-project repo의 my-project application을 production deployment group에 배포해보겠습니다.

CodeDeploy 설정

먼저 CodeDeploy에 application과 deployment group을 생성하고, EC2 instance에 적절한 IAM Role을 주어야 합니다.

  • 설정은 CodeDeploy 문서를 참고하시기 바랍니다.
  • CodeDeploy service role에는 AWSCodeDeployRole이 필요합니다. IAM에서 생성할 수 있습니다.
  • 각 EC2 instance에는 다음과 같은 IAM Role이 필요합니다. 역시 IAM에서 생성할 수 있습니다.
    1
    {
    2
        "Version": "2012-10-17",
    3
        "Statement": [{
    4
            "Effect": "Allow",
    5
            "Action": ["s3:Get*", "s3:List*"],
    6
            "Resource": [
    7
                // 프로젝트 파일이 업로드되는 bucket 이름입니다.
    8
                "arn:aws:s3:::deploy/*",
    9
                // 사용하는 region을 모두 추가해야 합니다.
    10
                "arn:aws:s3:::aws-codedeploy-ap-northeast-2/*",
    11
            ]
    12
        }]
    13
    }

Engineer -> Travis

배포 trigger는 Travis API를 사용합니다.

1
branch=$1
2
deployment_group=$2
3
4
travis_payload="{
5
\"request\": {
6
  \"branch\": \"$branch\",
7
  \"config\": {
8
    \"branches\": {
9
      \"only\": \"$branch\"
10
    },
11
  \"env\": [\"TRIGGER_DEPLOYMENT=true DEPLOYMENT_GROUP=$deployment_group\"]
12
  }
13
}}"
14
15
curl -s -X POST \
16
  -H "Content-Type: application/json" \
17
  -H "Accept: application/json" \
18
  -H "Travis-API-Version: 3" \
19
  -H "Authorization: token S3cR3T" \
20
  -d "$travis_payload" \
21
  https://api.travis-ci.com/repo/LenditKr%2Fmy-project/requests

특별한 내용은 없습니다. 단지 배포할 branch와 deployment group을 포함한 payload를 만들고, Travis API를 curl로 호출할 뿐입니다. 개발자는 이 스크립트를 ./trigger-deploy.sh master production과 같이 사용하여 배포를 시작합니다. env에 넣는 값은 다음 장에서 설명하겠습니다.

Travis -> S3 + CodeDeploy

.travis.yml의 내용은 다음과 같습니다.

1
/* some project-specific build settings */
2
before_deploy:
3
  - deploy/before_deploy.sh
4
deploy:
5
  - provider: s3
6
    access_key_id: S3CR3TACC3SSK3Y
7
    secret_access_key: &1
8
      secure: someThingVerySecureAWSAccessKey
9
    local_dir: upload
10
    bucket: deploy
11
    upload-dir: my-project
12
    region: some-aws-region
13
    skip_cleanup: true
14
    on: &2
15
      all_branches: true
16
      condition: $TRIGGER_DEPLOYMENT = true
17
  - provider: codedeploy
18
    access_key_id: S3CR3TACC3SSK3Y
19
    secret_access_key: *1
20
    bucket: deploy
21
    key: my-project/$DEPLOYMENT_GROUP-${TRAVIS_COMMIT:0:7}.zip
22
    bundle_type: zip
23
    application: my-project
24
    deployment_group: $DEPLOYMENT_GROUP
25
    region: some-aws-region
26
    on: *2

먼저, 배포 전 Travis container에서 deploy/before_deploy.sh를 실행하도록 합니다. 이 스크립트의 내용은 다음과 같습니다.

1
# .travis.yml에 deploy provider가 2개이므로, before_deploy도 두 번 실행됩니다.
2
# upload 폴더가 존재하지 않을 때의 조건을 걸어 한 번만 실행되도록 합니다.
3
if [ ! -d upload ]; then
4
  mkdir -p upload_tmp
5
  # Java 프로젝트이므로 빌드된 .war 파일을 복사합니다.
6
  cp target/my-project.war upload_tmp/
7
  # 기타 프로젝트에 필요한 파일도 복사합니다.
8
  cp -r some-important-files/ upload_tmp/
9
  # CodeDeploy 관련 파일을 복사합니다. 다음 장에서 설명합니다.
10
  cp -r deploy/$DEPLOYMENT_GROUP/* upload_tmp/
11
  mkdir -p upload
12
13
  # 지정된 이름으로 모두 압축하여 upload 폴더에 넣습니다.
14
  cd upload_tmp && zip -r ../upload/$DEPLOYMENT_GROUP-${TRAVIS_COMMIT:0:7} * && cd ..
15
fi

그리고 두 개의 deploy provider를 사용하여 배포합니다.

  • 첫 번째는 s3 provider를 사용하여 upload의 내용을 s3의 deploy bucket > my-project라는 폴더에 저장하는 설정입니다. 이때, condition이 $TRIGGER_DEPLOYMENT = true로 되어 있으므로 단순 PR build시에는 배포되지 않습니다.
  • 두 번째는 codedeploy provider를 사용하여 실제로 배포를 수행하도록 합니다. S3에 올라간 bucket name과 key를 넣고, CodeDeploy에서 설정한 application과 deployment_group을 넣으면 됩니다.

참고: .travis.yml파일에 secret key를 암호화하여 넣는 방법은 Travis 문서를 참고하시면 됩니다.

CodeDeploy

드디어 마지막 단계입니다. deploy/production/에는 다음과 같은 파일이 있습니다.

  • 먼저, appspec.yml입니다. CodeDeploy의 동작을 정의합니다. 이 파일의 자세한 설명은 CodeDeploy 문서를 참고하시기 바랍니다.
    1
    version: 0.0
    2
    os: linux
    3
    files:
    4
      // 압축했던 파일을 지정된 위치로 복사합니다.
    5
      - source: my-project.war
    6
        destination: /home/lendit
    7
      - source: my-project_14242.conf
    8
        destination: /home/lendit/nginx-conf
    9
      - source: my-project_14243.conf
    10
        destination: /home/lendit/nginx-conf
    11
      - source: some-important-files/
    12
        destination: /home/lendit/some-important-files/
    13
    permissions:
    14
      // 복사한 파일의 permission을 변경합니다.
    15
      - object: /home/lendit
    16
        pattern: "*.war"
    17
        owner: lendit
    18
        group: lendit
    19
      - object: /home/lendit/some-important-files
    20
        pattern: "**"
    21
        owner: lendit
    22
        group: lendit
    23
    hooks:
    24
      // application start phase에서 restart.sh를 실행합니다.
    25
      ApplicationStart:
    26
        - location: restart.sh
    27
          runas: lendit
  • 다음으로, my-project_14242.confmy-project_14243.conf가 있습니다. 이 두 파일은 nginx configuration 파일입니다.
  • 마지막으로 restart.sh 파일이 있습니다. 배포 instance에서 실행될 shell script입니다.
    1
    #!/bin/bash
    2
    3
    home=/home/lendit
    4
    source $home/.bash_profile
    5
    6
    # Java instance 별로 사용할 Spring profile을 정의합니다.
    7
    active_1='production1'
    8
    active_2='production2'
    9
    config_prefix='my-project_'
    10
    # nginx 설정 파일의 내용을 읽어 현재 어떤 Java instance를 사용하고 있는지 찾습니다.
    11
    current_port=$(cat /etc/nginx/conf.d/$config_prefix*.conf | grep -o '127.0.0.1:[0-9]*' | sed 's/127\.0\.0\.1://g')
    12
    13
    if [ "$current_port" = "14243" ]; then
    14
      target_port=14242
    15
      target_directory=/home/my-project1
    16
      active=$active_1
    17
    else
    18
      target_port=14243
    19
      target_directory=/home/my-project2
    20
      active=$active_2
    21
    fi
    22
    23
    source_war_loc=$home/my-project.war
    24
    target_log_loc=$target_directory/nohup.out
    25
    source_files_loc=$home/some-important-files/
    26
    target_files_loc=$target_directory/some-important-files/
    27
    28
    # 사용하고 있지 않은 java instance를 죽입니다.
    29
    if [ $(fuser $target_port/tcp) ]; then
    30
      fuser -n tcp -k $target_port 2>&1 > /dev/null
    31
    fi
    32
    33
    # 필요한 파일을 모두 복사합니다.
    34
    cp $source_war_loc $target_directory
    35
    rm -r $target_files_loc && cp -r $source_files_loc $target_files_loc
    36
    37
    # java instance를 실행합니다.
    38
    cd $target_directory
    39
    nohup java -Xms1000M -Xmx2000M -Dspring.profiles.active=$active -jar $target_war_loc > $target_log_loc 2>&1 &
    40
    41
    # nginx 설정 파일을 교체하여 새 java instance를 사용하도록 합니다.
    42
    sudo cp $home/nginx-conf/$config_prefix$target_port.conf /etc/nginx/conf.d
    43
    sudo rm -f '/etc/nginx/conf.d/'$config_prefix$current_port'.conf'
    44
    sudo systemctl reload nginx

기존 배포 시스템의 문제점

  1. 테스트 서버: 테스트 서버를 쓰고 싶은데 누가 테스트 서버를 쓰는지 모릅니다. 슬랙에 @here 지금 테스트 서버 사용하시는 분 계신가요? 라고 물어보고 사용했습니다.

  2. 중복 배포: 렌딧 중심의 크고 거대한 Spring framework 기반 프로젝트는 정말 크고 거대해서 두 개발자가 서로 다른 패치를 적용하기 위해 동시에 같은 서버로 배포하는 경우가 있습니다. 중복으로 배포가 되면 앞서 설명한 배포 스크립트가 2번 실행해서 장애가 발생할 수 있습니다. 이를 막기 위해 배포 관련 알림이 오는 Slack 채널을 항상 확인하고 배포를 수행했어야 합니다.

  3. 배포 내용: 이 글의 제일 위에서 설명해 드렸지만, 렌딧에서는 PR이 approved 되면 author가 원할 때 master에 merge하고, merge 즉시 배포를 수행합니다. 하지만 간혹 merge만 하고 배포를 까먹거나, 기타 정책상의 문제로 배포가 늦어질 때는 한 배포에 여러 PR이 같이 반영됩니다. 배포 후 장애가 생겼을 때는 PR의 merge 시간과 배포 시간을 가지고 문제가 되는 PR을 찾았습니다.

  4. Batch 서버: 렌딧에서는 서로 다른 일을 하는 여러 개의 batch 서버를 사용하고 있습니다. Batch 서버에서 특정 작업이 수행되고 있는 동안은 배포를 막아야 합니다. 예를 들어, 자동투자를 설정한 모든 사용자는 매일 오후 1시에 투자가 수행되는데 이 작업 중간에 배포가 된다면 재앙이 발생합니다. 하지만, 매분 수행되는 입금 확인 같은 작업은 배포로 인해 3분 정도 중단된다고 해도 특별한 문제가 되지 않습니다. 따라서 위키에 문서를 만들고, 각 batch 서버별로 어떤 작업이 몇 시에 수행되는지 기록한 후 해당 시간대에는 배포하지 않았습니다.

  5. IDC 서버: 렌딧은 AWS 말고 IDC에 있는 서버도 사용합니다. 법률 규제로 인해 개인의 신용 정보 등 특정 개인 정보는 AWS에 올릴 수 없고, 신용정보평가사 및 신탁 은행 등과의 시스템 연동 역시 전용 회선이 필요하여 AWS에서 할 수 없습니다. 이 IDC 서버로는 CodeDeploy를 통한 배포 설정이 되어있지 않아, 손(!)으로 배포를 했습니다.

과거형으로 서술된 것에서 눈치를 채신 분도 있으시겠지만, 지금은 위의 5가지 문제가 모두 해결되었습니다! 다음 3편의 글에서는 이 문제를 어떻게 해결했는지 다룰 예정입니다. 1~3번 문제를 해결한 배포를 배포답게! 배포 시스템 개선하기 - (2)에서 뵙겠습니다.

+ 이미지는 A팀의 Blake께서 만들어주셨습니다. 감사합니다!