[AWS] ECS 배포 환경 구축 과정의 Troubleshootings

[AWS] ECS 배포 환경 구축 과정의 Troubleshootings

현재 근무 중인 회사의 서비스에는 AWS EB(Elastic Beanstalk)를 이용한 배포 환경이 구축되어 있다. 그러나 최근에 이러한 배포 환경을 Docker 기반의 AWS ECS(Elastic Container Service)를 이용한 배포 환경으로 바꿔야 한다는 필요성을 느끼고 해당 작업을 필자가 맡게 되었다. 그 과정에서 Docker와 ECS를 학습하고 이를 바탕으로 수많은 삽질 끝에 기본적인 ECS 배포 방법을 익힐 수 있게 되었다. 이에 대한 내용은 직전 포스팅에 자세히 작성되어 있다.

그러나 이는 말 그대로 '기본적인' 배포 방법이었을 뿐, 실제 회사의 서비스에 적용하기에는 무리가 있었다. 이로 인해 직전 포스팅의 내용을 기반으로 서비스를 ECS에 배포하려 할 때 현실적으로 고려해야만 했던 문제들, 그리고 이로 인해 매우 막막하고 고통스러웠던 그때의 기억과 이로부터 필자를 구원해준 해결책들을 이번 포스팅을 통해 기록해보도록 할 것이다.

1. 보안을 위해, 환경 변수를 Dockerfile에서 분리하기

Dockerfile 혹은 docker-compose 설정 파일에 환경 변수들을 직접 작성하는 것은 좋지 않다. 이 파일들은 Git 원격 저장소에 푸시되기 때문이다. 물론 Private 저장소면 괜찮다고 생각할 수도 있겠지만, 반은 틀리고 반은 맞는 말이다. 기본적으로 Private 저장소면 해당 파일들이 다른 사람들에게 직접적으로 노출되지는 않지만, 개발자들은 결국 그 저장소로부터 코드를 내려받아서 작업을 하기 때문에 결론적으로 그 파일들을 안전하게 관리하는 것은 개발자의 몫이 된다. 만약 로컬이 해킹당하거나, 내려받은 파일들을 개발자가 실수로 다른 곳에 옮기다가 유출시키면 비밀 정보가 노출되는 것이다. 따라서 Pirvate 저장소라고 해도 중요한 비밀 정보를 담은 파일들은 푸시하지 않는 편이 좋다.

필자의 경우, Dockerfile과 docker-compose 설정 파일이 로컬 환경 전용과 배포 환경(실서버, 테스트 서버) 전용으로 분리되어 있다. 따라서 각 환경에 대해 환경 변수들을 어떻게 관리할지 고민하였다. 그 고민의 결과를 이곳에 기록한다.

로컬 환경의 경우, 직접 .env 파일을 생성하고 그 안에 필요한 환경 변수들을 작성해주기로 하였다. 이를 위해, docker-compose 설정 파일이 그 파일을 읽어서 Django 컨테이너에 환경 변수들을 설정해줄 수 있도록 하였다. (대략 다음과 같은 형태로 작성한다.) 그리고 당연히 .gitignore 파일에 .env 를 적어서 .env 파일이 Git 원격 저장소에 푸시되지 않도록 해야 할 것이다.

# docker-compose 설정 파일 version: '3' services: postgres: ... django: ... env_file: - ./django/.env

# .env 파일 DJANGO_SETTINGS_MODULE=... ENV1=... ENV2=... ENV3=... ...

배포 환경의 경우, AWS Secrets Manager를 이용하여 환경 변수들을 관리하고 Django 컨테이너가 해당 환경 변수들을 참조할 수 있도록 작업을 정의하였다. 이를 위해, ecs-cli compose 명령어를 실행할 때 --ecs-params 인자를 이용하여 ECS 파라미터 파일을 전달해주도록 하였다. ECS 파라미터 파일은 작업을 조금 더 세밀하게 정의할 수 있도록 돕는 파일로, 이 파일을 이용하면 Django 컨테이너에 지정할 환경 변수들을 AWS Serets Manager에서 로드하도록 설정할 수 있다.

# .ecs-params 파일 version: 1 task_definition: task_execution_role: "ecsTaskExecutionRole" services: django: secrets: - value_from: ":DJANGO_SETTINGS_MODULE::" name: "DJANGO_SETTINGS_MODULE" - value_from: ":ENV1::" name: "ENV1" - value_from: ":ENV2::" name: "ENV2" - value_from: ":ENV3::" name: "ENV3" ...

이때 ecsTaskExecutionRole 은 AWS Console에서 생성해야 하는 역할의 이름으로, 해당 역할에는 SecretsManagerReadWrite , AmazonECSTaskExecutionRolePolicy 정책을 연결해줘야 한다. 이는 ECS 컨테이너 에이전트가 AWS Secrets Manager로부터 환경 변수들을 로드하여 이를 바탕으로 컨테이너들을 실행시킬 수 있도록 권한을 부여한다.

로컬 환경과 달리 .env 파일을 이용하지 않은 것은, CI/CD 스크립트가 실행되는 환경에 .env 파일을 두려면 결국 또 이 파일을 Git 원격 저장소에 푸시해야 하는데 이렇게 할 거면 환경 변수를 분리하는 의미가 없기 때문이다.

2. ECS CLI를 사용하려면 ecs-cli configure가 먼저

ECS 배포를 위한 CI/CD 스크립트를 작성하던 도중, ecs-cli configure 명령어를 작성하지 않아서 ECS 배포에 실패하였다. 필자의 경우, 처음에는 해당 명령어가 단순히 클러스터 설정을 정의하는 행위이고 클러스터 설정은 클러스터 생성 시에만 필요하기 때문에, 클러스터가 이미 생성되어 있는 경우에는 해당 명령어를 작성할 필요가 없다고 생각했다. 하지만 알고 보니 클러스터 설정이라는 것은 클러스터 생성 시에만 필요한 것이 아니라, 해당 클러스터를 대상으로 한 ECS CLI 명령어를 실행할 때마다 필요한 것이었다. 즉, 이는 ECS CLI를 사용하기 위한 기본적인 설정으로 보는 것이 더 타당하였다. 따라서 CI/CD 스크립트에도 이를 작성해주었다.

3. Git CRLF/LF 변환 설정 문제 해결

필자의 경우 Windows 10 환경에서 작업을 한다. 그런데 Windows 환경과 Linux/macOS 환경은 개행 문자를 다르게 표현한다. Windows 환경에서는 개행 문자를 CR(Carriage Return) 문자와 LF(Line Feed) 문자를 이용하여 표현하지만, Linux/macOS 환경에서는 개행 문자를 오로지 LF(Line Feed) 문자만을 이용하여 표현한다. 이로 인해 Windows 환경의 사람과 Linux/macOS 환경의 사람이 함께 작업을 하는 경우에 예상치 못한 문제가 발생할 수 있다. 필자도 이번에 이러한 덫에 걸러버렸다.

배포 관련 설정을 조금 바꿀 때마다 매번 Git 원격 저장소에 푸시해서 Circle CI를 돌리는 것은 시간적으로 낭비라고 생각하여, 이럴 때는 그냥 로컬에서 직접 명령어를 입력하여 Circle CI의 배포 명령어를 흉내 냈다. 그런데 분명 같은 명령어를 실행했는데도 로컬에서 할 때는 ECS 배포에 자꾸 실패하는 것이었다. 구체적으로는, 컨테이너가 다음과 같은 형태의 에러 메시지를 뱉으며 종료되었다.

standard_init_linux.go:XXX: exec user process caused "no such file or directory"

수많은 검색 끝에, 위 에러 메시지는 개행 문자가 CRLF로 표현되어 있는 Docker 엔트리 포인트 .sh 파일을 사용했기 때문에 발생한 것임을 알게 되었다. 또한 이는 Windows 환경의 편집기가 자동으로 LF 문자를 CRLF 문자로 바꾸고, 엔터를 입력할 때도 CRLF 문자를 사용하도록 하였기 때문이었음을 알게 되었다. 그러나 Git에서 auto.crlf 설정이 true로 되어 있었기 때문에 Git 원격 저장소에는 CRLF 문자가 LF 문자로 바뀌어 올라갔고, 이로 인해 Circel CI에서는 정상적으로 ECS 배포에 성공하는 것이었다. 따라서 로컬에서 ECS 배포를 시도할 때는 편집기에서 직접 CRLF 설정을 LF 설정으로 바꿔줘야 한다는 것을 알게 되었다.

Git의 auto.crlf 설정에 대한 개념을 정리해보면 다음과 같다.

git config --global core.autocrlf true : 커밋할 때 자동으로 CRLF 문자를 LF 문자로 변환해주고, 반대로 Checkout 할 때는 LF 문자를 CRLF 문자로 변환해준다.

git config --global core.autocrlf input : 커밋할 때만 자동으로 CRLF 문자를 LF 문자로 변환해준다. 우연히 CRLF 문자가 들어간 파일이 저장소에 있다면 이를 교정해준다.

git config --global core.autocrlf false : CRLF 문자와 LF 문자의 자동 변환을 아예 하지 않는다. 따라서 CRLF 문자도 저장소에 저장될 수 있다.

참고로, Linux에서 다음과 같은 명령어를 이용하면 CRLF 문자로 표현되어 있는 개행 문자를 LF 문자로 바꿀 수 있다.

sed -i -e 's/\r$//' <파일 경로>

4. Nginx 세부 설정 (보일러 플레이트 설정 활용)

꼭 필요한 Nginx 설정은 직접 다 이해하고 작성 완료하였지만, 이대로 실제 서비스에 사용하기에는 찝찝한 부분이 없지 않아 있었다. 웹 서버 설정만큼은 해당 서비스의 성능이나 보안에 가장 결정적인 영향을 주기 때문이다. 과연 단순히 기능적으로만 잘 동작하도록 하는 이 설정이 충분한가에 대한 회의가 있었다.

개인적으로 필자는 보일러 플레이트 설정을 썩 좋아하지 않는다. 맹목적으로 아무 이해 없이 보일러 플레이트 설정을 가져다 사용하게 되면 문제가 발생했을 때 어디가 문제인지를 파악해내기 쉽지 않으며, 이후 유지 보수도 어렵기 때문이다. 그래서 웬만하면 보일러 플레이트 설정은 참고만 하고, 관련 설정을 직접 다 이해한 후 작성해주는 편이다.

하지만 Nginx는 예외로 두기로 하였다. Nginx의 경우 Django만큼 익숙한 기술이 아닐뿐더러, Django만큼 공부에 많은 시간을 투자하기에는 시간 대비 효율이 떨어진다고 판단하였기 때문이다. 즉, 이미 작성되어 있는 기본적이고 필수적인 설정을 제외한 나머지 세부 설정은 보일러 플레이트 설정(EX. h5bp)을 믿고 활용하기로 하였다. 물론 이것조차 완전히 맹목적으로 복사 붙여 넣기를 한 것은 아니고, 주석으로 설명되어 있는 내용을 차근차근 곱씹어 보며 필요하다고 어렴풋이 판단되면 가져다 사용하는 전략을 택하였다. 대표적인 Nginx 보일러 플레이트 설정인 h5bp에 대해 자세히 알고 싶다면 다음 링크를 참조하자.

5. Nginx robots.txt 설정

AWS EB에서는 robots.txt 를 읽도록 별도로 설정해주었지만, AWS ECS로 옮기면서 robots.txt 를 읽도록 하기 위한 설정을 Nginx에 별도로 해줘야만 했다. 이때 방법은 두 가지였다. 하나는 robots.txt 를 작성하고 이 파일을 Nginx 컨테이너 안으로 COPY 한 뒤 Nginx 설정 파일이 이 파일을 읽어서 /robots.txt 요청을 처리할 수 있도록 하는 것이었고, 다른 하나는 /robots.txt 요청에 직접 하드 코딩한 문자열로 응답하도록 하는 것이었다. 필자의 경우, robots.txt 파일에 작성할 내용이 그다지 길지 않았기에 후자의 방법을 선택하였다. 다음과 같은 location 블록을 server 블록 안에 작성하였다.

location = /robots.txt { return 200 ""; }

6. 초기화 동작 전용 별도의 작업 생성

클러스터를 구성하는 EC2가 두 개 이상이라면, 배포 시마다 한 번만 일어나야 하는 동작(= 초기화 동작)에 대한 세심한 주의가 필요하다. 필자의 경우, Django 컨테이너에서 migrate 명령어 및 collectstatic 명령어의 실행과 Cron Job의 실행이 단 한 번만 일어나야 하는 초기화 동작에 해당하였다.

사실 AWS EB에서는 리더 인스턴스(Leader Instance)의 개념이 존재하였기 때문에, 리더 인스턴스에서만 초기화 동작이 일어나도록 설정하면 되기에 어려운 문제가 아니었다. 그러나 AWS ECS에서는 그런 개념이 존재하지 않았기에 많은 고민이 필요하였다. 이 과정에서 운이 좋게도 다음과 같은 글을 발견하였고, 여기서 아이디어를 얻었다.

이 글에서 제시하는 아이디어는 다음과 같다. 초기화 동작을 담당하는 별도의 작업(= 초기화 작업)을 임의의 한 EC2에 배치하도록 하고, 서비스 단위로 배포되던 작업들에서는 초기화 동작을 실행하지 않도록 한다. 그리고 Django는 ELB의 Health Check 요청을 Nginx로부터 전달받으면 마이그레이션 히스토리를 살펴보는 데이터베이스 쿼리를 날려서 현재 마이그레이션이 필요한 상황이라면 503 응답(→ Unhealthy 상태로 판정)을 반환하고 아니라면 200 응답(→ Healthy 상태로 판정)을 반환하도록 한다.

# Nginx 설정 파일 location = /check-elb-status { proxy_pass http://django/check-elb-status; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; }

# Django 뷰 from django.db import DEFAULT_DB_ALIAS, connections from django.db.migrations.executor import MigrationExecutor def check_elb_status(request): executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS]) plan = executor.migration_plan(executor.loader.graph.leaf_nodes()) # 마이그레이션이 필요한 경우 Unhealthy 상태가 되도록 응답 반환 status = 503 if plan else 200 return HttpResponse(status=status)

그러면 평상시에는 마이그레이션이 필요하지 않은 상황이기 때문에 ELB의 Health Check 요청에 정상적으로 200 응답을 반환할 것이다. 그러나 마이그레이션을 동반하는 변동 사항을 서버에 배포하는 경우, 재생성되는 초기화 작업에서 마이그레이션이 완료되기 전까지는 서비스 단위로 배포되는 새로운 작업들이 Unhealthy 상태로 판정되어 트래픽 수신이 되지 않을 것이다. 새로운 작업들의 경우 마이그레이션이 필요한 상황이기 때문이다. 이후 초기화 작업에서 마이그레이션이 완료되면, 새로운 작업들이 Healthy 상태로 판정되어 이제부터 트래픽이 수신될 것이다. 그리고 기존의 작업들은 이제 Draining 상태가 되면서 대상 그룹에서 등록 해제되어 더 이상 트래픽 수신이 되지 않을 것이다. 이로 인해 서버의 코드는 변경되었는데 마이그레이션은 아직 완료되지 않아서 서버의 동작에 오류가 발생하는 시간이 거의 생기지 않게 된다. 마이그레이션의 완료 시점을 기준으로 그 이전에는 기존의 코드로, 그 이후에는 새로운 코드로 동작하도록 깔끔히 구분하였기 때문이다. 여기까지가 위 글에서 제시한 기본적인 아이디어이다.

이 아이디어를 활용하면 migrate 명령어와 collectstatic 명령어로 구성되는 초기화 동작이 배포 시마다 단 하나의 EC2에서 한 번만 실행되도록 할 수 있다. 이를 위해, 초기화 작업 전용의 docker-compose 설정 파일과 이 파일이 참조하는 엔트리 포인트 .sh 파일을 다음과 같이 따로 작성해주었다.

# 초기화 작업 전용 docker-compose 설정 파일 version: '3' services: django: ... container_name: django-init-container entrypoint: - /docker-entrypoint.production.init.sh

# docker-entrypoint.production.init.sh 파일 #!/bin/bash python3 manage.py migrate --noinput python3 manage.py collectstatic --noinput

그런데 여기서는 Cron Job의 실행이 고려되지 않았다. 따라서 다음과 같이 Cron Job의 실행을 위한 코드도 추가해줬다.

#!/bin/bash python3 manage.py migrate --noinput python3 manage.py collectstatic --noinput # 여기에 Cron Job 실행 관련 설정 및 Cron Job 실행 코드 작성 필요 tail -f /dev/null

Cron Job 실행 관련 설정 및 Cron Job 실행 코드는 작성하는 방법이 다양하니 직접 찾아보기 바란다. 마지막 줄에 있는 명령어는 컨테이너가 종료되지 않고 계속 살아 있도록 함으로써 초기화 작업의 종료를 방지하는 역할을 수행한다.

여기서부터는 migrate 명령어의 실행, collectstatic 명령어의 실행, Cron Job의 실행과 관련하여 따로 고민이 좀 더 필요했던 부분들에 대해 간단히 기록한다.

6-1. migrate 명령어의 실행 ▶ ELB의 Health Check 요청은 얼마나 자주 필요한가?

위의 전략이 얼마나 안정적인 배포를 가져오는가는 곧 ELB의 Health Check 요청 주기에 크게 의존한다. 왜냐하면 마이그레이션이 완료된다고 해서 새 작업들이 바로 Healthy 상태로 판정되는 것은 아니기 때문이다. Healthy 상태로 판정되려면 일단 Health Check 요청이 오고 그것을 통과해야 하기 때문에, 최소한 Health Check 요청이 올 때까지는 기다려야 하는 것이다. 따라서 만약 Health Check 요청의 주기가 30초라면, 마이그레이션은 되었지만 아직 코드가 갱신되지 않은 기존 작업들이 트래픽을 수신하고 있어서(새 작업들이 아직 Healthy 상태로 판정되지 못했기 때문) 서버의 동작에 문제가 발생할 수도 있는 시간(이하 '위험한 시간')이 최대 30초가 된다.

그런데 여기서 하나 더 고려할 점이 있다. Health Check 요청을 통과한다고 해서 바로 Healthy 상태로 판정되고, 통과하지 못한다고 해서 바로 Unhealthy 상태로 판정되는 것은 아니라는 것이다. 실제로 상태 검사 세부 설정에 들어가 보면, 위에서 말한 주기에 해당하는 '간격'이라는 설정 말고도 '정상 임계 값'이라는 설정이 존재한다. '정상 임계 값'이란 Health Check 요청을 연속으로 몇 번 통과해야 Healthy 상태로 판정되도록 할 것인지를 의미한다. 따라서 '간격'을 30초로 지정해도 '정상 임계 값'이 3이라면 '위험한 시간'은 최대 90초가 된다.

결론적으로, '간격' X '정상 임계 값'이 곧 '위험한 시간'의 최댓값이 된다. 그렇다면 이 두 설정 값을 최대한 작게 설정하는 것이 무조건 바람직할까? 그렇지는 않다. 왜냐하면 위의 전략대로라면 각 Health Check 요청은 데이터베이스 쿼리를 동반해서 Health Check 요청이 너무 잦으면 서버의 부담이 가중되기 때문이다. 따라서 이러한 trade-off를 고려하여 각각의 설정 값을 신중하게 결정하였다.

6-2. collectstatic 명령어의 실행 ▶ 조금 더 빠르게 할 수는 없을까?

Django의 collectstatic 명령어는 정적 파일들을 전부 수집하여 한 곳에 모으는 역할을 수행한다. 위에서 말했듯이 이 동작도 초기화 동작에 포함되기 때문에 배포 시마다 실행이 된다. 그런데 매번 작업이 새로 생성되어 캐시가 존재하지 않아서인지, collectstatic 명령어의 실행이 생각보다 좀 느린 듯하여 최적화의 필요성을 느꼈다. 그러던 중 collectfast 라는 패키지를 발견하였고, 적용하는 방법이 매우 간단하여 바로 적용하였다. 이로 인해 collectstatic 명령어의 실행이 훨씬 더 빨라졌다.

이 패키지는 기존 collectstatic 명령어를 오버라이드 하여 더 빠른 속도로 동작하도록 설계되었다. 특히 Redis 등의 캐시 서버를 지정함으로써 캐시를 활용한 속도 최적화를 가능하게 한 것이 큰 특징이다.

6-3. Cron Job의 실행 ▶ 모든 것을 직접 챙겨줘야 했다.

AWS EB를 사용할 때는 Cron Job의 실행을 설정하는 것이 어렵지 않았다. 아마 필자가 모르는 기본적인 설치 및 설정 과정을 어느 정도 알아서 해줬을 것으로 보인다. 그러나 AWS ECS로 바꾸게 되면서 Cron Job의 실행을 위한 설정을 전부 직접 해줘야 했다. 예를 들어, Python 이미지를 기반으로 생성되는 컨테이너에는 기본적으로 Cron Job의 실행을 위한 cron 이 설치되어 있지 않았다. 따라서 Dockerfile에 다음과 같은 cron 설치 명령어를 추가해줘야 했다.

RUN apt-get update && apt-get install -y cron

참고로 위 명령어는 crontab 도 함께 설치해준다. crontab 은 어떠한 Cron Job들을 어떠한 시간 규칙으로 실행할 것인지에 대한 설정을 해주기 위한 도구라고 생각하면 된다. cron 은 crontab 에 의해 설정된 내용을 참조하여 어떠한 Cron Job들을 어떠한 시간 규칙으로 실행해야 하는지 알 수 있는 것이다. crontab 의 기본적인 사용 방법은 다음과 같다.

# Print Current Crontab Configuration crontab -l # Set Crontab Configuration crontab

그리고 cron 을 실행/종료하는 방법은 다음과 같다.

# Start Executing Cron Jobs /etc/init.d/cron start # Stop Executing Cron Jobs /etc/init.d/cron stop # Start Executing Cron Jobs When Rebooted update-rc.d cron defaults

여기까지가 Cron Job 실행의 설정과 관련된 기본적인 개념이고, 실제로 맞닥뜨린 가장 큰 난관은 따로 있었다. 바로 Cron Job이 시스템에 이미 설정되어 있는 환경 변수들을 읽지 못하는 문제였다. 이러한 사실은 로컬 환경에서만 필요한 Python 패키지에 대해 import 에러가 발생하는 것을 보고 유추할 수 있었다. 이는 곧 DJANGO_SETTINGS_MODULE 이라는 환경 변수의 값이 실서버 전용 설정 파일을 제대로 가리키지 못하고 있었음을 의미했기 때문이다. 참고로 이와 같은 import 에러를 보기 위해서는 crontab 으로 Cron Job의 실행을 설정할 때 해당 Cron Job의 실행에서 발생하는 오류가 특정 파일에 기록해주도록 다음과 같이 설정해줘야 했다.

# Assume that /tmp/cronjob-log file exists. */1 * * * * {Cron Job에 해당하는 명령어} >> /tmp/cronjob-log 2>&1

원인을 파악하고 이에 대해 구글링을 해본 결과, 많은 사람들이 비슷한 문제로 어려워하고 있었다. 다행히 이에 대한 해결책들이 잔뜩 설명되어 있는 좋은 스택 오버플로우 글을 하나 찾았고, 여기서 해결을 위한 아이디어를 얻었다.

그것은 바로 printenv 명령어에 의해 출력되는 현재 시스템의 환경 변수들을 /etc/environment 파일로 복사해주는 것이었다. Cron Job들은 이 파일로부터 환경 변수들을 로드하기 때문이다. 굉장히 오래 고생한 것에 비해 해결책은 의외로 단순하였다.

7. CSV 다운로드 실패(502 Bad Gateway) 문제 해결

7-1. 문제 발생

AWS EB를 사용할 때와 똑같은 성능의 EC2를 사용했는데도, 몇몇 무거운 CSV 파일을 다운로드할 때 서버가 버티지 못하고 502 Bad Gateway 응답을 반환하는 문제가 발생했다. Nginx의 에러 메시지는 대략 다음과 같았다.

[Nginx] upstream prematurely closed connection while reading response header from upstream

업스트림(Upstream)에 해당하는 Django 서버가 제대로 된 응답을 반환하기 전에 터진 것이다.

7-2. 원인 파악

원인은 크게 두 가지 중에 하나라고 생각했다. 하나는 시간 초과이고, 다른 하나는 메모리 초과이다. 그런데 시간 초과 때문은 아닌 것 같았다. 왜냐하면 CSV 다운로드를 할 때는 대략 5초 만에 터졌는데, 5초 이상 걸리는 특정 페이지는 잘 접속이 되었기 때문이다. 또한, 애초에 5초 정도의 타임아웃을 그 어떤 곳에도 설정한 적이 없으며 기본 값이 5초인 타임아웃 설정도 찾지 못했다. 따라서 메모리 초과 때문일 거라고 생각했는데, 막상 Django 컨테이너에 접속한 뒤 top 명령어를 실행한 상태로 CSV 다운로드를 시도해보니, 메모리 점유율은 크게 변동이 없는데 CPU 점유율만 97퍼까지 급등하며 Gunicorn 워커 프로세스가 죽는 것을 발견하였다. 그래서 메모리 문제가 아닌 건가 싶어서 당황을 했고, 도대체 문제가 무엇일까 싶어서 여러 가지 시도를 해보았다. 혹시 워커 프로세스 개수나 쓰레드 개수가 부족했던 건가 싶어서 Gunicorn의 설정을 다음과 같이 바꿔보기도 했지만 전혀 해결되지 않았다.

gunicorn --bind 0.0.0.0:8000 --workers 3 --threads 20 config.wsgi:application

어떻게 해도 원인을 파악하기 어려워서, 이번에는 Gunicorn의 로그를 살펴보기로 했다. Nginx와 달리 Gunicorn은 기본적으로 로깅이 설정되어 있지 않아서, Gunicorn 실행 시 다음과 같은 설정을 추가하여 로깅을 활성화해야 했다.

gunicorn --bind 0.0.0.0:8000 --workers 3 --threads 20 --access-logfile - --error-logfile - --log-level debug config.wsgi:application

이후 다시 CSV 다운로드를 시도해서 Gunicorn의 로그를 살펴보니, 에러 메시지가 대략 다음과 같았다.

[Gunicorn] Worker with pid XXX was terminated due to signal 9

위 에러 메시지를 구글링 해보니, 스택 오버플로우에서도, 그리고 Gunicorn 공식 문서에서도 메모리 초과로 인해 Gunicorn 워커 프로세스가 죽었을 가능성이 높다고 설명하고 있었다. 그러고 보니 CPU 점유율이 올라간 것도 메모리 초과에 의한 순간적인 파생 현상일 수 있겠다는 생각이 들었다. 하지만 확실한 근거는 아니었기에 조금 더 삽질을 반복하며 조사를 진행하던 중, 다음과 같은 방법으로 특정 프로세스(pid : XXX)가 죽은 이유를 확인해볼 수 있음을 알게 되었다.

# Run this command outside the docker container. dmesg -T | grep -A 10 -B 10 "XXX" # XXX : Killed Gunicorn Worker Process ID

... (생략) ...

Memory cgroup out of memory: Kill process XXX (gunicorn) score 667 or sacrifice child

... (생략) ...

이를 통해 메모리 초과가 확실한 원인임을 알게 되었고, 이제부터는 컨테이너 혹은 작업 단위의 메모리 용량 관련 설정에 대해서만 파고들었다. 그 결과, 컨테이너 단위의 메모리 용량 관련 설정을 명시적으로 해줌으로써 해결할 수 있는 문제임을 깨닫게 되었다.

7-3. 문제 해결

여러 가지 조사 끝에, Docker에서는 컨테이너의 실행과 관련하여 메모리 용량 관련 설정을 해줄 수 있음을 알게 되었다. 조금 더 구체적으로, 메모리 용량의 Soft/Hard Limit이라는 개념이 존재한다는 것을 알게 되었는데, 간단히 요약해서 Soft Limit은 메모리 용량의 최솟값이고 Hard Limit은 메모리 용량의 최댓값을 의미한다.

이는 원래 docker run 명령어의 인자( --memory , --memory-reservation )로 주는 옵션이다. --memory 옵션은 Hard Limit을 의미하고, --memory-reservation 옵션은 Soft Limit을 의미한다.

한편 작업 정의의 각 컨테이너 정의 부분에서 memory , memoryReservation 옵션을 설정해주면 이것이 자동으로 docker run 명령어의 인자로 맵핑된다고 한다. 작업 정의를 기반으로 작업을 생성하고 컨테이너를 실행하기 때문일 것이다.

그런데 docker run 명령어의 인자로 설정해줄 수 있는 옵션들은 대부분 docker-compose 설정 파일에서도 설정해줄 수 있다. 그리고 실제로 작업 정의를 만들어내는 것도 결국은 docker-compose 설정 파일이다. 따라서 docker-compose 설정 파일에서도 메모리 용량 관련 설정을 해줄 수 있을 것으로 추측이 가능하다. 실제로, docker-compose 설정 파일에서도 mem_limit , mem_reservation 옵션을 설정해주면 이것이 곧 작업 정의의 해당 옵션들로 자동 맵핑된다.

또한 docker-compose 설정 파일 대신에 ECS 파라미터 파일에서도 mem_limit , mem_reservation 옵션을 설정해줄 수 있는데, 이는 docker-compose 설정 파일의 해당 옵션들을 덮어쓴다. 즉, 메모리 용량 관련 설정은 docker-compose 설정 파일에서도, ECS 파라미터 파일에서도 가능한 것이다. 다만 version 3의 docker-compose 설정 파일에서는 mem_limit , mem_reservation 옵션을 사용할 수 없기 때문에 반드시 ECS 파라미터 파일을 이용해야 한다는 주의사항이 있다.

필자의 경우, 테스트 서버와 실서버에서 사용하는 EC2의 성능이 다르기 때문에, 처음에는 메모리 용량 관련 설정을 구분해주기 위해 docker-compose 설정 파일을 테스트 서버 전용과 실서버 전용으로 구분했었다. 하지만 생각해 보니 어차피 환경 변수의 구분을 위해 ECS 파라미터 파일을 테스트 서버 전용과 실서버 전용으로 구분하여 사용하고 있었기 때문에, ECS 파라미터 파일을 활용하여 메모리 용량 관련 설정을 각각 해주기로 하였다.

아무런 설정을 해주지 않으면 ECS는 알아서 Hard Limit을 512MB로 설정하고(Soft Limit은 따로 설정하지 않음) 컨테이너를 실행하는 듯했다. 그렇다 보니 무거운 CSV 파일을 다운로드할 때 512MB가 부족하면 메모리 초과에 의해 Gunicorn 워커 프로세스가 죽은 것이었다. 그래서 처음에는 단순하게 Hard Limit을 늘려주면 된다고 생각해서 EC2의 메모리 용량에 거의 가깝게 큰 값으로 Hard Limit을 설정해줬다. 그런데 이렇게 설정하니 ECS가 대략 다음과 같은 에러 메시지를 뱉으며 배포에 실패하였다.

... was unable to place a task because no container instance met all of its requirements. The closest matching (container-instance ...) has insufficient memory available.

생각해 보니 당연한 결과였다. 배포를 하는 시점에는 순간적으로 하나의 EC2에 두 개의 작업이 배치될 수 있는데, 그 경우 각 작업의 컨테이너에 할당된 Hard Limit을 합치면 EC2의 메모리 용량을 초과하기 때문이다. 그런데 그렇다고 해서 대략 EC2 메모리 용량의 50%만큼을 Hard Limit으로 설정해주기는 싫었다. 아주 잠깐 두 작업이 공존할 수 있다는 이유로 평상시에 EC2 메모리 용량의 반밖에 사용하지 못하는 것은 너무 리소스 낭비라고 생각했기 때문이다. 사실 필자가 원한 것은, 컨테이너가 처음에는 적당량의 메모리 용량만 할당받고, CSV 다운로드와 같이 무거운 작업을 할 때만 메모리를 당겨서 사용하도록 설정하는 것이었다. 하지만 이 당시에는 Hard Limit과 Soft Limit의 차이점을 정확히 이해하지 못하고 있었다. Hard Limit과 Soft Limit의 차이점을 이해한 후에는 이 문제를 간단히 해결할 수 있었다.

Hard Limit과 Soft Limit이 둘 다 설정되어 있는 경우, 컨테이너는 Soft Limit만큼의 메모리 용량을 할당받으며 생성된다. 하지만 Soft Limit이 설정되어 있지 않은 경우에는 Hard Limit만큼의 메모리 용량을 할당받으며 생성된다. 따라서 위 문제를 해결하는 방법은, Soft Limit을 작게 설정하여 처음에는 적당량의 메모리 용량만 할당받도록 하고, Hard Limit을 EC2의 메모리 용량에 거의 가깝게 큰 값으로 설정하여 무거운 작업이 요구될 때만 메모리를 당겨서 사용하도록 하는 것이다. 이를 통해 Django 컨테이너의 Gunicorn 워커 프로세스가 터지는 문제를 해결할 수 있었다. 참고로, 비슷한 위험을 잠재적으로 가지는 Nginx 컨테이너도 동일하게 설정해줌으로써 메모리 활용의 유연성을 확보하였다.

7-4. 추가 문제 발생 및 해결

메모리 초과 문제를 해결하고 나니, 시간 초과 문제가 발생하였다. 충분한 메모리가 있기에 터지지 않고 열심히 작업을 하긴 하는데, 그 작업이 워낙 무거워서 오래 걸리는 경우에 Nginx와 Gunicorn의 타임아웃 설정을 초과하게 되는 것이었다. 이를 위해 Nginx와 Gunicorn의 타임아웃 설정을 건드려줄 필요가 있었다.

Nginx의 경우, keepalive_timeout 설정과 proxy_read_timeout 설정의 값을 늘려주었다. 타임아웃 설정은 굉장히 다양한데, 다 필요하다고 생각하지는 않았다. 예를 들어, proxy_send_timout 설정은 파일을 업로드하는 데 시간이 오래 걸리는 경우에 값을 늘려줘야 하지만, 우리 서비스의 경우 무거운 파일을 업로드할 일이 없었기에 굳이 설정해주지 않았다. 그리고 Gunicorn의 경우, Gunicorn 실행 시 인자로 설정할 수 있는 --timeout 옵션의 값을 늘려주었다. 이를 통해 시간 초과 문제는 메모리 초과 문제에 비해 (상대적으로) 쉽게 해결할 수 있었다.

8. ELB Health Check가 동시에 여러 번 요청되는 문제 해결 (feat. 가용 영역)

어떻게 보면 안 중요하고, 어떻게 보면 매우 중요한 내용이다. 우연히 Nginx 로그를 실시간으로 살펴보던 중, ELB의 Health Check가 한 번에 여러 번 요청되는 현상을 발견하였다. 분명 각 EC2에 대해서는 대상 그룹에 지정한 상태 검사 주기마다 한 번씩 요청을 받는 것이 맞을 텐데, 왜 여러 번 요청이 오는 것인지 알 수 없어서 크게 당황했었다.

알고 보니, 로드 밸런서를 생성할 때 네 개의 가용 영역을 선택했기 때문이었다. 이는 곧 네 개의 가용 영역에 각각 로드 밸런서 노드가 배치되는 것을 의미했다. 그리고 기본적으로 ALB(Application Load Balancer)는 교차 영역 로드 밸런싱(Cross Zone Load Balancing)이 활성화되어 있기 때문에, 네 개의 로드 밸런서 노드는 대상 그룹에 등록된 모든 가용 영역의 EC2에 Health Check 요청을 보내도록 되어 있다. 따라서 각 EC2는 한 번에 네 개의 Health Check 요청을 받고 있었던 것이다.

그러나 우리의 경우 Health Check 요청을 단순히 정적인 응답으로 처리하지 않고, 마이그레이션 히스토리를 살펴보는 데이터베이스 쿼리를 동반한 동적인 응답으로 처리하기 때문에, Health Check 요청이 한 번에 너무 여러 번 들어오면 서버의 부담이 커질 거라고 판단했다. 따라서 로드 밸런서를 생성할 때 네 개의 가용 영역이 아닌 두 개의 가용 영역만 선택함으로써 Health Check의 부담을 줄이기로 하였다.

여기에 작성한 것만 보면 굉장히 쉽게 해결한 것 같지만, 사실 엄청난 삽질과 검색을 필요로 하였다. 그 과정에서 도움을 준 글들은 다음과 같다. 특히 AWS의 공식 문서는 로드 밸런서의 동작 원리를 자세히 설명하므로, 시간만 되면 한 번 쭉 읽는 것도 좋을 것 같다.

from http://it-eldorado.tistory.com/167 by ccl(A) rewrite - 2021-12-06 03:27:27