면접어때 프로젝트의 CI/CD를 적용하는 과정에서 생겼던 트러블 슈팅을 기록해보려고 한다.
목표
- main 브랜치에 코드 변경이 일어나면 자동으로 EC2 서버에 배포된다.
workflows
- feature-example -> main merge
- github-actions에서 감지
- Docker Image build -> DockerHub push
- EC2 접속 및 docker-compose로 컨테이너 실행
- 배포 완료
CI/CD를 진행할 면접어때의 프로젝트 환경은 Next.js + Docker-compose + AWS EC2 + github-actions 이다.
필요한 중요 파일은 3가지로 다음과 같다.
- github-actions.yml
- docker-compose.yml
- DockerFile
먼저 해당 브랜치의 action을 감지하기 위해서 github-actions.yml 파일을 생성한다.
github-actions.yml 생성
event 설정 및 권한 설정
# .github/workflows/github-actions.yml
# github repository actions 페이지에 나타날 이름
name: CI/CD using github actions & docker
# event trigger
# main 브랜치에 push가 되었을 때 실행
on:
push:
branches: ['main']
permissions:
contents: read
.github/workflows 하위에 github-actions.yml 파일을 생성한다음,
해당 actions이 실행될 때 표시될 제목을 설정해주고, push 이벤트(merge도 포함)가 main브랜치에 일어날 때 동작하도록 타겟을 설정한다.
jobs - Docker build & push
jobs:
CI-CD:
runs-on: ubuntu-latest
steps:
# github 가상환경에서 작업할 수 있도록 repository 코드 복제
- uses: actions/checkout@v3
# docker build & push to production
- name: Docker build & push to prodction
if: contains(github.ref, 'main')
run: |
docker login -u ${{ secrets.DOCKER_USERNAME}} -p ${{ secrets.DOCKER_PASSWORD}}
docker build --build-arg NEXT_PUBLIC_KAKAO_LOGIN_URI=${{ secrets.KAKAO_LOGIN_URI }} --platform linux/amd64 -t ${{ secrets.DOCKER_USERNAME}}/myeonjeobeottae .
docker push ${{ secrets.DOCKER_USERNAME}}/myeonjeobeottae
타겟 브랜치에 이벤트가 감지되면 실행할 작업을 설정해준다.
CI-CD 작업을 진행하면서 ubuntu-latest 환경에서 진행한다.
steps를 통해 단계별로 작업을 진행한다.
github-actions에서 기본으로 제공하는 액션인 actions/checkout@v3를 uses 키워드로 사용한다.
해당 액션을 사용하면 github의 가상환경에서 해당 브랜치의 최신 코드를 복제해서 작업을 진행할 수 있다.
그다음 main 브랜치 source로 docker Image를 build하고 dockerHub에 push한다.
여기서 바로 docker 명령어를 사용할 수 있는 이유는 ubuntu-latest 환경에 기본으로 docker가 설치되어 있기 때문이다.
docker-compose build --platform linux/amd64 -t ${{ secrets.DOCKER_USERNAME}}/myeonjeobeottae .
위 코드를 뜯어보면,
- --platform linux/amd64 : macOS 환경에서 빌드 시 필요한 옵션
- -t ${{ secrets.DOCKER_USERNAME }}/myeonjeobeottae : 이미지 태그 설정, 여기서 보안을 위해 github secret 변수를 사용했다. repo - settings - Secrets and variables - Actions 에서 secrets 을 설정할 수 있고, github-actions.yml 파일에서 secrets.변수명 으로 접근이 가능하다. 한 번 생성하면, 볼 수 없고 수정 or 삭제만 가능하다.
그렇게 이미지 빌드가 끝났다면, docker push 명령어로 dockerHub에 이미지를 올린다.
jobs - EC2 deploy
# deploy to production
- name: Deploy to production
uses: appleboy/ssh-action@master
id: deploy-prod
if: contains(github.ref, 'main')
with:
host: ${{ secrets.EC2_HOST}}
username: ${{ secrets.EC2_USERNAME}}
key: ${{ secrets.EC2_KEY}}
envs: GITHUB_SHA
script: |
cd client/
sudo su
docker-compose down
docker-compose up -d --build
docker system prune -f
마지막 step으로 EC2에 접속한 뒤, docker-compose로 컨테이너를 실행하는 과정이다.
appleboy/ssh-action@master 액션으로 EC2에 접속한뒤, script로 docker-compose 명령어를 실행한다.
면접어때 프로젝트에서는 nginx-proxy-manager 이미지와 면접어때 app 두 가지를 올리기위해 docker-compose를 사용했고,
역시 해당 script를 뜯어보면 다음과 같다.
- sudo su : superuser do + switch user 의 약어로, 일시적으로 루트 권한을 얻기 위해 사용
- docker-compose down : 기존에 실행중인 컨테이너가 있다면 중지하고, 해당 볼륨, 네트워크 등 리소스 삭제
- docker-compose up -d --build : docker-compose.yml 파일에 명시된 컨테이너들을 background 환경으로 기존 이미지의 존재 유무 상관없이 강제로 새로 이미지 빌드
- docker system prune -f : 사용되지 않는 docker 리소스 제거
docker-compose.yml 생성
# docker 컨테이너 버전을 명시
version: '3.8'
services:
npm:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
restart: always
ports:
- 81:81 #관리포트
- 80:80 #http
- 443:443 #https
volumes:
- ./nginx-proxy-manager/data:/data
- ./nginx-proxy-manager/letsencrypt:/etc/letsencrypt
environment:
DISABLE_IPV6: 'true'
depends_on:
- myeonjeobeottae
myeonjeobeottae:
container_name: myeonjeobeottae
# 현재 경로에 이미지 빌드
build:
context: .
dockerfile: Dockerfile
ports:
- '3000:3000'
image: tanglog/myeonjeobeottae:latest
docker-compose up -d --build 명령어에 사용 될, docker-compose.yml 파일을 작성한다.
DockerFile 생성
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /usr/src/app
COPY package.json yarn.lock* ./
RUN yarn --frozen-lockfile
RUN rm -rf ./.next/cache
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /usr/src/app
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY . .
RUN yarn build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /usr/src/app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
ARG NEXT_PUBLIC_KAKAO_LOGIN_URI
RUN touch .env.production
RUN echo "NEXT_PUBLIC_KAKAO_LOGIN_URI=${NEXT_PUBLIC_KAKAO_LOGIN_URI}" > .env.production
ENV NODE_ENV production
COPY --from=builder /usr/src/app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# public , static 폴더는 standalone에 포함되지 않으므로 따로 복사
COPY --from=builder --chown=nextjs:nodejs /usr/src/app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /usr/src/app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
DockerFile은 Docker 이미지 최적화를 위해 Next.js의 standalone 빌드를 이용한 multi-staging build로 구성했다.
이렇게 github-actions.yml, docker-compose.yml, DockerFile 3가지 파일을 준비하고
feature-example 브랜치에서 main 브랜치로 merge를 했더니..
트러블슈팅 1 - nginx-proxy-manager 폴더 permission denied 에러
main 브랜치의 merge를 감지하고 github-actions.yml이 잘 실행됐지만, DockerHub에서 pull받아온 nginx-proxy-manager 이미지가 참조하는 nginx-proxy-manager 폴더에서 permission denied 에러가 발생하면서 CI/CD가 실패했다.
이 부분은 폴더의 권한을 변경하면서 해결됐다. nginx-proxy-manager 폴더 permission-denied
트러블슈팅 2 - docker 이미지를 dockerHub에 push 후 pull 받았는데, 최신 코드가 반영 안되는 현상
github-actions.yml에서 docker-compose build로 이미지를 빌드하고, DockerFile에서 image 명령어로 dockerHub에서 면접어때 도커 이미지를 pull 받고있다. CI/CD 과정도 실패없이 성공했다.
그런데 코드가 변경됐음에도 해당 코드가 반영이 안되는 문제가 발생했다.
관련 코드들을 살펴보다가 이상한 부분을 발견했다.
# docker-compose.yml
...
myeonjeobeottae:
container_name: myeonjeobeottae
# 현재 경로에 이미지 빌드
build:
context: .
dockerfile: Dockerfile
ports:
- '3000:3000'
image: tanglog/myeonjeobeottae:latest
docker-compose.yml에서 다시 이미지 build를 하고있었다.
그래서 github-actions의 과정에서 이미지를 build하고 DockerHub에 push했음에도 만들어진 이미지를 사용하지 않고 있었던 것.
그래서 만들어진 image를 pull하는 코드로 변경했다.
개선 코드
# docker-compose.yml
...
myeonjeobeottae:
container_name: myeonjeobeottae
image: tanglog/myeonjeobeottae:latest
ports:
- '3000:3000'
이렇게 문제가 해결된 줄 알았다. 실제로 코드를 바꾼 다음 첫 CI/CD가 성공했을 때, 최신 코드가 반영된 채로 배포에 성공했었다.
그런데 두 번째부터 더 이상 최신 코드가 반영되지않고 똑같은 현상이 반복됐다. 이유가 뭘까 생각하며 상황을 다시 되짚어봤다.
- 현재 최신 코드로 Docker 이미지를 만들고 DockerHub에 push후 그 이미지를 pull받아서 docker-compose로 컨테이너를 실행중이다.
- 그런데 최신 코드가 반영이 안된다.
- 그럼 이미지를 만들 때 최신 코드로 이미지가 만들어지는게 아닌가? -> 그럴리 없다. github의 커밋 기록과 DockerHub의 Image updated가 최신이다.
- 그렇다면 DockerHub에서 이미지를 pull 받는게 문제인가? -> 관련 코드를 살펴본다.
# github-actions.yml
# deploy to production
- name: Deploy to production
uses: appleboy/ssh-action@master
id: deploy-prod
if: contains(github.ref, 'main')
with:
host: ${{ secrets.EC2_HOST}}
username: ${{ secrets.EC2_USERNAME}}
key: ${{ secrets.EC2_KEY}}
envs: GITHUB_SHA
script: |
sudo su
docker-compose down
docker-compose up -d --build // 컨테이너를 올리는 부분
docker system prune -f
github-actions.yml 파일에서 docker-compose up 으로 컨테이너를 올릴 때 밀접한 관련이 있다고 보고, 해당 docker 명령어에 대해 좀 더 자세히 알아봤다.
- docker-compose pull [서비스명]
docker-compose.yml에 정의된 해당 서비스 이미지를 허브에서 해당 이미지의 항상 최신버전으로 업데이트한다(pull받는다).
이미지의 태그가 같아도 pull한다.
- docker-compose up -d --build
docker-compose pull과 마찬가지로 이미지를 새로 받는다. 만약 로컬에 이미지가 없거나 이미지의 Tag가 다를 때만 새로운 이미지를 다운로드 한다. 즉, 로컬에 이미지가 있고, 태그가 같으면 새로 다운로드하지 않는다. pull 커맨드와의 차이점은 pull은 이미지를 다운 받는게 역할의 전부이고, docker-compose up은 다운받음과 동시에 컨테이너를 실행하는 역할까지 한다. 즉, 이 명령어를 쓴다면 docker-compose pull은 생략이 가능하다.
따라서, 나는 계속해서 myeonjeobeottae:latest로 이미지를 생성했기때문에, 같은 이미지로 판단하고 이미지를 새롭게 받지 않았던 것이다!
원인은 알았지만, 나는 태그를 latest로 계속 유지하고싶었고, nginx 이미지를 추가로 사용하고있었기 때문에, 다음과 같이 코드를 수정했다.
개선코드
# github-actions.yml
...
script: |
sudo su
docker system prune -f
docker-compose down
docker-compose pull
docker-compose up -d --build
명시적으로 docker-compose pull을 해줌으로써 허브에서 이미지를 무조건 다운받고, docker-compose up -d --build로 docker-compose.yml에 명시된 서비스 전부를 한번에 올린다. 이렇게 해결!
트러블슈팅 3 - Docker Container 내부에서 환경변수를 참조하지 못하는 문제
보안을위해 카카오 OAuth 로그인할 때, client_id와 redirect_uri를 환경변수에 담아 참조해서 사용하고있다.
NEXT_PUBLIC_REST_API_KEY=클라이언트 아이디
NEXT_PUBLIC_REDIRECT_URI=리다이렉트 콜백 uri
브라우저에서 환경변수를 참조하기위해 NEXT_PUBLIC 접두사를 달아줬다.
그런데, 로그인 버튼 클릭 시, 해당 값이 undefined로 나타났다.
https://kauth.kakao.com/oauth/authorize?client_id=undefined&redirect_uri=undefined&response_type=code
현재 배포는 EC2컨테이너에서 dockerHub의 이미지를 pull받아서 컨테이너를 올리고있는데, docker-compose.yml과 같은 위치에 .env.production 환경변수 파일이 있다.
나는 Docker Image를 기반으로 컨테이너를 올리더라도 같은 depth에 있는 .env.production을 참조할 수 있다고 생각했는데, 실제로는 참조가 안되고있었던 것이다.
검색해보니, docker 이미지 빌드시에 동적으로 환경변수를 할당할 수 있는 방법이 있다고한다.
docker build --build-arg [환경변수 key]=[환경변수 value]
위 방법으로 사용할 환경변수 두 개를 github action secret으로 만들어 적용해봤다.
# github-actions.yml
...
- name: Docker build & push to prodction
if: contains(github.ref, 'main')
run: |
docker login -u ${{ secrets.DOCKER_USERNAME}} -p ${{ secrets.DOCKER_PASSWORD}}
docker build --build-arg NEXT_PUBLIC_KAKAO_LOGIN_URI=${{ secrets.KAKAO_LOGIN_URI }} --platform linux/amd64 -t ${{ secrets.DOCKER_USERNAME}}/myeonjeobeottae .
docker push ${{ secrets.DOCKER_USERNAME}}/myeonjeobeottae
이렇게 docker 이미지 build시에 환경변수를 넣어주고,DockerFile에서 ARG 커맨드로 해당 환경변수를 가져와서 사용하면 된다고한다.
//DockerFile
...
ARG NEXT_PUBLIC_KAKAO_LOGIN_URI
ENV NEXT_PUBLIC_KAKAO_LOGIN_URI=${NEXT_PUBLIC_KAKAO_LOGIN_URI}
...
그런데 여전히 배포 앱에서 환경변수 참조값은 undefined로 변함이 없었다..
왜일까 생각하다, build시에 환경변수를 넣어주니, DockerFile에서 yarn build전에 환경변수를 세팅해줘야하나 싶었다.
그래서 DockerFile을 다시보니,
기존 코드
# DockerFile
...
FROM base AS builder
WORKDIR /usr/src/app
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY . .
RUN yarn build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /usr/src/app
ARG NEXT_PUBLIC_KAKAO_LOGIN_URI
ENV NEXT_PUBLIC_KAKAO_LOGIN_URI=${NEXT_PUBLIC_KAKAO_LOGIN_URI}
...
multi-staging 빌드로 builder와 runner를 구분해서 사용하고있었는데, 지금 build가 끝난 후에 runner에서 환경변수를 세팅하고 있었다!
개선 코드
# DockerFile
...
FROM base AS builder
WORKDIR /usr/src/app
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY . .
// build 전에 환경변수 세팅
ARG NEXT_PUBLIC_KAKAO_LOGIN_URI
RUN touch .env.production
RUN echo "NEXT_PUBLIC_KAKAO_LOGIN_URI=${NEXT_PUBLIC_KAKAO_LOGIN_URI}" > .env.production
ENV NODE_ENV production
RUN yarn build
...
yarn build로 앱을 빌드하기전에, 환경변수를 세팅하는 걸로 코드를 수정했다.
추가로, docker container안에 배포시에 참조할 수 있도록 .env.production 환경변수 파일을 만들고, 거기에 github-actions.yml에서 넘겨줬던 환경변수를 덮어씌워줬다.
이렇게 docker 이미지가 빌드되면서 동적으로 할당된 action secret이 환경변수의 역할을 하면서 production 환경에서 브라우저가 환경변수를 참조할 수 있게 되었고, 실제 이 방법으로 문제를 해결할 수 있었다!
최종 코드
github-actions.yml
# github-actions.yml
# github repository actions 페이지에 나타날 이름
name: CI/CD using github actions & docker
# event trigger
# main 브랜치에 push가 되었을 때 실행
on:
push:
branches: ['main']
permissions:
contents: read
jobs:
CI-CD:
runs-on: ubuntu-latest
steps:
# github 가상환경에서 작업할 수 있도록 repository 코드 복제
- uses: actions/checkout@v3
# docker build & push to production
- name: Docker build & push to prodction
if: contains(github.ref, 'main')
run: |
docker login -u ${{ secrets.DOCKER_USERNAME}} -p ${{ secrets.DOCKER_PASSWORD}}
docker build --build-arg NEXT_PUBLIC_KAKAO_LOGIN_URI=${{ secrets.KAKAO_LOGIN_URI }} --platform linux/amd64 -t ${{ secrets.DOCKER_USERNAME}}/myeonjeobeottae .
docker push ${{ secrets.DOCKER_USERNAME}}/myeonjeobeottae
# deploy to production
- name: Deploy to production
uses: appleboy/ssh-action@master
id: deploy-prod
if: contains(github.ref, 'main')
with:
host: ${{ secrets.EC2_HOST}}
username: ${{ secrets.EC2_USERNAME}}
key: ${{ secrets.EC2_KEY}}
envs: GITHUB_SHA
script: |
sudo su
docker system prune -f
docker-compose down
docker-compose pull
docker-compose up -d --build
docker-compose.yml
# docker-compose.yml
# docker 컨테이너 버전을 명시
version: '3.8'
services:
npm:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
restart: always
ports:
- 81:81 #관리포트
- 80:80 #http
- 443:443 #https
volumes:
- ./nginx-proxy-manager/data:/data
- ./nginx-proxy-manager/letsencrypt:/etc/letsencrypt
environment:
DISABLE_IPV6: 'true'
depends_on:
- myeonjeobeottae
myeonjeobeottae:
container_name: myeonjeobeottae
image: tanglog/myeonjeobeottae:latest
ports:
- '3000:3000'
DockerFile
# DockerFile
FROM node:18-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /usr/src/app
COPY package.json yarn.lock* ./
RUN yarn --frozen-lockfile
RUN rm -rf ./.next/cache
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /usr/src/app
COPY --from=deps /usr/src/app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_KAKAO_LOGIN_URI
RUN touch .env.production
RUN echo "NEXT_PUBLIC_KAKAO_LOGIN_URI=${NEXT_PUBLIC_KAKAO_LOGIN_URI}" > .env.production
ENV NODE_ENV production
RUN yarn build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /usr/src/app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /usr/src/app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /usr/src/app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /usr/src/app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
CI/CD로 배포 자동화를 이루고 나니, 기존 src를 비롯한 각종 config 파일들을 제거하고, nginx 폴더와 docker-compose.yml, env 파일만으로 배포할 수 있게 되었다. EC2안에서 clone을 안받아도 되니, 구조가 매우 깔끔해져서 만족한다!
'FRONTEND > 기타' 카테고리의 다른 글
[yarn-package 배포] 나만의 boilerplate 만들기 (with inquirer) (2) | 2024.02.06 |
---|---|
Linux 폴더 권한 확인 + 권한 설정 (0) | 2024.01.30 |
테스팅 그게 뭐죠? (feat. Jest, react-testing-library) (0) | 2022.12.02 |