CI/CD Practice 09

appspec.yml: EC2 배포 동작의 청사진

배포 그룹까지 만들었는데 appspec.yml을 또 작성해야 하는 이유와, hook script가 언제 어디서 실행되는지 잘 보이지 않는다.

근거 · CI/CD 교안 p132-p137, appspec.yml, deploy-scripts.zip

appspec.yml: EC2 배포 동작의 청사진 대표 이미지

1장: appspec.yml, 또 다른 배포 규칙인가요?

솔라는 모니터에 두 개의 창을 나란히 띄워놓고 미간을 찌푸렸다. 왼쪽 창은 방금 전 설정을 마친 AWS CodeDeploy의 배포 그룹 화면이었다. ‘어떤 EC2 인스턴스에 배포할 것인가’라는 질문에 태그를 이용해 깔끔하게 답을 해둔 상태였다. 이제 배포 버튼만 누르면 될 것 같았다.

하지만 오른쪽 창에 띄워 둔 튜토리얼 문서의 한 줄이 솔라의 발목을 잡았다. appspec.yml에서 파일 배치 경로와 ApplicationStart hook을 정의합니다. 라는 문장이었다.

“이게 왜 또 필요하지?”

솔라는 혼잣말을 중얼거렸다. 배포할 대상 서버도 정했고, 한 번에 한 대씩 배포하라는 전략까지 모두 정해뒀는데. 마치 이사를 가기 위해 새집 주소와 이삿짐센터까지 모두 계약해 둔 기분이었다. 그런데 이삿짐센터에서 ‘어떤 짐을 어느 방에 놓을지 적은 목록’을 따로 또 내놓으라는 것 같았다. 그건 이삿짐센터가 알아서 해줘야 하는 일 아닌가? 솔라의 생각에 배포 그룹 설정이 모든 것을 지휘해야 했다.

그때 방으로 들어온 루나가 솔라의 모니터를 흘깃 보았다.

“거의 다 된 것 같은데, 표정이 왜 그래?”

“언니, 이것 좀 봐. CodeDeploy에서 배포 그룹 만들어서 어디로 배포할지 다 정했는데, appspec.yml이라는 파일을 또 만들래. 꼭 일이 두 번 하는 것 같아서. 배포 그룹이 알아서 다 해주는 거 아니었어?”

솔라의 말에 루나는 잠시 생각하더니, 왼쪽의 배포 그룹 설정 화면을 가리켰다.

“솔라, 여기서 우리가 정한 것들을 다시 한번 볼까? 뭘 지정했지?”

“음… 애플리케이션 이름, 그리고 ‘my-app-prod’ 태그가 붙은 EC2 인스턴스들. 그리고 배포 방식은 한 번에 하나씩. 이런 것들이지.”

“맞아. ‘어디에’ 보낼지, ‘어떤 속도’로 보낼지를 정했어. 이건 CodeDeploy라는 서비스가 AWS 클라우드 위에서 하는 일이야.”

루나는 말을 마치고, 이번에는 오른쪽 창의 appspec.yml 예시 코드를 손가락으로 짚었다.

version: 0.0
os: linux
files:
  - source: /index.html
    destination: /var/www/html/
hooks:
  ApplicationStart:
    - location: scripts/start_server.sh
      timeout: 300
      runas: root

“그럼 이건 뭘 정하는 것처럼 보여?”

솔라는 YAML 파일을 꼼꼼히 들여다보았다. files 섹션에는 sourcedestination이 있었다. 원본 파일을 목적지로 옮기는 것 같았다. hooks 섹션에는 ApplicationStart라는 알 수 없는 시점에 특정 스크립트를 실행하라는 내용이 담겨 있었다.

“파일을… 특정 폴더로 옮기고, 스크립트를 실행하네. 그런데 경로가 /var/www/html/인 걸 보니, 이건 EC2 인스턴스 안의 이야기 같은데…”

순간 솔라의 머릿속에서 두 개의 그림이 합쳐졌다. 하나는 AWS라는 거대한 지도 위에서 특정 EC2 인스턴스를 콕 집는 CodeDeploy의 모습. 다른 하나는 그 인스턴스 안으로 들어간 작은 로봇이 appspec.yml이라는 설명서를 들고 분주하게 움직이는 모습이었다.

“아! 알겠다. 배포 그룹은 말 그대로 배포 꾸러미를 ‘어디로’ 보낼지만 아는 거구나. 택배 트럭이 아파트 단지 앞까지만 가는 것처럼! 일단 인스턴스 안에 꾸러미가 도착하면, 그 안에서 뭘 어떻게 해야 할지는 CodeDeploy 서비스는 전혀 모르는 거네. 그래서 이 appspec.yml이 필요한 거구나. 인스턴스 안에서 일하는 에이전트한테 주는 ‘작업 지시서’ 같은 거네!”

솔라의 얼굴이 환해졌다. 역할이 분리되어 있다는 것을 깨달은 것이다.

루나는 고개를 끄덕이며 솔라의 비유를 받았다.

“정확해. 그래서 appspec.yml을 ‘배포 계약서’라고 생각하면 편해. CodeDeploy 서비스는 이 계약서가 포함된 배포 꾸러미를 약속된 EC2 인스턴스로 던져주는 역할만 해. 그러면 인스턴스 안에서 대기하던 CodeDeploy 에이전트가 그 계약서를 읽고, ‘아, 이 파일은 여기로 복사하고, 복사가 끝나면 이 스크립트를 실행하라는 뜻이군’ 하고 묵묵히 자기 일을 하는 거지.”

역할 분담. 솔라는 속으로 이 단어를 되뇌었다. AWS 콘솔에서 하는 설정은 AWS 서비스의 역할, appspec.yml 파일은 그 결과물을 받아 처리하는 EC2 인스턴스 내부 에이전트의 역할. 둘은 서로의 영역을 침범하지 않았다. 이삿짐센터는 집 앞까지만, 가구 배치는 내가 직접 작성한 ‘가구 배치도’를 보고 집 안의 조수가 처리하는 그림이었다.

이제 appspec.yml이 왜 필요한지는 명확해졌다. 더 이상 중복되는 작업으로 느껴지지 않았다. 오히려 아주 깔끔한 역할 분담이었다. 궁금증이 풀린 솔라는 다시 appspec.yml 파일로 시선을 돌렸다. 명쾌해진 만큼 새로운 질문이 고개를 들었다.

“언니, 그럼 이 계약서에 적힌 source: /index.html은 대체 어디서 오는 파일이야? 배포 꾸러미 안에 들어있다는 건 알겠는데, 그 꾸러미의 어떤 경로를 기준으로 하는 거지? 내 노트북의 루트 폴더는 아닐 테고…“

2장: 파일, EC2 어디로 가야 하죠?

솔라의 질문에 루나는 말없이 방 한쪽의 작은 화이트보드에 큼지막한 사각형을 그렸다. ‘배포 꾸러미 (Build Artifact)’라는 이름을 붙인 뒤, 그 안에 간결한 파일 구조를 그려 넣었다. 이전 장에서 이삿짐에 비유했던, 실체 없는 개념이었던 배포 꾸러미가 눈앞에 모습을 드러낸 순간이었다.

/ (꾸러미의 최상위 경로)
├── appspec.yml
├── index.html
└── scripts/
    └── start_server.sh

루나는 다 그린 그림을 가리키며 입을 열었다.

“솔라, 네가 궁금해했던 source 경로의 기준점이 바로 여기야. CodePipeline 같은 도구의 빌드(Build) 단계를 거치면, 우리 프로젝트의 소스 코드는 웹 서버가 바로 사용할 수 있는 파일들만 담은 이런 ‘꾸러미’ 형태로 압축돼. appspec.ymlfiles 섹션에 적는 source 경로는, 다른 어딘가가 아니라 바로 이 꾸러미 내부의 상대 경로를 가리키는 거지.”

“아, 상대 경로!”

솔라는 무릎을 쳤다. 자신의 노트북이나 빌드 서버의 특정 폴더 같은 절대 경로일 거라 막연히 짐작했던 것이 완전히 빗나갔다.

“그럼 저 그림을 기준으로 하면, source: /index.html은 꾸러미의 맨 위, 루트에 있는 index.html 파일을 뜻하는 거구나. 그리고 destination: /var/www/html/은 파일을 내려놓을 EC2 인스턴스 안의 실제 경로고. 이삿짐센터가 집 앞까지 배달한 짐을, 내가 작성한 배치도를 보고 조수가 안방으로 옮기는 것처럼!”

첫 번째 의문이 풀리자, 솔라의 머릿속은 빠르게 다음 단계로 움직였다. 그렇다면 이 경로는 정말로 appspec.yml 파일 하나로만 결정되는 걸까? 빌드 파이프라인 설정 어딘가에 기본 경로를 지정하는 옵션이 따로 있는 건 아닐까? 의심이 꼬리를 물었다.

그때 루나가 화이트보드에 appspec.ymlfiles 부분만 따로 떼어 두 가지 버전으로 나란히 적었다.

[계획 A]

files:
  - source: /index.html
    destination: /var/www/html/

[계획 B]

files:
  - source: /index.html
    destination: /home/sola/test-deploy/

“만약 우리가 계획 B처럼 destination 경로를 바꾸고 배포하면, EC2 인스턴스에서는 무슨 일이 일어날까?”

솔라는 잠시 생각에 잠겼다. 손가락으로 허공을 가르며 파일이 움직이는 경로를 그려보았다. 배포 꾸러미 안에서 index.html 파일이 뽑혀 나와, EC2 인스턴스 안의 /home/sola/test-deploy/ 라는, 생전 처음 보는 폴더로 들어가는 모습이 그려졌다.

“음… CodeDeploy 에이전트는 이 새로운 계약서(계획 B)를 읽고, 꾸러미 안의 index.html 파일을 /home/sola/test-deploy/ 폴더로 복사하겠지. 원래 웹 서버가 서비스하는 경로인 /var/www/html/이 아니니까, 배포는 성공했다고 떠도 정작 웹사이트에 접속하면 아무것도 바뀌지 않았을 거야!”

순간, 모든 조각이 맞춰졌다. files 섹션의 역할은 모호한 제안이나 참고 정보가 아니었다. 그것은 절대적인 ‘지시’였다.

“알겠다! 이 files 섹션은 그 자체로 완결된 ‘파일 배치 지시자’구나! 이 꾸러미 안의 어떤 파일(source)을 저 서버의 어느 위치(destination)로 옮겨라, 하고 정확하게 명령하는 역할이었어. 빌드 과정이나 다른 어떤 외부 설정도 여기에 관여하지 못하는 거고.”

솔라의 목소리에 자신감이 붙었다. 이제 튜토리얼 문서에 나왔던 files 섹션은 source: dist/my-angular-project를 destination: /var/www/my-angular-project로 복사합니다. 라는 문장이 완벽하게 이해되었다. 빌드 결과물 꾸러미 안에 dist/my-angular-project라는 폴더가 통째로 들어있고, appspec.yml은 그 폴더를 EC2의 /var/www/my-angular-project 위치로 그대로 옮기라고 지시하는 것이었다.

파일들이 가야 할 길을 명확히 알게 된 솔라는 다시 appspec.yml 전체 코드를 바라보았다. files 섹션이 간단명료하게 느껴졌다. 하지만 시선이 자연스레 다음 섹션으로 옮겨가자, 새로운 질문이 떠올랐다.

hooks:
  ApplicationStart:
    - location: scripts/start_server.sh
      timeout: 300
      runas: root

“좋아, 파일들은 이제 제자리를 찾아갈 수 있어. 그런데 언니, 이 hooks는 뭐지? ApplicationStart라는 건 대체 어느 시점이야? 파일을 다 옮긴 다음에 이 스크립트를 실행하는 걸까? 아니면 옮기기 전에? 배포 과정에도 정해진 순서가 있을 것 같은데…”

파일 위치의 미스터리는 풀렸지만, 이제는 시간과 순서의 미스터리가 솔라 앞에 나타났다. ‘배포 계약서’에는 해독해야 할 조항이 더 남아있었다.

3장: 스크립트, EC2에서 언제 움직이나요?

솔라의 시선은 appspec.yml 파일의 hooks 섹션에 머물러 있었다. 파일들이 가야 할 길을 정해주는 files 섹션의 역할은 명확해졌지만, hooks는 여전히 안갯속이었다. ApplicationStart라는 이름은 그저 약속된 이름일 뿐, 이 스크립트가 정말 EC2 인스턴스 안에서, 그것도 정확한 순서에 맞춰 실행된다는 보장이 어디 있을까? 솔라는 이 스크립트가 CodeDeploy 서비스 어딘가에서 실행되어 EC2에 신호만 주는 건 아닐까 하는 의심을 지우지 못했다.

솔라의 고민을 읽은 루나는 말없이 다시 화이트보드를 가져왔다. 이전 장에서 배포 꾸러미 구조를 그렸던 그림 옆에, 이번에는 긴 수평선을 하나 그었다. 마치 기차 노선도처럼 보였다.

“이게 EC2 인스턴스 한 대에서 일어나는 배포 과정의 시간 순서야.”

루나는 선 위에 몇 개의 점을 찍고 이름을 붙이기 시작했다.

[배포 시작] -> DownloadBundle -> Install -> ApplicationStart -> ValidateService -> [배포 종료]

“CodeDeploy 에이전트는 이런 정해진 라이프사이클 이벤트 순서에 따라 배포를 진행해. 우리가 appspec.ymlhooks 섹션에 적는 건, 바로 이 특정 단계에 우리가 원하는 스크립트를 ‘끼워 넣겠다’는 예약 선언 같은 거지.”

솔라는 노선도와 appspec.ymlhooks 부분을 번갈아 보았다.

hooks:
  ApplicationStart:
    - location: scripts/start_server.sh
      timeout: 300
      runas: root

“그럼 ApplicationStartInstall 단계, 즉 파일 복사가 모두 끝난 다음에 실행된다는 뜻이구나! 이건 정말 서버 안에서 일어나는 순서네.”

하지만 여전히 한 가지 의문이 남았다. 정말 그럴까?

“언니, 그래도 잘 모르겠어. 이 스크립트가 정말 EC2 안에서, 그것도 root 권한으로 실행되는지 어떻게 믿지? 이걸 확인해볼 방법이 있을까?”

솔라의 질문에 루나는 기다렸다는 듯 노트북을 열었다.

“직접 확인해보면 되지. 우리가 직접 배포 계약서의 이 조항을 테스트해보는 거야. start_server.sh 스크립트 내용을 이렇게 바꿔보자.”

루나는 간단한 셸 스크립트를 작성했다.

#!/bin/bash
# scripts/start_server.sh

# 현재 시간을 로그 파일에 기록
echo "$(date) - ApplicationStart hook is running!" >> /tmp/deploy.log

# Nginx 웹 서버 재시작
systemctl restart nginx

“이 스크립트는 두 가지 일을 해. 첫째, /tmp/deploy.log라는 파일에 실행됐다는 기록을 남기고, 둘째, Nginx 서버를 재시작시켜. 만약 배포 후에 EC2 인스턴스의 저 로그 파일에 메시지가 찍혀있고, Nginx가 재시작됐다면, 이 스크립트가 정말 EC2 안에서, 제때, 그리고 필요한 권한으로 실행됐다는 증거가 되겠지.”

솔라는 고개를 끄덕이며 CodePipeline에서 새로운 배포를 시작했다. 잠시 후, CodeDeploy 대시보드에 ‘성공’ 메시지가 떴다. 솔라는 지체 없이 EC2 인스턴스에 접속했다. 심장이 살짝 두근거렸다.

가장 먼저 로그 파일의 존재를 확인했다.

$ cat /tmp/deploy.log

엔터 키를 누르자, 화면에 선명한 문장이 나타났다.

Wed Mar 27 14:32:15 UTC 2024 - ApplicationStart hook is running!

“와! 진짜 실행됐어! EC2 안에서!”

솔라의 목소리가 커졌다. 자신의 가설, 즉 CodeDeploy 서버에서 실행될 거라는 생각이 틀렸다는 명백한 증거였다. 다음은 Nginx의 상태를 확인할 차례였다.

$ systemctl status nginx

출력된 내용 속에서 Active: active (running) since Wed 2024-03-27 14:32:15 UTC 라는 문구가 눈에 들어왔다. 로그 파일에 찍힌 시간과 정확히 일치했다. runas: root 설정 덕분에 서버를 재시작하는 민감한 명령이 성공적으로 실행된 것이다.

순간, 모든 것이 제자리를 찾았다. hooks 섹션은 단순한 스크립트 목록이 아니었다. CodeDeploy 에이전트가 EC2 인스턴스 내부에서 진행하는 정교한 작업 절차, 그 라이프사이클의 특정 지점을 정확하게 제어하는 ‘리모컨’이었다. 파일 복사가 끝난 직후(ApplicationStart), 새 버전의 애플리케이션이 정상인지 확인하기 직전(ValidateService 이전)에 웹 서버를 재시작하라는 명확한 명령. 이제 솔라는 이 ‘배포 계약서’의 모든 조항을 해독할 수 있게 되었다.

“알겠다! appspec.yml은 그냥 파일이 아니었어. EC2 안에서 일어날 모든 일을 시간 순서대로 지시하는 완벽한 청사진이었던 거야. 파일은 어디로 가고(files), 그 파일들이 준비되면 어떤 순서로 무엇을 실행할지(hooks) 결정하는 거지.”

솔라는 화이트보드 앞에 서서 자신이 처음 가졌던 질문들을 떠올렸다. 배포 그룹과의 역할 분담, 파일의 이동 경로, 그리고 스크립트의 실행 시점. 이제 그 모든 질문에 스스로 답할 수 있었다. 솔라는 보드마카를 집어 들고, 루나가 그려준 라이프사이클 노선도 아래에 자신만의 appspec.yml 초안을 그리기 시작했다. 이것은 더 이상 튜토리얼의 예제가 아닌, 솔라 자신이 온전히 이해하고 작성하는 첫 번째 ‘배포 계약서’였다.