현재 근무 중인 회사의 서비스에는 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
설정 파일▼
.env
파일배포 환경의 경우, AWS Secrets Manager를 이용하여 환경 변수들을 관리하고 Django 컨테이너가 해당 환경 변수들을 참조할 수 있도록 작업을 정의하였다. 이를 위해,
ecs-cli compose
명령어를 실행할 때 --ecs-params
인자를 이용하여 ECS 파라미터 파일을 전달해주도록 하였다. ECS 파라미터 파일은 작업을 조금 더 세밀하게 정의할 수 있도록 돕는 파일로, 이 파일을 이용하면 Django 컨테이너에 지정할 환경 변수들을 AWS Serets Manager에서 로드하도록 설정할 수 있다.▼
.ecs-params
파일이때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 문자로 변환한다.
git config --global core.autocrlf false
: CRLF/LF 문자의 자동 변환을 수행하지 않는다.
참고로, Linux에서 다음과 같은 명령어를 이용하면 CRLF 문자로 표현되어 있는 개행 문자를 LF 문자로 바꿀 수 있다.
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
블록 안에 작성하였다.6. 초기화 동작 전용 별도의 작업 생성
클러스터를 구성하는 EC2가 두 개 이상이라면, 배포 시마다 한 번만 일어나야 하는 동작(= 초기화 동작)에 대한 세심한 주의가 필요하다. 필자의 경우, Django 컨테이너에서
migrate
명령어 및 collectstatic
명령어의 실행과 Cron Job의 실행이 단 한 번만 일어나야 하는 초기화 동작에 해당하였다.사실 AWS EB에서는 리더 인스턴스(Leader Instance)의 개념이 존재하였기 때문에, 리더 인스턴스에서만 초기화 동작이 일어나도록 설정하면 되기에 어려운 문제가 아니었다. 그러나 AWS ECS에서는 그런 개념이 존재하지 않았기에 많은 고민이 필요하였다. 이 과정에서 운이 좋게도 다음과 같은 글을 발견하였고, 여기서 아이디어를 얻었다.
이 글에서 제시하는 아이디어는 다음과 같다. 초기화 동작을 담당하는 별도의 작업(= 초기화 작업)을 임의의 한 EC2에 배치하도록 하고, 서비스 단위로 배포되던 작업들에서는 초기화 동작을 실행하지 않도록 한다. 그리고 Django는 ELB의 Health Check 요청을 Nginx로부터 전달받으면 마이그레이션 히스토리를 살펴보는 데이터베이스 쿼리를 날려서 현재 마이그레이션이 필요한 상황이라면 503 응답(→ Unhealthy 상태로 판정)을 반환하고 아니라면 200 응답(→ Healthy 상태로 판정)을 반환하도록 한다.
▼ Nginx 설정 파일
▼ Django 뷰
그러면 평상시에는 마이그레이션이 필요하지 않은 상황이기 때문에 ELB의 Health Check 요청에 정상적으로 200 응답을 반환할 것이다. 그러나 마이그레이션을 동반하는 변동 사항을 서버에 배포하는 경우, 재생성되는 초기화 작업에서 마이그레이션이 완료되기 전까지는 서비스 단위로 배포되는 새로운 작업들이 Unhealthy 상태로 판정되어 트래픽 수신이 되지 않을 것이다. 새로운 작업들의 경우 마이그레이션이 필요한 상황이기 때문이다. 이후 초기화 작업에서 마이그레이션이 완료되면, 새로운 작업들이 Healthy 상태로 판정되어 이제부터 트래픽이 수신될 것이다. 그리고 기존의 작업들은 이제 Draining 상태가 되면서 대상 그룹에서 등록 해제되어 더 이상 트래픽 수신이 되지 않을 것이다. 이로 인해 서버의 코드는 변경되었는데 마이그레이션은 아직 완료되지 않아서 서버의 동작에 오류가 발생하는 시간이 거의 생기지 않게 된다. 마이그레이션의 완료 시점을 기준으로 그 이전에는 기존의 코드로, 그 이후에는 새로운 코드로 동작하도록 깔끔히 구분하였기 때문이다. 여기까지가 위 글에서 제시한 기본적인 아이디어이다.
이 아이디어를 활용하면
migrate
명령어와 collectstatic
명령어로 구성되는 초기화 동작이 배포 시마다 단 하나의 EC2에서 한 번만 실행되도록 할 수 있다. 이를 위해, 초기화 작업 전용의 docker-compose
설정 파일과 이 파일이 참조하는 엔트리 포인트 .sh
파일을 다음과 같이 따로 작성해주었다.▼ 초기화 작업 전용
docker-compose
설정 파일▼
docker-entrypoint.production.init.sh
파일그런데 여기서는 Cron Job의 실행이 고려되지 않았다. 따라서 다음과 같이 Cron Job의 실행을 위한 코드도 추가해줬다.
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
설치 명령어를 추가해줘야 했다.참고로 위 명령어는
crontab
도 함께 설치해준다. crontab
은 어떠한 Cron Job들을 어떠한 시간 규칙으로 실행할 것인지에 대한 설정을 해주기 위한 도구라고 생각하면 된다. cron
은 crontab
에 의해 설정된 내용을 참조하여 어떠한 Cron Job들을 어떠한 시간 규칙으로 실행해야 하는지 알 수 있는 것이다. crontab
의 기본적인 사용 방법은 다음과 같다.그리고
cron
을 실행/종료하는 방법은 다음과 같다.여기까지가 Cron Job 실행의 설정과 관련된 기본적인 개념이고, 실제로 맞닥뜨린 가장 큰 난관은 따로 있었다. 바로 Cron Job이 시스템에 이미 설정되어 있는 환경 변수들을 읽지 못하는 문제였다. 이러한 사실은 로컬 환경에서만 필요한 Python 패키지에 대해
import
에러가 발생하는 것을 보고 유추할 수 있었다. 이는 곧 DJANGO_SETTINGS_MODULE
이라는 환경 변수의 값이 실서버 전용 설정 파일을 제대로 가리키지 못하고 있었음을 의미했기 때문이다. 참고로 이와 같은 import
에러를 보기 위해서는 crontab
으로 Cron Job의 실행을 설정할 때 해당 Cron Job의 실행에서 발생하는 오류가 특정 파일에 기록해주도록 다음과 같이 설정해줘야 했다.원인을 파악하고 이에 대해 구글링을 해본 결과, 많은 사람들이 비슷한 문제로 어려워하고 있었다. 다행히 이에 대한 해결책들이 잔뜩 설명되어 있는 좋은 스택 오버플로우 글을 하나 찾았고, 여기서 해결을 위한 아이디어를 얻었다.
그것은 바로
printenv
명령어에 의해 출력되는 현재 시스템의 환경 변수들을 /etc/environment
파일로 복사해주는 것이었다. Cron Job들은 이 파일로부터 환경 변수들을 로드하기 때문이다. 굉장히 오래 고생한 것에 비해 해결책은 의외로 단순하였다.마지막으로, Cron Job이 실행되는 Django 컨테이너의 OS인 Debian 11에서는 Cron Job의 실행 로그가 자동으로 남지 않는다는 문제를 해결해야 했다. 이를 위해 로깅 툴인
rsyslog
를 cron
과 함께 설치하도록 하였고, cron
을 실행할 때와 마찬가지 방식으로 rsyslog
를 실행하도록 하였다. 물론 cron
보다 먼저 실행해야 할 것이다. 단, rsyslog
를 실행하기 전에 rsyslog
설정 파일(/etc/rsyslog.conf
)에서 기본적으로 주석 처리되어 있는 Cron Job의 로깅 설정을 주석 해제 처리해줘야 하기 때문에, 엔트리 포인트 .sh
파일에서 다음과 같이 sed -i
명령어로 해당 라인의 내용을 주석 해제 처리해주도록 하였다.그러면 이제 Cron Job의 실행 로그가
/var/log/cron.log
파일에 자동으로 남게 될 것이다.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의 로그를 살펴보기로 했다. Nginx와 달리 Gunicorn은 기본적으로 로깅이 설정되어 있지 않아서, Gunicorn 실행 시 다음과 같은 설정을 추가하여 로깅을 활성화해야 했다.
이후 다시 CSV 다운로드를 시도해서 Gunicorn의 로그를 살펴보니, 에러 메시지가 대략 다음과 같았다.
[Gunicorn] Worker with pid XXX was terminated due to signal 9
위 에러 메시지를 구글링 해보니, 스택 오버플로우에서도, 그리고 Gunicorn 공식 문서에서도 메모리 초과로 인해 Gunicorn 워커 프로세스가 죽었을 가능성이 높다고 설명하고 있었다. 그러고 보니 CPU 점유율이 올라간 것도 메모리 초과에 의한 순간적인 파생 현상일 수 있겠다는 생각이 들었다. 하지만 확실한 근거는 아니었기에 조금 더 삽질을 반복하며 조사를 진행하던 중, 다음과 같은 방법으로 특정 프로세스(pid: XXX)가 죽은 이유를 확인해볼 수 있음을 알게 되었다.
... (생략) ...Memory cgroup out of memory: Kill process 15184 (gunicorn) score 667 or sacrifice childKilled process 15184 (gunicorn) total-vm:1523260kB, anon-rss:333232kB, file-rss:15624kB, shmem-rss:0kBoom_reaper: reaped process 15184 (gunicorn), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB... (생략) ...
이를 통해 메모리 초과가 확실한 원인임을 알게 되었고, 이제부터는 컨테이너 혹은 작업 단위의 메모리 용량 관련 설정에 대해서만 파고들었다. 그 결과, 컨테이너 단위의 메모리 용량 관련 설정을 명시적으로 해줌으로써 해결할 수 있는 문제임을 깨닫게 되었다.
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의 공식 문서는 로드 밸런서의 동작 원리를 자세히 설명하므로, 시간만 되면 한 번 쭉 읽는 것도 좋을 것 같다.
9. 실서버와 테스트 서버의 ECR 리포지토리 분리
필자가 처음에 잘못 생각한 게 하나 있었는데, 그것은 바로 실서버 배포 전용 Docker 이미지와 테스트 서버 배포 전용 Docker 이미지를 동일한 ECR 리포지토리에 푸시해도 된다고 생각했던 점이다. 어차피 실서버든 테스트 서버든 매번 새로운 Docker 이미지를 생성 및 푸시하고 가장 최근 Docker 이미지를 활용하여 배포를 진행하므로, 동일한 ECR 리포지토리를 공유해도 된다고 생각한 것이다.
하지만 이건 굉장히 위험하다. 실서버와 테스트 서버가 병렬적으로 배포 중일 때, 우연히 테스트 서버 전용 Docker 이미지가 ECR 리포지토리에 푸시된 직후에 실서버가 가장 최근 Docker 이미지를 활용하여 배포를 진행하면 끔찍한 일이 벌어지기 때문이다. 따라서 이 문제를 해결하기 위해서는 실서버와 테스트 서버가 서로 다른 ECR 리포지토리를 사용하도록 해야 했다.
하지만 서로 다른 ECR 리포지토리를 사용하려면,
docker-compose
설정 파일에 작성하는 Docker 이미지의 경로 때문에 docker-compose
설정 파일이 실서버 전용과 테스트 서버 전용으로 나뉘어야 하는 번거로움이 있었다. 그런데 이 문제 하나 때문에 파일을 둘로 분리하는 게 너무 불편해서 방법을 찾아보던 중, docker-compose
설정 파일에 변수를 사용할 수 있음을 알게 되었다. 동일한 디렉토리에 .env
파일이 있고 그 파일이 KEY=VALUE
형식의 변수 선언문을 담고 있다면 docker-compose
설정 파일에서는 그 변수를 ${KEY}
형식으로 참조할 수 있다는 것이다. 따라서 Circle CI 스크립트에서 현재 어떤 환경에 배포하려는 것인지를 나타내는 변수를 선언한 .env
파일을 임시로 생성하고 이를 docker-compose
설정 파일이 참조하게 함으로써 문제를 해결할 수 있었다.10. Nginx 캐싱 설정이 Django 캐싱 설정을 덮어쓴 문제 해결
EB에서 ECS로 전환한 직후, 실무자의 문의에 의해 파악하게 된 문제이다. 특정 목록 페이지에서 다른 페이지로 이동한 후, 뒤로 가기를 이용하여 해당 목록 페이지로 돌아오면 해당 목록이 새로고침 되지 않는다는 것이었다. 이는 뒤로 가기 시에 해당 페이지를 새로 로드하지 않고 캐시를 활용했다는 것을 의미했다. 이번에 ECS로 전환하면서 해당 목록 페이지의 Django 코드는 일절 건드리지 않았기 때문에, 이는 Nginx의 캐싱 설정으로 인한 문제임을 추론할 수 있었다.
분명 Django에서는 해당 뷰에
never_cache()
데코레이터가 달려 있었기 때문에, 원래대로라면 응답의 Cache-Control
헤더에 no-cache
, no-store
등의 값이 담겨 있어야 했다. 그러나 개발자 도구로 목록 페이지를 들어갈 때의 응답을 확인해본 결과, never_cache()
데코레이터가 설정하는 Cache-Control
헤더의 값들이 전혀 담겨 있지 않았다. 즉, Nginx의 캐싱 설정이 Django의 캐싱 설정을 덮어쓴 것이다.이 문제는 의외로 쉽게 해결했다. Nginx의 캐싱 설정을 최소화하는 것이다. 사실 Nginx의 캐싱 설정도 보일러 플레이트 설정을 거의 그대로 가져왔었는데, 생각해 보니 우리 서비스에서는 대부분의 정적 파일을 외부 스토리지에서 제공하기 때문에 Nginx에서의 세부 캐싱 설정이 거의 필요 없다는 걸 깨달았기 때문이다. 따라서 Nginx의 캐싱 설정에서 불필요한 내용은 삭제함으로써 Django의 캐싱 설정을 존중할 수 있도록 하였다.