들어가며
사이드 프로젝트로 리뉴얼 중인 cherish 개발용 테스트 서버를 배포하던 중 삽질을 하던 경험을 풀어보려한다.
배포는 aws 서비스를 사용해 진행했고, 설계한 테스트 서버 전체 구조는 다음 이미지와 같다.
전체적으로 Beanstalk 환경에 ELB를 사용한 오토 스케일링 그룹을 만들고, 내부적으로는 EC2, docker 를 사용해 Nest.js 앱을 배포하는 구조이다. Beanstalk 으로 보내는 과정은 Github Actions 를 통해 자동화해보았다.
이 과정에서 배포에 실패하는 다양한 문제를 겪었는데 그 중 No space left on device 라는 킹받는 이슈를 해결하기까지 과정을 적어보려한다.
아직 docker 나 인프라 관련 지식이 많이 부족해 틀린 부분이 있을 수 있어 발견하신다면 댓으로 지적 부탁드립니다.
(Beanstalk 환경 구성은 이미 되었다고 가정해 따로 설명하진 않으려 한다. 나중에 정리해볼게요)
https://github.com/NewCherish/Cherish-Server
전체 과정을 볼 수 있는 해당 프로젝트의 Repository (스타 눌러줘 .. 어쩌고)
처음 작성했던 기본 코드들
뇌피셜과 구글링에 의지해 열심히 스크립트를 짜보았다.
여기서 따로 스크립트 코드에 대한 설명은 하지 않겠다!
develop.yml - 테스트 서버 배포 yml 스크립트
name: cherish-server-dev
on:
push:
branches: [develop]
jobs:
build:
env:
PORT: ${{ secrets.PORT }}
DATABASE_URL: ${{ secrets.DATABASE_URL_DEV }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID_DEV }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY_DEV }}
REGION: ${{ secrets.REGION }}
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Create .env file
run: |
touch .env
echo PORT=${{ secrets.PORT }} >> .env
echo DATABASE_URL=${{ secrets.DATABASE_URL_DEV }} >> .env
echo AWS_ACCESS_KEY_ID=${{ secrets.AWS_ACCESS_KEY_ID_DEV }} >> .env
echo AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY_DEV }} >> .env
echo REGION=${{ secrets.REGION }} >> .env
echo DOCKERFILE=Dockerfile.dev >> .env
cat .env
- name: Install dependencies
run: yarn
- name: Run build
run: yarn build
- name: Build the Docker image
run: docker build -t cherish-dev/cherish-dev -f Dockerfile.dev .
- name: Generate Deployment Package
run: zip -r deploy.zip *
- name: Add .env to deploy.zip
run: zip deploy.zip .env
- name: Get timestamp
uses: gerred/actions/current-time@master
id: current-time
- name: Run string replace
uses: frabert/replace-string-action@master
id: format-time
with:
pattern: '[:\.]+'
string: '${{ steps.current-time.outputs.time }}'
replace-with: '-'
flags: 'g'
- name: Deploy to EB
uses: einaregilsson/beanstalk-deploy@v14
with:
aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID_DEV }}
aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY_DEV }}
application_name: cherish-dev
environment_name: Cherishdev-env
version_label: 'cherish-dev${{ steps.format-time.outputs.replaced }}'
region: ${{ secrets.REGION }}
deployment_package: deploy.zip
- name: action-slack
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
author_name: Github Action Push Server
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_DEV }} # required
if: always() # Pick up events even if the job fails or is canceled.
Dockerfile.dev - test 서버라 dev 파일로 만들어주었다.
FROM node:16-alpine
WORKDIR /usr/app
COPY package*.json ./
RUN npm install -g typescript
RUN npm install -g cross-env
RUN yarn
ADD . .
EXPOSE 8081
ENTRYPOINT ["cross-env", "NODE_ENV=development", "node", "dist/main"]
docker-compose.yml
version: '3'
services:
deploy:
env_file:
- .env
build:
context: .
dockerfile: ${DOCKERFILE}
ports:
- '80:8081'
- '443:8081'
environment:
- TZ=Asia/Seoul
.dockerignore - 빌드과정에서 생략할 파일
.git
.github
node_modules
docker-compose.yml
README.md
dist
문제 발생
처음 nest app 은 잘 배포되었으나 기본에 prisma, swagger setting만 추가한 nest app 을 올리자마자 띠용 actions deploy 에 실패했다는 알림이 떴다.
actions 를 확인해보자 Deploy to EB 단계에서 에러가 난 걸 확인할 수 있었다.
Beanstalk 에서 로그를 확인해보았고, No space left on device 라는 오류와 마주쳤다.
오류의 내용은 docker image build 에 실패했다는 내용이었고, 디바이스에 공간이 없다는 뜻 같았다.
바로 용량 확인에 들어갔고, 따로 캡쳐를 해두진 못했지만, 기본 EC2 8GB 볼륨을 거의 다 쓰고 있었다.
docker image 용량이 어떻게 잡아먹고 있는지 확인이 필요했다. 그러기 위해선 EC2 접근이 필요했고, Beanstalk EC2 접근은 따로 설정이 조금 필요했다.
Beanstalk EC2 접근하기
EC2 콘솔 좌측 메뉴를 내리다 보면 [네트워크 및 보안] -> [키 페어] 를 발견 할 수 있다.
[키페어 생성]을 눌러 이름과 유형, 형식을 선택해 생성해주자
pem 파일은 특별히 ~/.ssh 에 저장해줘야한다.
저장 후에 Beanstalk 환경으로 돌아가서 키페어 연결을 해줘야한다.
환경 구성에서 [보안] 탭을 수정해주면서 EC2 키페어를 아까 만든 키페어로 연결해준다.
그러면 아마도 인스턴스를 재시작한다는 warning 이 뜰 것이다. 고고
이후 터미널에서 키를 저장한 .ssh 로 이동해주자.
chmod 400 [name].pem
ssh -i "name.pem" root@[public dns]
기본 ec2 접근하던 대로 키 권한을 수정한 뒤 public dns 를 사용해 접근해보자
요렇게 접속되면 성공이다.
용량 확인
참고로 Beanstalk 은 내부 구조가 조금 달라 당황할 수 있는데
다음 path 를 기억하고 잘 사용하면 된다.
# engine log path
/var/log/eb-engine.log
# current deploy file
/var/app/current
# staging deploy file
/var/app/staging
대부분 beanstalk 이 내뱉는 로그는 eb-engine.log 파일에 존재한다.
나는 docker image 를 빌드하는데 실패했고, 용량 문제가 발생해서 제일 먼저 도커 이미지 상태를 확인해보았다.
docker system df --verbose
다음 명령어로 이미지와 컨테이너 상태를 볼 수 있다.
대박 처음에 올렸던 Nest 기본 앱인데도 이미지 size가 1.349 GB 나 차지하고 있었다. 아무래도 dockerfile 을 너무 대충 썼나보다 ;;
current_deploy 가 현재 배포된 이미지이다. <none> 은 올리려다 실패한 부분
열심히 구글링 삽질을 시작했고, 블로그마다 조금씩 차이가 있었지만 큰 해결법은 비슷했다
1. EC2 볼륨 확장
2. dockerfile을 '잘' 작성해 이미지 줄이기
일단 쉬워보이는 1부터 해보자
EC2 볼륨 확장법은 너무 쉽다.
EC2 볼륨 확장하기
마찬가지로 EC2 콘솔 좌측 메뉴에서 [Elastic Block Store] -> [볼륨] 으로 가주자
원하는 볼륨을 체크 후 볼륨 수정을 눌러주자
이후 뜨는 수정 창에서 크기를 늘려주면 된다.
돈 없는 프리티어 기준 16GiB는 돈 안나온다고 하니 고고 (원래 8GiB 였음)
여기서 끝이 아니고 EC2 로 직접 접속해서 Volume 을 늘려준다
# 용량 확인
df -h
# 확장
sudo growpart /dev/xvda 1
# 파일 시스템 재할당
sudo resize2fs /dev/xvda1
이후 다시 용량을 확인해보면 아마도 8GB -> 16GB 가 되어 있을 것이다!
사실 이렇게 용량을 늘리니까 기존 no space left on device 오류를 해결할 수 있었다. (급하시면,, 이것만 고고)
하지만!!
생각 없이 짠 dockerfile 때문에 image 크기가 불필요하게 큰걸 용납할 수 없었다. 고쳐보자
dockerfile '잘' 짜보기
dockerfile을 어떻게 짜냐에 따라서 build 시 이미지 용량이 좌우될 수 있다고 한다.
여러 아티클을 읽어보면서 Nest 앱 배포 시 용량을 줄일 수 있도록 multi-stage build 를 사용해 스크립트를 수정해보았다.
수정한 dockerfile
FROM node:16-alpine AS base
# node prune 설정
RUN apk add curl bash && curl -sfL https://gobinaries.com/tj/node-prune | bash -s -- -b /usr/local/bin
WORKDIR /usr/src/app
COPY package.json ./
RUN ls -a && yarn
FROM base AS dev
COPY . .
RUN ls -a && yarn build
# run node prune - 사용하지 않는 모듈 제거
RUN /usr/local/bin/node-prune
FROM node:16-alpine
COPY --from=base /usr/src/app/package.json ./
COPY --from=dev /usr/src/app/dist/ ./dist/
COPY --from=dev /usr/src/app/node_modules/ ./node_modules/
# port 설정
EXPOSE 8081
# 환경 변수 설정
ENV NODE_ENV=development
# start
CMD ["node", "dist/main.js"]
multi-stage build
도커 빌드 시 디펜던시 설치나 설정, 빌드, 실행 단계가 있을 수 있는데 기존에는 한 단계에서 모두 실행을 했다면,
multi-stage build는 한 dockerfile 내에서 여러개의 FROM 명령어를 사용해 스테이지를 나누는
디펜던시 설치 -> 빌드 -> 이미 전단계들에서 설정한 파일이나 폴더만 받아서 가볍게 실행
이런 구조이다.
즉, 최종 단계에서는 불필요한 파일이나 폴더를 제외하고 꼭 필요한 것들만 받아와서 더 빠르고 작은 이미지를 만드는 것이다.
AS <name> 을 통해 각 단계의 이름을 지정할 수도 있다.
내가 작성한 dockerfile에서는 필요한 의존성 모듈을 설치하는 base -> 필요한 파일들로 build 하는 dev -> 빌드파일과 모듈을 가지고 실행 하는 3 단계로 나눠보았다.
base 단계에서 미리 호스트의 모든 파일을 복사할 필요 없이 package.json 파일만 현재 working dir 에 복사해준다.
이후 dependency 를 설치한 후 다음 스테이지로 넘어갔다.
어라라.
기존에 발생한 space 에러는 고쳐졌는데 yarn build 시 모듈을 찾을 수 없다는 오류가 발생했다.
직감적으로 node_modules가 꼬였구나라는걸 알았는데 .dockerignore 에 node_modules 를 추가했는데 어떤 이유인지 찾아내기가 힘들었다.
결론적으로 문제는 develop.yml 에서 EB 로 보내기 위해 zip 파일을 만들때 였다. 이 때 불필요하게 node_modules 까지 같이 zip 파일에 넣어 보내고 있어 덮어씌어진 것 같았다.
develop.yml 수정
- name: Generate Deployment Package
run: zip -r deploy.zip . -x '*.git*' './node_modules/*'
이렇게 -x 옵션을 줘서 불필요한 파일을 빼고 압축하도록 변경했다.
dev 단계에서는 base 단계에서 이어 호스트 코드들을 현재 디렉토리에 복사해주고, build 후 용량 최적화를 위해 node-prune 을 통해 불필요한 모듈을 제거해주었다.
이후 실행 단계에서는 COPY 명령어를 사용해 base, dev 단계에서 만들어진 실행에 필요한 파일, 폴더만 복사해왔다.
이때 --from 이 어느 단계에서 가져올지를 결정하니 stage를 정확히 입력해줘야한다.
추가적으로 포트 설정이나, ENV 설정은 해당 프로젝트에 맞게 작성하면 된다.
dist 폴더를 사용해 명령어를 실행해주면 완료.
사용한 docker 명령어 간단 설명
FROM : Base 이미지 지정
WORKDIR: 작업 디렉토리 전환
RUN: 커맨드 실행
COPY: 호스트 컴퓨터에 있는 디렉터리나 파일을 Docker 이미지 파일 시스템으로 복사
EXPOSE: 컨테이너로 들어오는 트래픽을 리스닝하는 포트 지정
CMD: 컨테이너로 띄울 때 default로 실행할 명령어
ENV: 환경 변수 설정
참고 - alpine image
Node.js 이미지에서 alpine 이 붙은 이미지는 경량화된 이미지이다.
대부분의 라이브러리 등이 추가되어 있지 않아서 작은 이미지를 만들 수 있으나 필요한 것들은 직접 설치해야한다.
alpine 말고도 slim 과 같은 이미지도 존재해서 필요에 따라 사용하면 될 것 같다.
결과
multi-stage build를 사용한 결과, 1.3GB -> 333MB 로 사이즈가 줄어든 걸 볼 수 있다.
이미지 사이즈 뿐만 아니라 빌드, 배포 타임도 줄어든다!
dockerfile 을 어떻게 설계하냐에 따라 이렇게 최적화가 된다는 점이 신기하면서도 공부할게 대박 많다는 걸 느꼈던 삽질기였다.
다들 이런 오류 만나면, 당황하지 말고 해결해보세요 :-)
참고한 링크
https://medium.com/@alpercitak/nest-js-reducing-docker-container-size-4c2672369d30
https://www.tomray.dev/nestjs-docker-production
https://www.slideshare.net/jit2600/multi-stage-docker-build