Docker와 CI/CD

💡 왜 Docker..?
서버는 나한테는 아직 공포다. 보안에 대한 압박 때문인지 내가 감히? 한발 올려보는 것도 조심스러웠다. 그렇다고 마냥 쫀건 아니어서 이번 프로젝트를 계기로 내디뎌 봤다. 백엔드 기초에 대한 레퍼런스는 어느 정도 정형화된 느낌을 받았고, 해서 세세한 부분까지 다루지는 않을 것이다. 복사에 불과할 것이기 때문에. 대신 그분들의 귀한 자료를 내가 어떻게 이해했는지, 막힌 부분은 어디였는지 내 식대로 적어두는 게 더 의미있을 것이다.

도커는 컨테이너 기반의 오픈소스 가상화 플랫폼이다. 무역항에 적재되는 수많은 컨테이너들을 상상할 수 있는데 그 안에 상품 대신 소프트웨어가 들어가 있을 뿐이다. 서버에서 컨테이너는 다양한 프로그램, 실행환경을 추상화하고 동일한 인터페이스를 제공하여 프로그램 배포 및 관리를 단순하게 해준다. 어떤 프로그램도 컨테이너화 할 수 있고, 컨테이너는 AWS/AZURE/내PC 어디에서든 실행할 수 있다1.
나는 오랫동안 맥을 썼기 때문에 어 이거 맥에 있는 부트캠프 아닌가? 싶었다. 맥에서 윈도우 환경을 구동하기 위해 제공되는 VM머신 말이다. 결과부터 말하면 차이가 있다. 그리고 그 차이가 도커를 흥하게 만든 이유라고 하는데.. 먼저 기존의 가상화는 주인(Host) OS가 존재하고 그 위에 자체 커널을 가진 게스트 OS를 올리는 방식이었다. 이런 방식은 사용법은 간단했지만 무겁고 느려서 효율은 많이 떨어졌다. 반면 도커는 엔진에 명령을 보내면 호스트 OS 안에 컨테이너가 적재된다. VM과 달리 컨테이너는 호스트 OS의 커널을 공유한다는 점이 결정적인 차이다. 커널을 가지기 위해 OS를 새로 부팅할 필요가 없기 때문에 더 가볍고 빠르게 구동된다. 그러면서 컨테이너 자체의 프로세스/네트워크/파일시스템은 격리되어 있으니 이런 신속성과 복제의 일관성 덕분에 도커는 CI/CD 파이프라인을 만드는 데 핵심 기술로 자리매김했다.
컨테이너를 적재할 때 네트워크를 지정하지 않으면 기본적으로
bridge
네트워크로 입항한다. 도커는 같은 네트워크 간에만 자원을 공유할 수 있는데 나의 경우 서버 네트워크를 신설하여 백엔드 자원만 따로 격리시켰다.docker network ls
,docker network inspect
등의 명령어로 네트워크를 직접 살펴보면 도움이 된다. 도커 입문자라면 꼭 찍어보시길!!

💡 컨테이너 간의 통신
이렇게 백엔드 컨테이너들을 같은 네트워크에 묶고 세팅을 진행했다. 그 과정에서 다른 컨테이너의 IP가 필요한 경우가 많았는데, 나는 도커에서 localhost처럼 사용하라는 0.0.0.0이랑 컨테이너 자체가 부여받는 IP랑 뭐 이것저것 섞이니까 살짝 혼동이 왔음. 특히 Nginx에서 자꾸 502 게이트에러를 내서 곤욕을 치렀는데, proxy_pass로 넘긴 ip값이 잘못이었다. 그런데 사실 도커에서는 숫자로 된 IP 대신 특수한 이름의 DNS를 제공한다. IP가 들어갈 자리에 이 변수를 사용할 수 있는데
- host.docker.internal: 도커 컨테이너에서 호스트 머신을 참조할 때 사용한다. 정확히는 호스트 머신(EC2)의 프라이빗 IP를 가리킨다. 어떤 네트워크에서든 같은 값을 가리킬 거라 짐작할 수 있다. 근데 리눅스에서만 기본적으로 주어지지 않아서 컨테이너를 실행할 때
--add-host
옵션을 붙어줘야 한다고 함. - 컨테이너 이름: 컨테이너 이름을 사용하여 곧장 해당 컨테이너에 접근할 수 있다.
내컨테이너-이름:뚫어놓은 포트번호
를 사용하면 된다. 나도 이 방법을 사용했는데, 굳이 명시하고 싶으면docker network inspect 나의네트워크
를 쳐서 나오는 컨테이너별 아이피를 확인하자
그럼 0.0.0.0 은 몬데? 컨테이너를 실행하고 -p 옵션으로 포트를 매핑하면 Dokcer는 기본적으로 포트를 호스트의
0.0.0.0
에 바인딩한다. 그래서 호스트 터미널에서curl 0.0.0.0:서버포트
를 치면 서버의 응답을 받아볼 수 있다. 그런데 컨테이너 관점에서는 조금 다르다. 컨테이너 관점에서 이 특수IP의 함의는 네트워크에서 들어오는 모든 요청을 수신하겠다는 의미이다. 즉 네트워크가 자신에게 배정해준 아이피를 그대로 받아들이겠다는 뜻이다2. 이건 도커 네트워크에서 자신이 열려있음을 상징하는 값이지, 실제 IP를 맵핑하기 위해 사용되는 값이 아니다. 이게 내 Gateway 에러의 이유였다. Nginx 컨테이너에서 아무리0.0.0.0:서버포트
를 라우팅해봤자 내 NestJS 컨테이너를 참조하지 않는 것이다. 때문에 상대 컨테이너를 참조할 땐 위에서 언급한 컨테이너 이름을 참조하는 방법을 사용하고 또 추천한다.
여기까지가 도커를 시작하는 내 개인적인 이해였다. 첫걸음인데 나름 재밌는듯?ㅎ 그럼 이제 도커와 깃허브 액션을 활용하여 CI/CD 파이프라인을 구축한 기록을 남겨본다.!
💡 자동화에 필요한 것
- Dockerfile(보통 root 경로에 둔다)
- docker build & push 명령이 담긴 yml 파일
- docker pull & run 명령이 담긴 yml 파일
- github action runner(self-hosted)
💡 Dockerfile
도커파일은 CI 파이프라인에서 docker build 명령이 실행될 때 이미지 생성을 위해 참조하는 파일이다. 서버를 빌드하는 명령, 이미지에서 받을 환경변수를 열어두는 작업 등이 여기에서 수행된다. 보통 두 단계로 나누어 진행하는 거 같다. 단계를 나눴을 때 node_modules의 크기를 줄일 수 있기 때문에 그렇다.
# build stageFROM node:21-alpine AS buildWORKDIR /usr/src/appCOPY package*.json ./RUN npm install --productionCOPY . .RUN npm run build# prod stageFROM node:21-alpineWORKDIR /usr/src/appARG PORTENV NODE_ENV \ DB_HOST \ DB_PORT \ DB_USER \ DB_PASS \ DB_NAME \ PROTOCOL \ HOST \ PORT \ JWT_SECRET \ SALT_ROUNDS \ CA_CERTCOPY --from=build /usr/src/app/dist ./distCOPY --from=build /usr/src/app/node_modules ./node_modulesCOPY package*.json ./EXPOSE ${PORT}ENTRYPOINT ["npm", "run", "start:prod"]
- npm build
- nest.js 코드를 빌드한다. dist 폴더가 생성되는데
- 이미지
- dist, node_modules 폴더를 통째로 복사해온다.
- 도커는 마지막 레이어의 파일 시스템과 명령, 변수들만 이미지화한다.
- 도커는 이전 단계의 종속성(package.json)이 변경되지 않은 경우 캐시를 재사용하여 시간을 절약한다.
구글링하면 여러 자료에서 npm install을 각각하던데 그냥 처음 빌드 단계에서부터 production으로 인스톨해도 무방할 거라 생각했다. 시간은 딱 그만큼, 거의 절반만큼 줄어들었다(2분에서 1분 6초). 혹시 안되는 이유가 있다면 훈수좀 주세요..
💡 CI 파이프라인
CI 파이프라인은 위에서 작성한 도커파일을 이미지로 구워내는 일을 한다. 그러고 나서 도커허브에 푸시한다.
main 브랜치에 푸시가 들어오면 실행시켰다. 민감한 정보는 모두 github.secret
키로 관리했다. 이거는 따로 설명한 건데 일단 간단히 steps 단계만 짚어보자
- 도커허브 로그인
- 도커 허브에 내 계정으로 로그인한다
- 도커 이미지 빌드
- 현재 경로(.)에서 Dockerfile을 찾아 이미지를 빌드한다.
- 도커파일에서
PORT
를ARG
로 사용한다고 했으므로--build-arg
로 값을 전달한다. apuu-nest
라는 이름으로 이미지를 생성한다.
- 태그 부여
- 꼭 필요한 작업인진 모르겠는데 태그를 달지 않아 에러가 났다는 케이스를 읽었다.
- 도커 닉네임을 앞에 달아줬다.
- 도커허브에 푸시
- 생성된 이미즈를 도커 허브에 올려준다.
💡 CD 파이프라인
그럼 이제 CD 파이프라인은 도커 허브에 올라가 있는 내 이미지를 서버에서 당겨 실행, 즉 배포하는 작업을 맡는다. CI 파이프라인이 끝나면 알아서 돌아가게 하는데, 근데 생각해보면 이를 위해서는 내 호스트(EC2 리눅스) 환경에서 내 깃허브 상태를 도청하고 있어야 한다. 그게 아니라면 CD 파이프라인이 언제 실행돼야 하는지 어떻게 알겠는가? 깃허브는 이에 대한 가이드라인을 제공하고 있다.
액션러너
그게 바로 액션 러너다. 깃허브 Actions → Runners 에서 찾을 수 있다. 설치 자체는 무척 쉽게 만들어놔서 쥐어주는 스크립트만 차례로 실행시키면 된다. 클라우드 서비스 운영체제에 맞게 스크립트도 친절히 나뉘어져 있으니 각자 알맞은 걸 선택하고 SSH 환경에서 스크립트를 실행시킨다.
설치가 다 되면 깃허브 그림이 뜨면서 감시가 시작된다. 감시가 시작되면 CD 파이프라인이 작동하는 순간 그 내용을 내 호스트 환경의 도커엔진에서 받는다. 그런데 만약 내 터미널이 꺼진다면...? 이에 대한 답은 부록으로 넘기겠다. 일단 어떤 명령들이 실행되는고 있는지 짚어본다
- 도커 이미지 풀
- CI 파이프라인에서 도커 허브에 업로드한 이미지를 가져온다
- 기존 컨테이너 삭제
- 기존에 컨테이너를 실행중이었다면 삭제한다.
- 도커 컨테이너 실행
- 따끈한 이미지로 새로운 컨테이너를 생성한다. 환경변수를 모조리 넘겨준다
- 네트워크 연결
- 나는 server 네트워크를 만들어 백엔드 컨테이너들을 운용중이다. 커맨드라인에서 네트워크를 특정했는데도 기본 네트워크로 들어가는 이슈가 있어 작성했음. 네트워크를 연결해도 기존 네트워크가 끊기는 건 아니어서 bridge에 대해 disconnect 작업도 같이 해주면 좋을듯(확인해볼것)
도커의 환경변수 환경변수에 대한 명쾌한 설명을 찾지 못했다. 파일로 넘길 수 있다면 그러고 싶은데, gitignore에 env 파일이 등록된 상황에서 딱히 읽어올 수가 생각나지 않았음. 그냥 일일이 시크릿으로 등록하고 ENV로 넘겨줬다. 웃긴 게 백슬래시 쓰니까 포맷팅 에러가 나서(공백체크 당연히 함) yml 포맷팅 다시 알아봤다2.
>-
이걸로 쓰면 될 거 같아서 써봤는데 되더라
액션러너 상시 구동
해답은 비교적 간단한데./run.sh
을 실행시킬 때 대신nohup ./run.sh &
을 사용하면 된다. nohup은 터미널이 꺼져도 실행시키는 명령어, &은 백그라운드에서 실행시키겠다는 명령어다3. 그러니까 액션러너를 항상 켜놓고 나는 신경 끄겠다는 의미다.ps -ef | grep run.sh
로 언제든 프로세스를 확인할 수 있다. 테스트를 해보니 CD 파이프라인이 시동되고 있는 걸 확인할 수 있었다.
참고문서
Footnotes
Comment ?
▾ Comment