본문 바로가기

Study/DevOps

Jenkins로 CI/CD Pipeline 구축하기 - 통합본

CI/CD 란?

CI 란?   개발자를 위한 자동화 프로세스인  지속적인 통합 ( Continuous Integration ) 

어플리케이션의 새로운 코드 변경 사항이 정기적으로 빌드/ 테스트 되어 공유 Repository에 (ex. git,github) 통합하는것을 의미한다.

다수의 개발자가 작업할 경우 레포지토리에 쌓이는 commit들이 충돌하는 것을 자동화된 빌드와 테스트로 방지할수 있다.

CD 란?  지속적인 배포 ( Continuous Deployment ) 

“수동적" 으로 배포하는것을 지속적인 제공이라 하는데, 이것을 “자동화”하는것이 지속적인 배포 ( Continuous Deployment ) 이다.

 

📌 어플리케이션 개발 단계 부터 배포 때 까지 모든 단계들을 자동화를 통해서 사용자에게 배포 할수있도록 만드는것 

 

 개발팀에서 체크해야될 사항

  • 변경이 새로운 결함을 유발하지 않는지 확인하는 코드 커버리지로 지속적 테스트 
  • 보안, 성능 및 기타 코드 품질 문제를 테스트하는 정적/동적 코드 분석 툴
  • 개발자가 빌드를 트리거시 자동으로 테스트하는 시프트-레프트(shift-left) 보안 실천 (조기결함 감지)  
  • 애플리케이션과 마이크로서비스에 대한 관찰 가능성 표준 - https://www.itworld.co.kr/tags/118845/%EA%B4%80%EC%B0%B0%EA%B0%80%EB%8A%A5%EC%84%B1/183899
  • 사용자 일부에 대해 새로운 기능을 켜고 끄거나 제어하기 위한 기능 플래깅​

프로덕션의 배포 자동화는 그 결과가 비지니스, 최종 사용자에게 영향을 미치는 만큼 위험이 크기 때문에

배포 프로세스에는 지속적 테스트와 철저한 오류처리가 포함되어야한다.

 

클라우드 서비스 | 클라우드 컴퓨팅 솔루션| Amazon Web Services

개발자, 데이터 사이언티스트, 솔루션스 아키텍트 또는 AWS에서 구축하는 방법을 배우는 데 관심이 있는 모든 사용자용 무료 온라인 교육 AWS 전문가가 구축한 500개 이상의 무료 디지털 교육 과정

aws.amazon.com

 

AWS 가입

  • 결제 관련 E-mail 로 받기 필수
  • MFA 코드로 이중 보안 해주기

 

🔆 AWS EC2 인스턴스 생성

 

 

 

제일먼저 AWS 에 접속후 EC2 인스턴스를 생성해 줍니다.

* 생성전 Resion 선택 필히 해주세요.

다른 Resion의 내용은 서로 공유되지 않습니다.

 

 

이름 칸에 EC2의 용도 혹은 이름적기.

* 추후 변경 가능

 

 

Server OS 선택 하기

* 본글에서는 Amazon Linux를 사용합니다.

 

💡 AWS 최초 가입시 프리티어 서비스 80~90개 이용 가능. EC2는 한달 기준 750시간씩 무료로 가능.

단, EC2는 인스턴스 개 당이 아닌 만들어놓은 인스턴스 총 시간으로 적용. ex) 10개의 인스턴스를 사용할시 각 75시간씩 이용 가능 💡

 

 

인스턴스 유형 선택 - 무료로 사용할 수 있는 인스턴스 유형 선택

* 내가 갖고자 하는 컴퓨터의 CPU, Memory 를 선택하는것

 

 

키페어

* 인스턴스에 접속하기 위한 암호화된 토큰 혹은 암호화된 Key-file

 

🔆 키페어 생성하기

 

새 키페어 생성 ➡ <본인이 식별할수 있는 이름 작성> ➡ 유형, 파일 형식 선택 ➡ 키 페어 생성

 

* Mac OS 사용자들은 Putty를 사용하지 않기 때문에 .pem 

Window 사용자중 Putty,Xshell을 이용할 예정이라면 .ppk를 선택

 

 

 

네트 워크 설정은 넘어가주기

 

 

스토리지는 30GB 까지 무료이므로 30GB로 설정 해주기

 

 

고급 세부 정보도 넘어가기

 

 

인스턴스 개수 <2> 로 설정해주기

* Jenkins 용 / Nginx 용 총 2개 필요함

 

요약 정보 확인후 인스턴스 시작 ⭐️

 

 

만들어진 인스턴스는

Docker / Nginx 또는 Jenkins / Nginx 

본인이 관리하기 편한 이름으로 변경해주기.

 

🔆 SSH Client Tool을 이용해 EC2 인스턴스 접속

 

 

Termius for macOS | Download

- Split view. You can open up to 4 terminals inside a single tab. - Enterprise SSO. Termius supports more than 30 identity providers.

termius.com

편리하게 인스턴스 접속하려면 SSH Client Tool을 이용하는데

다양한 tool이 있기때문에 자신에게 맞는것을 선택하면 됨

* 내가 사용한 Tool 

 

회원가입 해주기

 

설치가 완료되었다면 New Host를 만들어줍니다.

 

 

AWS에서 만든 인스턴스 이름 으로 Label 입력후 ,

Adress 칸에 Ec2 인스턴스 Public IPv4 주소 입력 (생성된 AWS 인스턴스에서 확인 가능)

 

 

EC2 인스턴스는 기본적으로 " ec2-user " 이라는 계정이 만들어 지고

로그인 방식은 ID,PW가 아닌 KEY (EC2 키페어할때 받은 .pem파일) 로 로그인을 함

 

 

 

Password 옆 Keys 클릭후 (등록키가 없다면) <create a new key>  ➡
이름 등록후 Drag & Drop 또는 import from keyfile 로 KEY-file (EC2 키페어할때 받은 .pem파일) 가져오기
➡ (암호화된 파일이 열렸다면) 오른쪽 상단 save

 

 

같은 방법으로 Nginx 도 만들어 주기

 

 

이런 화면이 나왔다면

성공⭐️

 

⚠️ ERROR ⚠️

 

만약 이런 화면이 나오셨다면 ?

 

 

Username을 바꿔서 한번 해보시면 됩니다!

*ec2-user 이 맞는 name 입니다. 임의 설정하시면 에러 납니다.

 

🔆 Docker 설치

* Command 권한 없을때는 커맨드 앞에 ⌨️   sudo su 입력후 커맨드 입력하기 *

 

 

⌨️   sudo yum update -y 

* 최초 yum Repository를 최신화 해줍니다.

 

⌨️   yum install docker -y

* Docker 설치

 

⌨️   docker -v

* Docker 버전 확인

 

⌨️   sudo service docker start

* Docker 실행 확인

 

⌨️   usermod -aG docker ec2-user

* Docker에 유저를 등록

 

⌨️   exit

* 접속 종료 후 다시 재접속

 

⌨️   docker run hello-world

* Docker 명령어를 실행해서 테스트

 

도커 설치 완료 ⭐️

 

 

🔆 Docker 에 Jenkins 설치하기

 

⌨️   docker run -itd --name jenkins -p 8085:8080 jenkins/jenkins:lts

* Docker에 Jenkins 를 최신버전으로 설치하고 컨테이너로 실행

 

⌨️   docker ps

* 젠킨스가 작동하는지 확인해주기

 

 

EC2 인스턴스에서 Docker 인스턴스 클릭후 하단에 나오는 

보안 ➡ 보안그룹 밑 sg-04ab805... 클릭 ➡ 인바운드 규칙 ➡ 인바운드 규칙 편집 ➡  규칙 추가

에서 <8085> 포트 추가해주기

 

 

인스턴스 페이지로 돌아가서 퍼블릭 IPv4 주소를 가져와서, http://ip:8085/ 로 접속합니다.

* http://내 도커 퍼블릭IPv4주소:8085/

 

 

젠킨스의 비밀번호를 입력하라고 나오는데, 패스워드는 젠킨스 컨테이너의 경로에 있습니다.

그렇기 때문에 exec 명령어로 터미널에서 실행중인 젠킨스 컨테이너에 접근해서 어드민 패스워드를 찾아봅니다.

 

 

⌨️   docker exec -it jenkins /bin/bash

⌨️   cat /var/jenkins_home/secrets/initialAdminPassword

 

패스워드를 입력하고 플러그인을 설치해줍니다.

 

 

플러그인이 설치되면 어드민 계정을 만들게됩니다.

 

 

플러그인이 설치되고 어드민 계정 만들어 줍니다.

 

 

Jenkins 접속 완료 ⭐️

 

🔆 Jenkins 와 Gitea 연동 해주기

 

 

Jenkins 대시보드에서 Jenkins 관리로 들어가주세요.

 

 

Jenkins 관리 에서  플러그인 관리에 들어가주세요.

 

 

  • Multibranch Scan Webhook Trigger
  • Parameterizwd Trigger
  • jQuery
  • Delivery Pipeline
  • Gitea Plugin
  • NodeJS

플러그인 관리 ➡ 설치 가능 ➡ 검색 으로 해당 플러그인들 설치해주세요.

 

 

다시 Jenkins 관리 ➡ 시스템 설정 에서 스크롤 내리다보면

 

 

Gitea Server 설정 이 나오고 작성해주시면 됩니다.

* Name, URL (Gitea 주소), Manage hooks 체크✅ 후 Credentials 적용 (없으면 + Add 클릭)

 

 

* Server URL 작성후 밑에 Gitea Version 이 떠야 정상적으로 연동 된것입니다.

 

🔆 Gitea Token 만들기

 

 

Gitea 접속후 우측 상단 프로필 ➡ 설정에 들어가줍니다.

 

 

어플리케이션 ➡ 토큰이름 입력후 토큰생성 ➡ 가려진 부분 토큰은 복사해놓아주세요.

* 토큰은 생성때 말고 다시 볼수 없으니 안전한곳에 복사 해두시면 됩니다.

 

다시 Jenkins로 돌아와서 Credentials-Gitea용 으로 만들어주세요.

* Kind 에서 Gitea Personal Access Token 선택후 토큰 입력해주기

 

Credentials 까지 선택해준 뒤 왼쪽 하단 저장 눌러주기

 

 

Jenkins 대시보드 에서 <새로운 Item> 으로 들어갑니다.

 

 

Item name 작성후 Multibranch Pipeline 으로 만들어주세요.

 

 

name 과 설명 작성후 Branch Sources 에 Gitea 선택후 정보 입력해주기

* Gitea 가 뜨지 않는다면 gitea plugin 설치 해야합니다.

 

 

gitea server 연결 (시스템설정에서 입력한 Gitea server) ➡ Credentials 선택 ➡

Owner (Gitea 본인 name) 등록하면 하단에 레포지토리가 자동으로 등록 됩니다.

 

왼쪽 하단에 Save 클릭

 

 

연동 완료 ⭐️

 

🔆 Jenkins File

 

만들어둔 Multibranch pipeline repository 이름옆 화살표 클릭 ➡ Configure로 들어가주세요.

 

 

연결 해두었던 Branch Sources 에 Gitea server 아래에 Behaviours 와 Property strategy 를 사진대로 설정

* 아래 Add 버튼 클릭후 Filter by name (with regular expression) 추가 후 " (main.*) " 작성

 

 

Build Configuration은 Jenkins-file이 default 값으로 되어있음으로 그대로 두고 넘어가시면 됩니다.

 

 

Multibranch Scan Webhook Trigger 플러그인을 설치 했다면 

Scan Multibranch Pipeline Triggers 이 생기고 둘다 체크박스✅ 선택후 사진과 같이 작성

* Trigger token 이름은 Gitea에서 webhook 설정을 할 때 header에 담을 token을

지정해주는 거기 때문에 자신이 원하는 이름으로 지정해주면 됨

 

왼쪽 하단 Save 클릭

 

Jenkins 연동은 되었지만 Jenkinsfile not found 라고 뜨셨을 겁니다.

 

 VSCODE에 폴더 만들어주시고

새파일 ➡  Jenkinsfile 을 만들면 아저씨얼굴 모양의 파일이 생성됨

 

📌 Gitea와 VSCODE를 먼저 연동해주셔야 합니다.

Gitea 에서 새로운 레포지토리 생성후 VSCODE에서 연동해주시면 됩니다.

 

파일에 위 코드를 작성해주고 push

 

 

 

Gitea Repository 에 Commit 된것 확인

 

 

Jenkins 로 돌아가서 다시 Scan + Log 해보기 

 

Jenkins-file Found 완료 ⭐️

 

🔆 Webhook 설정

 

Gitea 에서 본인 레포지토리로 들어간뒤

우측에 설정 ➡ 웹훅 ➡ webhook 추가 ➡ Gitea 클릭

 

 

URL 에는 http://젠킨스주소/multibranch-webhook-trigger/invoke?token=(Trigger token)

* (Trigger token) 에는 위에 설정했던 Scan by webhook 의 Trigger token 적어주기

 

Webhook 갱신

 

 

webhook 갱신 밑

" 전달 시험 " 버튼을 통해 test를 진행

만약 이런 에러가 뜬다면, EC2 설정에 들어가봐야합니다.

저같은경우는 인바운드 보안규칙설정에서 제 공인IP로만 접속이 가능하게 해놨던 문제였습니다.

 

이렇게 보안규칙을 하나 추가해주시고, 저장 후 웹훅을 다시 돌려보겠습니다.

이렇게 나오시면 성공입니다 !

 

연동확인을 위해 vscode에서 push를 해봤습니다.

gitea에도 push가 잘 되었고, jenkins에서 빌드까지 연동되셨다면  웹훅 성공 ⭐️

 

🔆 Docker에 SonarQube 설치하기

 

SonarQube는 SSH가 아닌 로컬에 설치해야 합니다.

그래서 터미널 이용시 SSH 터미널이 아닌 (mac 기준) iTerm 으로 설치하면 됩니다.

 

 

Docker Desktop 설치

 

 

Docker Hub 접속후 왼쪽 상단 검색 창에 " sonarqube " 검색

 

 

소나큐브 공식 계정 이미지와 명령어가 나옴

 

+추가로

docker pull sonarqube:lts

​라고 입력후 설치도중에 에러가 났다면 (with M1)

docker pull --platform linux/amd64 sonarqube:lts​

옵션을 줘서 linux을 명시해야 정상 다운로드가 됩니다.

만일 이상이 더 있을 경우 dockerhub에 public으로 올려진 m1용 소나큐브 이미지를 받을 수 있습니다.

하지만 이건 공식으로 제공되는 게 아니라, 잘 보고 받아야합니다.

 

m1용 으로도 안되신다면?

mwizner으로 올라간 소나큐브를 아래 추가한 명령어로 추가해서 사용해보시면 됩니다.


docker run -d -p 80:80 --name sonarqube sonarqube:lts

 

docker run -d -p 9000:9000 mwizner/sonarqube:8.7.1-community

Docker desktop gui에서는 내가 방금 만든 컨테이너가 동작하는 log를 실시간으로 볼 수가 있습니다.

 

 

📍

 

no matching manifest for linux/arm64/v8 in the manifest list entries

라는 오류 발생시

⌨️   docker pull --platform linux/amd64 sonarqube

 

 

latest 태그 까지 완료 

📍

 

 

⌨️   docker images

* sonarqube 이미지가 받아진것을 확인할 수 있음

 

 

⌨️ docker run -d --name sonarqube -p 9000:9000 sonarqube

* 소나큐브 컨테이너 실행

 

📝 -d 옵션을 통해 백그라운드로  실행하고

-- name 옵션으로  sonarqube 라고 명명

-p 옵션으로 포트를 설정 📝

 

⌨️    docker ps 

* sonarqube 컨테이너가 실행되고있는것을 확인

 

 

 

Docker Desktop 에 Sonarqube 컨테이너가 생성된것을 확인

 

 

빨간별 표시부분에 ▶️ 클릭 후

* 사진 상으론 ◼️ 로 되어 있지만 Run 시작 하지 않으신 분들은 ▶️ 로 뜸

 

소나큐브 컨테이너를 눌러보면

 

 

설치가 진행 되고 있는것을 확인

* 중간중간 잠깐 멈췄다가 계속 설치가 되고 있는 경우가 있으니

더이상 설치가 진행되지않을때 쯤 브라우저 접속하는것을 추천

 

 

{ 본인 공인 IP }:9000 혹은 localhost:9000

으로 접속

* mac 에서 공인IP 커맨드 ⌨️   curl ifconfig.me

+ 공인 IP도 인바운드 규칙에서 설정해주셔야 합니다.

 

로딩이 시간이 좀 걸리니 기다려주세요

 

 

설치가 끝나고 실행이 된다면 admin / admin 으로 로그인

 

 

로그인 하고나면 Password 변경창이 나오고 Password 변경해주기

 

 

설치 완료 ⭐️

 

🔆 Jenkins 와 SonarQube 연동하기

 

 

Jenkins 관리에서 플러그인 관리로 들어가주세요.

 

 

SonarQube Scanner for Jenkins 설치

* SonarQube Scanner 라고 나와도 OK

 

 

다시 Jenkins 관리에서 Global Tool Configuration 클릭

 

 

내리다보면 SonarQube Scanner 에서 Add SonarQube Scanner 클릭

 

 

Name 지정해주고 version 도 최신 버전으로 선택 ➡ 왼쪽 하단 SAVE

 

🔆 SonarQube 프로젝트 생성

* 내 소나큐브 실행한 서버 접속이 외부에서 잘 되는지 확인 해봐야 합니다.

인바운드 설정을 해줘야할 수 도 있습니다.

 

 

소나큐브 실행시켜주고, Gitea가 없기에 Manually 로 선택

 

 

Name 과 Key 작성후 Set Up 클릭

 

 

여기도 Gitea 가 없어서 Other CI 클릭

 

 

Token name 지정후 Generate

 

 

Token 생성 완료 ➡ Continue 클릭

 

 

Others ➡ macOS 선택후 나오는것 확인 (확인만 하셔도 됩니다. 따로 설치 안하셔도 되요.)

 

 

설정할 project 이름 클릭해서 설정으로 들어갑니다.

 

 

Administration -> Webhooks 에 들어가주세요.

 

상단에 create 클릭해주세요.

 

 

이름을 적어주시고 URL은 젠킨스주소/sonarqube-webhook/ 
으로 적고 만들어주세요.

 

 

다음과 같이 나온다면 웹훅 생성 완료입니다.

 

 

상단에 Quality Gates 메뉴로 들어가서 좌측에 Create를 눌러서 원하는 이름으로 만들어주세요.

 

 

만든후 우측에 Add Condition을 눌러서 다음과 같이 원하는 Coverage퍼센트를 적고 만들어줍니다.

 

 

그후 하단에 Project에서 자신의 project를 검색한 후 추가해주시면 Quality Gates 설정까지 완료 !

 

 

Jenkins 로 돌아오셔서 Jenkins 관리 ➡ Security 에 Manage Credentials 클릭

 

 

Stores scoped to Jenkins 에서 (global) ➡ Add credentials 클릭

 

 

📍 Kind = Secret text

📍 Scope = Global

📍 Secret = 소나큐브에서 프로젝트 생성시 만든 토큰

📍 ID 와 Description 은 임의로 지정

 

Create 클릭

 

 

Jenkins 관리 ➡ 시스템 설정 클릭

 

 

내리다보면 SonarQube Servers 가 나오면 Environment variables 체크✅ 후 Add SonarQube 클릭

 

 

📍 Name = 임의지정

📍 Server URL = 본인 소나큐브 URL

* 9000 포트 뒤에 </> 가 붙으면 안됨

📍 Server authentication token = Credentials 에서 생성한 토큰 (이미 만들었기 때문에 선택하면 됨)

 

왼쪽 하단 SAVE 클릭

 

 

VSCODE 에서 Jenkinsfile 과 같은 폴더 내에 sonar-project.properties 파일 생성

 

# must be unique in a given SonarQube instance
sonar.projectKey=cicd-test 📌 본인 소나큐브 프로젝트 이름 적기 📌
sonar.sources=.
# sonar.tests=.
# sonar.testExecutionReportPaths=coverage/cobertura-coverage.xml
sonar.exclusions=node_modules/**, coverage/**, public/**, build/**, **/__tests__/**,script/**,Dockerfile
# sonar.javascript.lcov.reportPaths=coverage/lcov.info

# --- optional properties ---

# defaults to project key
#sonar.projectName=My project
# defaults to 'not provided'
#sonar.projectVersion=1.0
 
# Path is relative to the sonar-project.properties file. Defaults to .
#sonar.sources=.
 
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8

 

작성후 Gitea에 push 해놓기

 

 

Jenkins Plugin 관리에서 NodeJS 설치 안하셨다면 설치해줍니다. ( VSCODE npm build시 필요함 )

 

 

Jenkins 관리 ➡ Global Tool Configuration 에 맨밑 NodeJS 설정 해주기

 

** 빌드하기 전 ** 

📍

📝 젠킨스에서 소나큐브를 빌드하게되면 EC2 메모리 용량의 부족으로 빌드가 멈추게 될수 있음

그래서 미리 스왑메모리를 설정해두면 이를 방지할 수 있음 

 

SSH Docker 터미널에 들어가서 다음과 같이 작성해주기

 

⌨️   sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=2048

⌨️   sudo mkswap /mnt/swapfile

⌨️   sudo swapon /mnt/swapfile

 

만약 sudo not command 에러가 나온다면

⌨️   apt-get install sudo -y

를 먼저 실행하고 다시 진행하기

📍

 

 

 

다음과 같이 Jenkinsfile 코드를 작성해주세요.

 

 

gitea에 push해주고 젠킨스를 확인해보겠습니다.

 

 

Build 성공 ⭐️



🔆 Jest 연동하기

 

Jest란?

Jest 공식문서는 다음과 같이 설명하고 있습니다.

Jest is a delightful JavaScript Testing Framework with a focus on simplicity.

Jest는 facebook에서 만든 자바스크립트 테스팅 프래임워크로, Jest를 이용하면 Code coverage 측정, Function Mocking, Snapshot test 등을 수행할 수 있다.

 

이번에는 code coverage 중 next, react와 연관성이 높은 Jest를 설치해보겠습니다.

npm install --save-dev jest

 

 

Jest.config.js 작성

Jest는 이미 기본적으로 configuration을 따로 작성하지 않아도 되지만 configuration을 작성하면 세부적인 기능을 사용할 수 있습니다.

const nextJest = require('next/jest');

const createJestConfig = nextJest({
  // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
  dir: './',
});

// Add any custom config to be passed to Jest
/** @type {import('jest').Config} */
const customJestConfig = {
  // Add more setup options before each test is run
  // setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
  moduleDirectories: ['node_modules', '<rootDir>/'],
  testEnvironment: 'jest-environment-jsdom',
  collectCoverageFrom: [
    '**/pages/**',
    '**/utils/**',
    '!**/node_modules/**',
    '!**/coverage/**',
    '!**/public/**',
    '!**/.next/**',
  ],
  coverageReporters: ['text', 'lcov', 'json', 'text', 'clover', 'cobertura'],
};

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);

각 설정에 대한 설명을 해보면,

moduleDirectories - 필요한 모듈의 위치에서 재귀적으로 검색할 디렉터리 이름 배열입니다.(default: ['node_modules'])

testEnvironment - 테스트 환경에 대한 옵션입니다. 기본으로 Node.js 환경입니다. 저희는 web app을 빌드하고 있기 때문에 브라우저와 같은 환경인 jsdom을 선택하였습니다. 참고로 각 테스트 코드 파일마다 @jest-environment 를 파일 상단에 추가하여 해당 파일에서 사용되는 모든 테스트에 대해 사용될 환경을 지정해줄 수 있습니다.

collectCoverageFrom coverage를 측정할 소스를 지정해줄 수 있습니다. 앞에 느낌표(!)가 들어간 파일(or 폴더)는 coverage 측정에서 제외합니다. 또한 collectCoverageFrom에 적힌 파일/폴더의 순서도 중요합니다. 뒤에 위치한 파일/폴더가 앞에 위치한 것보다 더 우선순위가 높습니다.

coverageReporters coverage roport를 작성할 때 Jest가 사용하는 reporter 이름의 배열입니다.

 

 

Sample 테스트 코드 작성

subtraction.js 이라는 파일을 만들어서 utils 폴더를 생성한 뒤 안에 넣어줍니다.

function subtraction(a, b) {
	return a - b;
}

module.exports = subtraction;

파일안에 다음과 같이 코드를 작성해줍니다.

 

그 후 subtraction.test.js 이라는 파일을 만들어서 __test__라는 폴더를 생성한 뒤 넣어주세요.

const subtraction = require("../utils/subtraction");
test("subtracts 4 - 2 to equal 2", () => {
  expect(subtraction(4, 2)).toBe(2);
});

다음과 같이 코드를 작성해주세요.

 

 

Code coverage 측정

Jest에서 Code coverge를 측정하기 위해서 jest cli의 option 중 --coverage 을 추가해야 합니다.

이 옵션을 추가하면 test coverage 정보가 수집되고 output으로 coverage report가 나옵니다.(공식문서 설명)

 

package.json 파일로 가서 코드를 추가해주세요.

{
  ...
  "scripts": {
    ...
    "test:coverage": "jest --coverage"
  },
  "dependencies": {
    ...
  },
  "devDependencies": {
    ...
    "jest": "^29.2.0",
    ...
  }
}

다음 터미널에 다음 명령어를 입력하면 test 결과와 프로젝트 root 경로에 coverage 폴더가 만들어집니다.

npm run test:coverage

이런 에러가 나온다면   npm install -D jest-environment-jsdom  를 먼저 하고 진행해주세요 !

 

그 후 npm run test:coverage 를 실행하면 다음과 같이 PASS 문구가 나온다면 jest 설치는 완료된 것입니다.

 

test 결과

coverage 폴더가 만들어집니다.

/coverage/lcov-report 폴더 안에 index.html을 chrome에서 열어보면,

우리가 아까 작성한 subtraction.js 안에 빼기 연산 함수에 대한 coverage 결과를 확인할 수 있습니다.

현재는 utils - coverage를 100% 만족했다고 나오고 있습니다.

 

 

stage('Test') {
  steps {
    script {
     sh 'npm run test:coverage'
    }
  }
}

Jenkinsfile에 다음과같이 코드를 추가해주세요.

 

gitea에 push를 해주고 Jenkins로 돌아가보겠습니다.

 

sonarqube 뒤에 test라는 항목이 생기고, 성공적으로 빌드가 되었습니다.

 

 

🔆  ESLint airbnb, prettier 적용하기

"eslint는 코드 퀄리티를 보장하도록 도와주고, prettier는 코드 스타일을 깔끔하게 혹은 통일되도록 도와준다."

 

npx install-peerdeps --dev eslint-config-airbnb

eslint airbnb 규칙을 사용하기 위해 필수 종속성 까지 한번에 받습니다.

 

 

npm install --save-dev typescript eslint-config-airbnb-typescript @typescript-eslint/eslint-plugin @typescript-eslint/parser

TypeScript 관련 패키지도 받아줍니다.

 

 

npm install --save-dev prettier eslint-plugin-prettier eslint-config-prettier

Prettier 관련 패키지를 받아줍니다.

 

.eslintrc.js Setting

module.exports = {
  root: true,
  parser: "@typescript-eslint/parser",
  plugins: ["@typescript-eslint", "prettier"],
  parserOptions: {
    project: "./tsconfig.json",
  },

  env: {
    node: true,
  },

  extends: [
    "airbnb",
    "airbnb-typescript",
    "@typescript-eslint/parser",
    "plugin:prettier/recommended",
    "plugin:@typescript-eslint/recommended",
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended-requiring-type-checking",
  ],

  ignorePatterns: [".eslintrc.js"], 
  // Failed to load config "@typescript-eslint/parser" to extend from. 
  // eslint가 .eslintrc.js파일이 구성에 포함되어 있지 않더라도 lint를 시도하기 때문에 오류가 발생합니다.

  rules: {
    "react/jsx-props-no-spreading": "off",  
    //  Error: Prop spreading is forbidden  react/jsx-props-no-spreading
    "react/react-in-jsx-scope": "off",
    // 'React' must be in scope when using JSX 에러 지우기(Next.js)
    "react/jsx-filename-extension": [1, { extensions: [".ts", ".tsx"] }],  
    // ts파일에서 tsx구문 허용(Next.js)
    "no-unused-vars": 'warn', 
    // 정의 후 사용하지 않은 변수는 경고만 하기
    "no-console": 0, 
    // console 사용하기
    "import/prefer-default-export": ['off'], 
    // export const 문을 쓸때 에러를 내는 rule 해제
    "import/extensions": 0,
    // ts 파일을 불러오기 위해
  },
};

 

.prettierrc.js Setting

module.exports = {
  printWidth: 80, //코드 한줄 최대치
  semi: true, //코드 마지막에 세미콜론
  singleQuote: false, //문자열을 작은따옴표로 작성할것인지(false = 큰 따옴표)
  trailingComma: 'all', //객체나 배열 등에 맨 마지막에도 콤마
  tabWidth: 2, //들여쓰기 2칸(스페이스 2칸)
  bracketSpacing: true, //객체 리터럴에서 괄호에 공백 삽입 여부
  endOfLine: 'auto',  // EoF 방식, OS별로 처리 방식이 다름 
  useTabs: false, //탭 대신 스페이스
  arrowParens: 'avoid', // 화살표 함수에서 매개변수를 하나만 받을때 괄호 생략
};

 

 

Jenkinsfile Setting - Lint stage 추가

 

Prettier 전체옵션

{
  "arrowParens": "avoid", // 화살표 함수 괄호 사용 방식
  "bracketSpacing": false, // 객체 리터럴에서 괄호에 공백 삽입 여부 
  "endOfLine": "auto", // EoF 방식, OS별로 처리 방식이 다름 
  "htmlWhitespaceSensitivity": "css", // HTML 공백 감도 설정
  "jsxBracketSameLine": false, // JSX의 마지막 `>`를 다음 줄로 내릴지 여부 
  "jsxSingleQuote": false, // JSX에 singe 쿼테이션 사용 여부
  "printWidth": 80, //  줄 바꿈 할 폭 길이
  "proseWrap": "preserve", // markdown 텍스트의 줄바꿈 방식 (v1.8.2)
  "quoteProps": "as-needed" // 객체 속성에 쿼테이션 적용 방식
  "semi": true, // 세미콜론 사용 여부
  "singleQuote": true, // single 쿼테이션 사용 여부
  "tabWidth": 2, // 탭 너비 
  "trailingComma": "all", // 여러 줄을 사용할 때, 후행 콤마 사용 방식
  "useTabs": false, // 탭 사용 여부
  "vueIndentScriptAndStyle": true, // Vue 파일의 script와 style 태그의 들여쓰기 여부 (v1.19.0)
  "parser": '', // 사용할 parser를 지정, 자동으로 지정됨
  "filepath": '', // parser를 유추할 수 있는 파일을 지정
  "rangeStart": 0, // 포맷팅을 부분 적용할 파일의 시작 라인 지정
  "rangeEnd": Infinity, // 포맷팅 부분 적용할 파일의 끝 라인 지정,
  "requirePragma": false, // 파일 상단에 미리 정의된 주석을 작성하고 Pragma로 포맷팅 사용 여부 지정 (v1.8.0)
  "insertPragma": false, // 미리 정의된 @format marker의 사용 여부 (v1.8.0)
  "overrides": [ 
    {
      "files": "*.json",
      "options": {
        "printWidth": 200
      }
    }
  ], // 특정 파일별로 옵션을 다르게 지정함, ESLint 방식 사용
}

 

 

🔆 Dockerfile 추가하기

 

Jenkins 에서 도커 명령어를 사용하기 위해서는 도커와 도커 파이프라인 플러그인을 설치 해주셔야 합니다.

플러그인 없이 사용하게 되면 파이프라인 스크립트내에서 도커 관련 설정이 인식되지 않아 에러가 발생합니다.

 

 

#기존 node 16버전의 이미지로부터 새로운 이미지 생성함을 지정
FROM node:16-alpine

#dockerfile을 생성/관리하는사람
MAINTAINER 📌본인 이름 <본인 이메일>📌

# 이미지내에 /app 디렉토리 생성
# 컨테이너 안에는 기본적으로 root권한으로 만들어짐
# 만일, 운영계정을 별도로 만들어 관리한다면 폴더 소유자 혹은 권한에 대한 설정도 해야됨.
RUN mkdir -p /app

# 이미지내의 /app 디렉토리를 WORKDIR 로 설정
WORKDIR /app

# 현재 Dockerfile 있는 경로의 모든 파일을 /app 에 복사
COPY ./ /app

# 실행하는 컨테이너 안에서 work dir에 있는 package.json을 기반으로 모듈 설치
RUN npm install

#환경변수 설정 : 운영 or 개발
ENV NODE_ENV production

# 컨테이너가 하게될 실행 명령 선언. 
# RUN은 이미지 빌드 중 실행. ENTRYPOINT 와 CMD는 이미지 빌드 후 컨테이너 실행시점에 살행
# ENTRYPOINT 와 CMD 차이점은, 
# ENTRYPOINT는 컨테이너가 수행될 때 반드시 지정한 명령을 수행되도록 지정
# CMD는 사용자가 입력하는 파라미터에 따라 변동가능
# 추후 두개의 명령어 테스트 필요
CMD ["npm","start"]

 

Dockerfile 도 같이 만들어서 위 코드로 push 해주기

* 코드 중간에 📌부분은 본인 이름 <본인 이메일주소> 적어주기

 

 

🔆 Jenkins 에서 원격 배포 하기

 

mkdir /home/ec2-user/deploy

 

SSH docker 터미널에서 deploy 폴더를 만들어 줍니다.

 

젠킨스에서 npm build를 진행시,

컴파일이 완료된 파일들을 실제 서비스 할 수 있도록 운영될

서버에 자동으로 이동 > 실행되고 있는 서버에 수정한 파일 반영 > 사이트에서 보여야합니다.

 

 

Jenkins Plugin 관리에서 Publish over SSH 플러그인을 설치 해줍니다.

 

 

그러면 Jenkins 관리의 시스템설정 제일 하단에 SSH Server 항목이 추가됩니다.

 

 

SSH 에 만들어둔 Nginx 서버 정보 입력해주시면 됩니다.

📍 Name : nginx

📍 Hostname: 서버 주소(ip)

📍 Username: ec2-user

📍 Remote Directory : /home/ec2-user/deploy

 

 

하단에 Use passwoard authentication, or ....옵션을 체크✅ 하고

Nginx 서버 만들때 사용한 AWS key 붙여넣기 해줍니다.

* SSH 에서 Nginx 서버 정보 수정들어가시면,

key 설정해주는곳에서 +new 클릭후 AWS 에서 만든 키 파일 import 해주시고

private key 란에 나오는 key 복붙 하시면 됩니다.

 

🔆  무중단 배포

무중단 배포 방식

왜 필요할까?

개발 결과물을 고객에게 제공하기 위해서는 서버에 배포해야 합니다.

최신 애플리케이션들은 클라우드 기반으로 구성되어 트래픽에 따라 배포 시 서비스를 멈춰야 하는 중단 배포 방식의 경우 다운타임(Downtime)이 발생하여 고객이 서비스를 이용하지 못하게 되는데 이러한 부정적인 영향을 최소화하는 좋은 대안입니다.

무중단 배포를 하기 위한 3가지 아키텍처.

롤링 배포

롤링 배포는 사용 중인 인스턴스 내에서 새 버전을 점진적으로 교체하는 것으로 무중단 배포의 가장 기본적인 방식

블루-그린 배포

구버전과 동일하게 신버전의 인스턴스를 구성한 후, 로드밸런서를 통해 신버전으로 모든 트래픽을 전환하는 배포 방식

카나리 배포

신버전과 구버전의 사용률을 로드밸런서를 통해 조절하면서 부정적 영향을 최소화하고 상황에 따라 트래픽 양을 늘리거나 롤백할 수 있다.

여기서 저희는 블루-그린 배포 방식을 구현을 해보겠습니다.

 

🔆 Docker Compose  설치

 

 

Install the Compose plugin

 

docs.docker.com

 

docker compose :

도커 이미지로 만들어진 컨테이너들을 만들고 실행할때 우리는 포트라던지 여러 옵션들을 기억하고 있어야했고 만일 하나가 아니라 여러 컨테이너들을 관리를 해야된다고 하면 여간 불편한일이 아닐 수 없습니다.

하나의 설정파일로 관리할 수 있도록, 조금 더 사용자에게 편리하도록 제공하는것이 docker의 기능 중 하나인 docker compose 입니다.

 

 

$ DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker}
$ mkdir -p $DOCKER_CONFIG/cli-plugins
$ curl -SL https://github.com/docker/compose/releases/download/v2.12.0/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose

 

SSH docker 서버에 

⌨️   DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker}

⌨️   mkdir -p $DOCKER_CONFIG/cli-plugins

⌨️   curl -SL https://github.com/docker/compose/releases/download/v2.11.2/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose

 

 chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose

 

실행 권한 부여를 해줍니다.

 

docker compose version

 

도커 컴포즈 버전 확인

⌨️   docker compose version

 

🔆 무중단배포 설정

[Nginx 서버 셋팅]

1. Nginx 서버 구성 소스를 위한 GIT 저장소 만들기

 

2. jenkins 에서 새로운 파이프라인 아이템 생성

 

3. 웹훅 추가

 

4. 저장소에 배포를 위한 소스 추가

자동 배포를 위한 Jenkinsfile,

도커 컴포즈를 실행하기 위한 deploy.sh,

도커 컨테이너를 구성하기 위한 docker-compose.yml,

서버 설정을 위한 nginx.conf

서버 설정내에서 블루-그린 배포에 따른 설정이 필요한 부분을 따로 nginx.server.conf 설정을 빼서 관리하도록 하였습니다.

블루-그린 서버에 따른 서버 설정을 별도로 구성하였습니다.

server.blue.conf: 블루

server.green.conf : 그린

Jenkinsfile

checkout > publish over ssh 플러그인을 이용한 원격 서버 전송 > deploy.sh 실행

pipeline {
    agent any
     stages {
          stage("Checkout") {
                steps{
                    echo "checkout start"
                    script{
                        checkout scm;
                    }
                    echo "checkout end"
                }
            }
        stage("Transfer") {

            steps([$class:"BapSshPromotionPublisherPlugin"]){
                script{
                    sshPublisher(
                        publishers:[
                            sshPublisherDesc(
                                configName:"nginx",
                                verbose:true,
                                transfers:[
                                    sshTransfer(
                                        remoteDirectory:"/conf.d",
                                        sourceFiles:"**/**",
                                        execCommand:"chmod +x /home/ec2-user/deploy/conf.d/deploy.sh"
                                    )
                                ],
                            )
                        ]
                        
                    )
                }

            }

        }
         stage("Deploy") {

            steps([$class:"BapSshPromotionPublisherPlugin"]){
                script{
                    sshPublisher(
                        publishers:[
                            sshPublisherDesc(
                                configName:"nginx",
                                verbose:true,
                                transfers:[
                                    sshTransfer(
                                       execCommand:"/home/ec2-user/deploy/conf.d/deploy.sh"
                                    )
                                ]
                            )
                        ]
                        
                    )
                }

            }

        }
    }

}

 

저희는 시스템 설정에서 ssh 서버 설정의 디렉토리를 기본으로 /home/ec2-user/deploy로

지정해놨었기 때문에 파일을 전송할 경우 폴더를 명시하지 않을 경우 해당 디렉토리로 바로 이동됩니다.

그래서 저희는 remoteDirectory로 폴더로 conf.d를 지정해주어

최종으로는 /home/ec2-user/deploy/conf.d 폴더에 소스들이 이동되게 됩니다.

deploy.sh

EXIST_NGINX=$(docker ps | grep front-web)
if [ -z "$EXIST_NGINX" ]; then 
    docker compose -p front-web -f /home/ec2-user/deploy/conf.d/docker-compose.yml down
fi 
    docker compose -p front-web -f /home/ec2-user/deploy/conf.d/docker-compose.yml up -d
  
exit 0

 

해당 명령어가 시작되면, 저희가 컨테이너이름을 준 front-web가 실행중인지를 찾고,

조건에 따라 yml 파일을 기준으로 다운되고 업 됩니다.

docker-compose.yml

version: '3.9'
services:
  nginx:
    image: nginx
    ports:
      - 80:80
    extra_hosts:
        - "host.docker.internal:host-gateway"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./nginx.server.conf:/etc/nginx/nginx.server.conf
    container_name: front-web

해당 부분은 저희같은 경우, nginx.conf , nginx.server.conf 을 컨테이너 밖에서도 관리하기 위해 volume설정을 해주었습니다.

이렇게 설정하면 저희가 docker-compose.yml 파일 위치를 기준으로한 ./nginx.conf 파일의 내용을 변경하면 실제 컨테이너에서 동작할때 사용되는 /etc/nginx/nginx.conf 파일을 수정하는 효과를 볼 수 있습니다.

nginx.conf

user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}


http {
    upstream app{
        include nginx.server.conf;
    }
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    server {
        listen 80;
            location / {
                proxy_pass http://app;
                proxy_redirect off;
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            
            proxy_connect_timeout 300;
            proxy_send_timeout 300;
            proxy_read_timeout 300;
            send_timeout 300;

            proxy_buffer_size 128k;
            proxy_buffers 4 256k;
            proxy_busy_buffers_size 256k;
        }

    }

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
}

 

nginx.server.conf : 초기 설정 추후 블루 또는 그린 서버 내용으로 덮어씌워짐

server host.docker.internal:3001 ; server host.docker.internal:3003 backup;

nginx.blue.conf : 블루일 경우 nginx.server.conf에 적용

server host.docker.internal:3001 ; server host.docker.internal:3003 backup;

nginx.green.conf : 그린일 경우 nginx.server.conf에 적용

server host.docker.internal:3002 ; server host.docker.internal:3004 backup;
 

5. 적용 후 git push를 하고 웹훅이 제대로 동작하는지 확인합니다.

 

6. nginx 컨테이너 정상 동작 확인 및 AWS 인바운드 설정

docker ps 를 이용해서 컨테이너의 실행 여부를 확인합니다.

 

nginx.conf에 설정했던 80번 포트가 열려있어야 외부에서 접속이 가능하니까, aws라면 인바운드 규칙이 적용되어있는지, 혹은 아이피가 외부에서 접근가능하도록 포트포워딩을 설정했는지 확인을 해줘야합니다.

만일 프로젝트 소스를 연결하는 3001,3002,3003,3004 의 컨테이너가 없을 땐 아래와 같은 화면이 나타납니다.

이건 당연한 결과니 안심할 것.

nginx 를 설정했으니, 3001~3004번 포트에 프로젝트를 blue-green 교차 배포를 위한 스크립트를 작성해보겠습니다.

[ Project 소스 deploy.sh 작성]

DOCKER_APP_NAME="front"

#컨테이너에 쓰이는 프로젝트 명이며, 통일해서 사용하기 위해 변수로 지정, 프로젝트 명이 바뀔때 이부분만 바꾸면 된다.

DEPLOYPATH="/home/ec2-user/deploy/"

#배포소스가 있는 경로이며, 스크립트내에서 반복적 사용을 위해 변수로 지정

CURRENT="blue" # current변수에 blue를 지정

ALTER="green" # alter 변수에 green 지정

EXIST_CURRENT=$(docker compose -p ${DOCKER_APP_NAME}-${CURRENT} -f ${DEPLOYPATH}prod/script/prod/docker-compose.${CURRENT}.yml ps --status=running | grep

${DOCKER_APP_NAME}-${CURRENT})

#EXIST_CURRENT 변수에 docker compose 명령어를 이용하여 사용하는 blue용 컨테이너가 실행되고 있는지 조회하며, 그 결과는 컨테이너에 대한 정보를 문자열로 확인 할 수 있다.

#만일 current 변수인 blue 의 컨테이너가 존재하지 않는다면 반환되는 값이 없기때문에 출력시 아무것도 나오지 않게 된다.

EXIST_ALTER=$(docker compose -p ${DOCKER_APP_NAME}-${ALTER} -f ${DEPLOYPATH}prod/script/prod/docker-compose.${ALTER}.yml ps --status=running | grep ${DOCKER_APP_NAME}-${ALTER})

#EXIST_ALTER 변수에 그린용 컨테이너가 실행되고 있는지 조회하며, 그 결과를 문자열로 반환하여 확인 할 수 있다.

DATE=$(date '+%Y%m%d%H%M%S')

echo "DATE : $DATE"

# dockerfile 이미지를 백업하기 위한 현재 날짜,시간을 변수에 담고 echo 명령어로 그 결과를 출력한다.

if [ -z "$EXIST_CURRENT" ]; then

# if문 [조건] 문법이며, -z 는 조건문에서 다음에 작성된 문자열의 길이를 체크하며, 문자열 길이가 0이면 true, 아니면 false 반환을 해준다.

# 즉, EXIST_CURRENT 는 blue컨테이너가 존재하면 문자열을 가질 것이고 길이가 0보다 클것이기 때문에 해당 조건을 만족하지 않아 else 절로 넘어갈 것이다.

docker compose -p ${DOCKER_APP_NAME}-${CURRENT} -f ${DEPLOYPATH}prod/script/prod/docker-compose.${CURRENT}.yml up -d

# docker compose 명령어로 blue용 docker-compose 설정 파일을 실행한다.

-p : --project-name 의 생략된 옵션이다. 프로젝트 이름을 설정한다.

-f : --file 의 생략된 옵션으로, 도커컴포즈의 설정파일의 패스를 설정해서 사용하도록 한다.

-d : --detach 의 생략된 옵션으로 백그라운드 실행을 하도록 한다.

-v : --version 의 생략된 옵션으로, 도커컴포즈의 버전을 알려준다.

[참고]

https://docs.docker.com/engine/reference/commandline/compose/

 

docker compose

docker compose: You can use compose subcommand, `docker compose [-f ...] [options] [COMMAND] [ARGS...]`, to build and manage multiple services in Docker containers. ### Use `-f` to specify name and...

docs.docker.com

 

sudo cp ${DEPLOYPATH}conf.d/server.${CURRENT}.conf ${DEPLOYPATH}conf.d/nginx.server.conf

# 현재 반영하고자하는 blue 컨테이너는 이미 만들어져 띄우고, 해당 명령어에서는 nginx.blue.conf파일을 nginx.server.conf로 덮어쓴다. 해당 부분은 기존에 nginx 컨테이너를 띄울때 volume 설정을 주었기 때문에 nginx 컨테이너의 밖의 설정파일로 충분히 변경할 수 있다.

docker exec front-web nginx -s reload

# nginx 컨테이너가 재기동 될 경우, 서비스가 종료되기 때문에, 설정파일만 반영하도록하는 명령어 reload를 사용하기 위해

컨테이너안에서 명령어를 실행하도록 하는 도커 명령어(docker exec)를 사용한다.

if [ -n "$EXIST_ALTER" ]; then

# 조건문안의 -n은 -z와 반대로 문자열의 길이가 0이 아니면 true 이다.

즉, blue 컨테이너가 실행되어있지 않은 상태에서 green 컨테이너가 실행되어있다면, 해당 구문을 실행할 것이다.

echo "${DOCKER_APP_NAME}-${ALTER} down start"

docker compose -p ${DOCKER_APP_NAME}-${ALTER} -f ${DEPLOYPATH}prod/script/prod/docker-compose.${ALTER}.yml down

# green 컨테이너를 종료한다.

docker image tag ${DOCKER_APP_NAME}-${ALTER} ${DOCKER_APP_NAME}-back:$DATE # 이미지 백업

docker rmi -f ${DOCKER_APP_NAME}-${ALTER}

# docker-compose 설정파일 내에 연결된 이미지는 Dockerfile로 이미지가 만들어지는데, up될때마다 생성이 되어 이 부분에 대한 관리를 어떻게 할지 결정이 필요한 부분이다. 해당 스크립트의 경우에는 기존의 이미지를 다른 이름 또는 tag로 분리(docker images tag )하여 저장하고 기존의 파일을 제거(docker rmi )하는 형식으로 구현했다.

fi # if 조건 문 종료

exit 0 # 쉘스크립트 종료를 알리며 성공의 0을 반환한다.

[전체소스]

DOCKER_APP_NAME="front"
DEPLOYPATH="/home/ec2-user/deploy/"
CURRENT="blue"
ALTER="green"

EXIST_CURRENT=$(docker compose -p ${DOCKER_APP_NAME}-${CURRENT} -f ${DEPLOYPATH}prod/script/prod/docker-compose.${CURRENT}.yml ps  --status=running | grep ${DOCKER_APP_NAME}-${CURRENT}) 
EXIST_ALTER=$(docker compose -p ${DOCKER_APP_NAME}-${ALTER} -f ${DEPLOYPATH}prod/script/prod/docker-compose.${ALTER}.yml ps  --status=running | grep ${DOCKER_APP_NAME}-${ALTER}) 
DATE=$(date '+%Y%m%d%H%M%S') #배포날짜변수 
echo "DATE : $DATE"

if [ -z "$EXIST_CURRENT" ]; then #blue가 없을 때
    echo "$EXIST_CURRENT up start"   
    docker compose -p ${DOCKER_APP_NAME}-${CURRENT} -f ${DEPLOYPATH}prod/script/prod/docker-compose.${CURRENT}.yml up -d
    sudo cp ${DEPLOYPATH}conf.d/server.${CURRENT}.conf ${DEPLOYPATH}conf.d/nginx.server.conf
    docker exec front-web nginx -s reload
    if [ -n "$EXIST_ALTER" ]; then # green이 떠있을때 
        echo "${DOCKER_APP_NAME}-${ALTER} down start"
        docker compose -p ${DOCKER_APP_NAME}-${ALTER} -f ${DEPLOYPATH}prod/script/prod/docker-compose.${ALTER}.yml down
        docker image tag ${DOCKER_APP_NAME}-${ALTER} ${DOCKER_APP_NAME}-back:$DATE # 이미지 백업 
        docker rmi -f ${DOCKER_APP_NAME}-${ALTER}
        echo "${DOCKER_APP_NAME}-${ALTER} down end"
    fi
    echo "$EXIST_CURRENT up end" 
else 
    echo "$EXIST_ALTER up start"
    docker compose -p ${DOCKER_APP_NAME}-${ALTER} -f ${DEPLOYPATH}prod/script/prod/docker-compose.${ALTER}.yml up -d
    sudo cp ${DEPLOYPATH}conf.d/server.${ALTER}.conf ${DEPLOYPATH}conf.d/nginx.server.conf
    docker exec front-web nginx -s reload
    echo "${DOCKER_APP_NAME}-${CURRENT} down start"
    docker compose -p ${DOCKER_APP_NAME}-${CURRENT} -f ${DEPLOYPATH}prod/script/prod/docker-compose.${CURRENT}.yml down
    docker image tag ${DOCKER_APP_NAME}-${CURRENT} ${DOCKER_APP_NAME}-back:$DATE # 이미지 백업 
    docker rmi -f ${DOCKER_APP_NAME}-${CURRENT}
    echo "${DOCKER_APP_NAME}-${CURRENT} down end"
    echo "$EXIST_ALTER up end"
fi 
 echo "deploy successfully finished" 

exit 0
 

🔆 Dev, Opration 환경에 따른 CI/CD 설정

 

 Multibranch pipeline item에서 Configure을 선택해주세요.

 

 

Branch Sources > behaviours에서 filter by name(with wildcards)을 다음과 같이 설정해주세요.

 

Include main 브랜치와 develop라는 단어가 포함된 브랜치의 브랜치에서 변경(change)이 발생했을 때 jenkins job이 해당 브랜치마다 돌아가게 됩니다. (*은 wildcard 키워드로 어느 단어가 와도 된다는 뜻. develop*은 develop-1, develop-3 등의 브랜치에서 변경이 발생하면 jenkins job이 만들어집니다.)

** Exclude는 Include와 반대로 무시할 브랜치 이름을 작성해줄 수 있습니다. 

 

branch sources를 설정했다면 저장해줍니다.

 

Develop server(EC2) 설정(Jenkins, EC2인바운드)

이전에 Jenkins의 publish over ssh 라는 플러그인을 사용해서 Operation server(EC2)로 프로젝트 폴더들을 옮겼던 것과 마찬가지로 Develop server(EC2)에도 프로젝트 폴더들을 옮겨줘야 합니다. 

 

Dashboard > Jenkins 관리 > 시스템 설정 에서 Publish Over SSH라는 옵션을 찾고 다음과 같이 설정합니다.

  • SSH Servers에서 ‘추가’를 눌러 SSH Server를 추가합니다.

Hostname - 연결할 EC2의 퍼블릭 IPv4 주소를 작성해주세요.

Username - 연결할 EC2의 username을 작성. Amazon Linux 2이므로 username이 ec2-user로 고정입니다.

Remote Directory - EC2내에 프로젝트 파일을 옮길 위치를 지정해줄 수 있다. 위 설정에서 /home/ec2-user/deploy 라는 경로로 프로젝트 파일을 넘겨주겠다는 의미입니다.  EC2내에 해당 경로의 폴더가 반드시 존재해야 합니다.(저절로 생기지 않음)

 

 

  • EC2에 접속해서 해당 폴더(deploy 폴더)를 만들어줍니다.

 

Develop server 구축을 위한 script 작성

Develop server를 띄우기 위해 Develop 배포를 위한 shell script와 docker compose 설정 파일을 새로 작성해야 합니다.

* docker-compose.dev.yml (develop 브랜치에 대한 docker 이미지를 만들고 컨테이너를 실행할 수 있는 설정 파일)

 

version: '3'

services:
  next-app:
    image: nextjs
    container_name: nextjs-dev
    build:
      context: ./
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - 3000:3000

 

container_name - 실행하는 컨테이너 이름

build - 빌드 관련 설정

context - build context를 지정. build context란 해당 경로에 있는 파일들의 집합. docker build 시 context 경로를 참조.

dockerfile - context 기준 dockerfile의 상대 경로.

  - restart  - 플랫폼이 컨테이너 종료 때 적용할 정책을 정의. 참고

  - ports - 컨테이너 포트를 노출하는 옵션이다. A:B 의 의미는 A번 포트로 접근을 하면 B번 포트와 매핑한다는 의미. (포트 포워딩)

 

* deploy_dev.sh 추가

Develop server에서 실행중인 nextjs-dev라는 컨테이너를 종료(down)하고 새로 빌드해서 실행(up)하는 shell script입니다.

 

#!/bin/bash

DOCKER_APP_NAME=nextjs
ROOT_DIR=/home/ec2-user/deploy

echo "dev down"
# ${ROOT_DIR}/docker-compose.dev.yml 설정파일로 실행한 컨테이너를 종료(down)하고 그 이미지(--rmi all)를 삭제한다.
docker compose -p ${DOCKER_APP_NAME}-dev -f ${ROOT_DIR}/docker-compose.dev.yml down --rmi all

echo "dev up"
# ${ROOT_DIR}/docker-compose.dev.yml 설정파일로 nextjs-dev라는 컨테이너를 실행한다.(up)
docker compose -p ${DOCKER_APP_NAME}-dev -f ${ROOT_DIR}/docker-compose.dev.yml up -d

exit 0

 

* Jenkinsfile 수정

이전에는 Operation server에만 프로젝트 파일을 넘겨주었다면 이제는 Develop server에도 넘겨줘야 합니다.

여기서  develop 브랜치에서 push나 merge가 발생했을 때는 Develop server로 보내줘야 하고,

main 브랜치에서 push나 merge가 발생했을 때는 Operation server로 보내줘야 합니다.

브랜치마다 다른 stage를 밟고 싶다면 when이라는 directive를 사용하면 됩니다.

 

stage('something branch stage') {
	when {
// 오직 multibranch Pipeline 프로젝트에서만 작동한다.
		branch 'something'
  }
}
pipeline {
    agent any
    tools {
        nodejs 'Nodejs'
    }
    parameters {
        choice(name:'VERSION', choices:['1.0', '1.1', '1.2'], description:'Choose the version of the project')
        booleanParam(name :'executeTests', description:'Execute the tests', defaultValue:false)
    }
    stages {
	// Something; Build, Test, etc.
	// develop브랜치인 경우 develop-server로 프로젝트 파일을 보내준다.
        stage('Transfer to develop server') {
            when {
                branch 'develop'
            }
            steps([$class: "BapSshPromotionPublisherPlugin"]) {
                script {
                    sshPublisher(
                        publishers: [
                            sshPublisherDesc(
	// 아까 '시스템 설정' 에서 Publish Over SSH > SSH server의 name으로 설정한 것)
                                configName: "develop-server",
                                verbose: true,
                                transfers: [
                                    sshTransfer(
                                        sourceFiles: "**/**",
                                        excludes: "**/node_modules/**,.git/",
                                        execCommand: "chmod +x /home/ec2-user/deploy/scripts/deploy_dev.sh; chmod +x /home/ec2-user/deploy/scripts/build_n_run.sh;"
                                    )
                                ]
                            )
                        ]
                    )
                }
            }
        }
	// main브랜치인 경우 operation-server로 프로젝트 파일을 보내준다.
        stage('Transfer to operation server') {
            when {
                branch 'main'
            }
            steps([$class: "BapSshPromotionPublisherPlugin"]) {
                script {
                    sshPublisher(
                        publishers: [
                            sshPublisherDesc(
                                configName: "operation-server",
                                verbose: true,
                                transfers: [
                                    sshTransfer(
                                        sourceFiles: "**/**",
                                        excludes: "**/node_modules/**,.git/",
                                        execCommand: "chmod +x /home/ec2-user/deploy/scripts/deploy.sh; chmod +x /home/ec2-user/deploy/scripts/build_n_run.sh;"
                                    )
                                ]
                            )
                        ]
                    )
                }
            }
        }
	// develop브랜치인 경우 develop-server에서 nginx docker container를 실행한다.
	// develop-server에서 build_n_run.sh 실행
        stage("Build and Run nginx for develop") {
            when {
                branch 'develop'
            }
            steps([$class: "BapSshPromotionPublisherPlugin"]) {
                script {
                    sshPublisher(
                        publishers: [
                            sshPublisherDesc(
                                configName: "develop-server",
                                verbose: true,
                                transfers: [
                                    sshTransfer(
                                        execCommand: "/home/ec2-user/deploy/scripts/build_n_run.sh"
                                    )
                                ]
                            )
                        ]
                    )
                }
            }
        }
	// main브랜치인 경우 operation-server에서 nginx docker container를 실행한다.
	// operation-server에서 build_n_run.sh 실행
        stage("Build and Run nginx for operation") {
            when {
                branch 'main'
            }
            steps([$class: "BapSshPromotionPublisherPlugin"]) {
                script {
                    sshPublisher(
                        publishers: [
                            sshPublisherDesc(
                                configName: "operation-server",
                                verbose: true,
                                transfers: [
                                    sshTransfer(
                                        execCommand: "/home/ec2-user/deploy/scripts/build_n_run.sh"
                                    )
                                ]
                            )
                        ]
                    )
                }
            }
        }
	// develop브랜치인 경우 develop-server에 배포한다.
	// develop-server에서 deploy_dev.sh 실행
        stage("Deploy for develop") {
            when {
                branch 'develop'
            }
            steps([$class: "BapSshPromotionPublisherPlugin"]) {
                script {
                    sshPublisher(
                        publishers: [
                            sshPublisherDesc(
                                configName: "develop-server",
                                verbose: true,
                                transfers: [
                                    sshTransfer(
                                        execCommand: "/home/ec2-user/deploy/scripts/deploy_dev.sh"
                                    )
                                ]
                            )
                        ]
                    )
                }
            }
        }
	// main브랜치인 경우 operation-server에 배포한다.
	// operation-server에서 deploy.sh 실행
        stage("Deploy for operation") {
            when {
                branch 'main'
            }
            steps([$class: "BapSshPromotionPublisherPlugin"]) {
                script {
                    sshPublisher(
                        publishers: [
                            sshPublisherDesc(
                                configName: "operation-server",
                                verbose: true,
                                transfers: [
                                    sshTransfer(
                                        execCommand: "/home/ec2-user/deploy/scripts/deploy.sh"
                                    )
                                ]
                            )
                        ]
                    )
                }
            }
        }
    }
}

 

* nginx.conf 수정

Operation server에서는 Blue/Green 배포방식을 사용하고 있으며 Develop server는 무중단 배포는 하지 않으려고 합니다.

Operation과 Develop 모두 nginx로 proxy 설정을 하려고 하는데, 하나의 nginx.conf 에서 두개의 upstream으로 보내는 방법은

server 블럭안에 server_name 옵션을 이용하면 가능합니다.

 

server_name 옵션에 적은 IP 주소 (혹은 DNS 주소)와 nginx로 들어온 request header의 HOST 필드가

일치하면 proxy_pass에 설정한 upstream으로 요청을 보냅니다.

예를 들어, 주소창에 운영EC2퍼블릭IPv4주소 으로 들어온다면 해당 주소와 일치하는 server_name이 있는 server 블록에서 proxy_pass에 적인 upstream으로 요청이 가게 됩니다.

 

Operation server

운영EC2퍼블릭IPv4주소 입력 → 운영EC2퍼블릭IPv4주소 와 일치하는 server_name이 있는 server 블록 → 그 server 블록 안에 있는 proxy_pass에 적힌 upstream 즉, http://frondend로 요청을 보냄

 

Develop server

개발EC2퍼블릭IPv4주소 입력 → 개발EC2퍼블릭IPv4주소 와 일치하는 server_name이 있는 server 블록 → 그 server 블록 안에 있는 proxy_pass에 적힌 upstream 즉, http://dev-frondend로 요청을 보냄

 

 user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {                     
    worker_connections  1024;
}                            

http {
    default_type  application/octet-stream;
    upstream frontend {
        server host.docker.internal:8090 max_fails=3 fail_timeout=30s;
        server host.docker.internal:8095 max_fails=3 fail_timeout=30s;
    }
    upstream dev-frontend {
        server host.docker.internal:3000 max_fails=3 fail_timeout=30s;
    }    
    server {
        listen 80;
        listen [::]:80;

        server_name 운영EC2퍼블릭IPv4주소;

        location / {
		    proxy_http_version 1.1;
            proxy_pass <http://frontend>;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $http_host;

            proxy_connect_timeout 600;
            proxy_send_timeout 600;
            proxy_read_timeout 600;
            send_timeout 600;
        }
    }
    server {
        listen 80;
        listen [::]:80;

        server_name 개발EC2퍼블릭IPv4주소;

        location / {
		    proxy_http_version 1.1;
            proxy_pass <http://dev-frontend>;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $http_host;

            proxy_connect_timeout 600;
            proxy_send_timeout 600;
            proxy_read_timeout 600;
            send_timeout 600;
        }
    }

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;
                                                
    sendfile        on;                                                                         
    keepalive_timeout  65;                                                                      
}

Troubleshooting

server_name 관련 에러 :  conflicting server name

nginx.conf를 설정하면서 두개의 server 블록에 동일한 server_name을 실수로 작성했었습니다.

해결방법은 간단한데, nginx.conf에서 동일하게 작성한 server_name 다르게 바꿔주면 됩니다.

참고 : https://webisfree.com/2017-07-04/nginx-웹서버-config-설정시-conflicting-server-name-에러-발생하는-경우

 

 

🔆 자동배포 결과 Discord로 연동하기

자동배포를 하는 중간, 젠킨스 빌드에서 에러가 난다면?

일단 젠킨스 자체에서 제공하는 console 로그를 확인 할 수 있지만 만약 다른 일을 하고 있는 중이라면 쉽게 알아 차리기 어렵기때문에 디스코드에 알림 연동을 해보고자 합니다.

Jenkins plugin - discord notifier

https://plugins.jenkins.io/discord-notifier/

 

Discord Notifier

Discord Notifier allows you to send Discord embeds about your builds via Discord's webhooks.

plugins.jenkins.io

 

디스코드 채널에서 서버 설정 > 연동 > 웹후크 > 새 웹후크에서 다음과 같이 추가 해 주신후,

Jenkinsfile 내에 원하는 부분에 아래형식으로 추가해주세요 !

 

discordSend description: "전달 sub message",
footer: "last message", //링크시 젠킨스로 이동 연결
link: env.BUILD_URL,
result: currentBuild.currentResult,
title: "테스트 젠킨스 title",
webhookURL: "{webhook주소}"

 

다음과 같이 빌드 결과에 따라 알림 메시지가 오는 것을 확인 할 수 있습니다.