요약
개발환경은 Node.js 기반의 NestJS를 사용하며, AWS EC2 인스턴스에서 작업하고 있다. 이 프로젝트에서는 Jenkins와 Docker를 활용하여 CI/CD 환경을 구축하였다.
- AWS EC2 인스턴스에 접속한 후 Docker를 설치
- EC2의 메모리 스왑을 설정하여 여유 메모리를 확보
- Docker 그룹을 추가하여 권한 문제를 해결
- Jenkins를 Docker 이미지로 패키지하여 컨테이너를 실행
- Jenkins 컨테이너 내부에 Docker를 설치하여 Docker In Docker 환경을 구축
- Jenkins 컨테이너 내부에서 Node.js 프로젝트를 빌드하고 Docker 이미지를 생성
도중에 발생한 문제들로는 프리티어 성능 제약, 네트워크 설정 부족, 빌드 중 서버 다운 등이 있었으나, 이를 해결하기 위해 Dockerfile과 Jenkinsfile을 효과적으로 활용하여 문제를 극복하였다.
이 프로젝트를 통해 CI/CD 환경 구축 및 Docker 활용에 대한 경험을 쌓을 수 있었고, 아직 해결되지 않은 문제들이 있지만 지속적인 학습과 개선을 통해 프로젝트를 성공적으로 진행 중이다. 여기에 적용된 DID 방법이 최선의 방법은 아니지만 유의미한 결과물을 먼저 만들어 보는 것이 중요하다고 생각하기에 합리적이라고 판단했다.
전체적인 빌드 / 배포 과정
많은 블로그와 아티클에서 Java를 이용한 기술스택이 많았다. 대부분의 과정은 비슷하지만, Java를 이용 시 컴파일 빌드 파일인 jar을 생성해 주자.
나는 nodejs 기반 nestjs를 사용하였다. 전체적인 개발환경부터 빌드/배포 환경의 흐름을 위의 그림으로 정리했다.
AWS Ec2 서비스를 사용하였는데 아무래도 프리티어의 성능 제약 때문에 인스턴스에 Jenkins와 도커를 설치하여 실행 시 빌드되는 과정에서 서버가 계속 다운되는 현상을 경험하여, Docker로 Jenkins 이미지 패키지를 다운로드하여 Jenkins를 실행하는 Docker Container를 만들어 사용하였다. AWS Ec2 설정과 Jenkins 설정은 지난 포스트를 참고하자.
Docker 설치 및 설정
Ec2 인스턴스 ssh 연결을 통해 원격으로 접속을 성공했다면, Docker를 설치해 주자. Docker 공식문서에 운영체제별 설치방법이 아주 친절히 설명되어 있다. 차근차근 진행하다 보면 성공적으로 설치를 완료할 수 있다. 나는 Ubuntu 운영체제를 선택하였다.
## 안전하게 진행하기위해 운영체제에 다운되어 있을 수 있는 파일 삭제하기.
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove $pkg; done
# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Add the repository to Apt sources:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
## 도커 설치
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
## 설치 확인
sudo docker run hello-world
위와 같이 "Hello from Docker!"가 출력됐다면 성공이니 기뻐하자.
Jenkins를 시작하기 전에 AWS Ec2 프리티어로 인스턴스를 생성 시 사용할 수 있는 RAM 1GB는 빌드배포 환경에서 문제가 될 확률이 매우 높다. 따라서 Ec2 메모리 스왑을 통해 디스크 공간을 메모리로 대체하여 여유 메모리를 확보하고 시작하자. 자세한 설명은 공식문서를 참고하자. 매우 쉽게 설정할 수 있다.
설정을 마치고, free -m 명령어를 사용하면 위와 같이 Swap 메모리가 할당된 것을 확인할 수 있다.
이어서, 터미널에서 root권한을 도커라는 그룹을 추가하여 부여해주자. 공식문서에 따르면, Docker는 기본적으로 Unix 소켓에 바인딩되어 동작하며, 이 소켓의 소유주는 기본적으로 root 사용자이다. 일반 사용자는 sudo를 사용하여만 이 소켓에 액세스할 수 있고, Docker 데몬은 항상 root 사용자로 실행된다고 한다.
sudo를 사용하지 않고도 docker 명령어를 사용하고 싶다면, docker라는 Unix 그룹을 만들어 사용자를 추가할 수 있다. Docker 데몬이 시작되면 docker 그룹의 멤버들이 액세스할 수 있는 Unix 소켓이 생성된다. 더 자세한 설명은 공식문서를 참고하고 자신의 상황에 맞게 설정하자.
## 도커그룹 생성
sudo groupadd docker
## 사용자를 도커그룹에 추가
sudo usermod -aG docker $USER
## 바로 적용하기
newgrp docker
## sudo 없이 docker 사용해보기
docker run hello-world
여기까지 설정을 마친다면, 권한문제가 해결이 된다. 하지만 진행 중 "/var/run/docker.sock의 permission denied 발생하는 경우" 아래의 설정을 한번더 해주자. 모든 사용자가 파일에 접근할수도있도록 권한을 부여하는 것이기때문에 보안상 권장되지는 않는다.
## 모든사용자에게 권한부여
sudo chmod 666 /var/run/docker.sock
## 도커 로그인하기
docker login
Jenkins 설치 및 설정
도커 설정을 완료했다면, Jenkins를 설치하고 실행할 순서이다. 저번 포스팅을 참고하자. 이미지 패키지를 사용하여 jenkins를 실행하는 컨테이너를 만들어 jenkins에 접속할 수 있게한다. 이 상태로 dockerfile을 사용한 빌드를 진행하면 아래와 같이 docker를 사용할수 없다라는 오류메시지와 함께 실패하게 된다.
그 이유는 현재 docker를 사용한 별개의 컨테이너 안에서 Jenkins를 실행중이고, 그렇다는 건 Jenkins에서는 docker를 찾을수 없게 되는 것이다. 따라서 Jenkins를 실행중인 컨테이너 내부에 도커 설치가 필요하다. 이것을 Docker In Docker, DID라고 한다.
Tip) 이해가 잘 되지 않으면, 포스팅 첫번째 그림을 참고하자.
차근 차근 진행해보자, 우선 복습차원에서 Jenkins 실행 컨테이너를 만들어보자.
docker run \
--name cicd \
-p 8080:8080 \
-e TZ=Asia/Seoul \
-v /home/jenkins:/var/jenkins_home \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /usr/bin/docker:/usr/bin/docker \
-u root \
-d \
--restart unless-stopped \
jenkins/jenkins:lts
docker run: Docker 컨테이너를 실행하는 명령어
--name (이름지정): 컨테이너에 이름을 부여하는 옵션으로, 여기서는 "cicd"로 지정되었습니다.
-p 8080:8080: 호스트와 컨테이너 간의 포트 매핑을 설정하는 옵션으로, Jenkins 웹 UI에 접근하기 위한 포트 8080을 호스트에, Jenkins 에이전트와 통신하기 위한 포트 8080을 호스트에 매핑합니다.
-e TZ=Asia/Seoul: 환경 변수를 설정하는 옵션으로, 컨테이너 내부의 시간대를 한국 시간대로 설정합니다.
-v /home/jenkins:/var/jenkins_home: 호스트와 컨테이너 간의 볼륨 매핑을 설정하는 옵션으로, Jenkins의 데이터를 저장하는 데 사용되는 디렉토리를 호스트의 /home/jenkins와 컨테이너의 /var/jenkins_home 간에 매핑합니다.
-v /var/run/docker.sock:/var/run/docker.sock: Docker 소켓을 컨테이너 내부로 매핑하는 옵션으로, 컨테이너 내부에서 호스트의 Docker 데몬과 통신할 수 있게 합니다.
-v /usr/bin/docker:/usr/bin/docker: Docker 실행 파일을 매핑하는 옵션으로, 컨테이너 내부에서 호스트의 Docker 실행 파일을 사용할 수 있게 합니다.
-u root: 컨테이너 내부에서 사용할 사용자를 지정하는 옵션으로, 여기서는 root 사용자로 지정되었습니다.
-d: 컨테이너를 백그라운드 모드로 실행하는 옵션으로, 컨테이너가 백그라운드에서 실행됩니다.
--restart unless-stopped: 컨테이너가 종료될 때 자동으로 다시 시작되도록 설정하는 옵션으로, "unless-stopped"는 명시적으로 중지되지 않은 한 재시작하는 것을 의미합니다.
jenkins/jenkins:lts: 사용할 Docker 이미지를 지정하는 부분으로, 여기서는 Jenkins의 LTS (Long-Term Support) 버전 이미지를 사용합니다.
docker ps 명령어를 통해 실행중인 컨테이너를 확인할 수있다. 옵션의 설명을 잘 알아보고 환경에 맞게 설정하면된다.
Tip) 만약 지정한 포트가 사용중이라는 에러가 발생시, sudo lsof -i :8080, sudo kill -9 [PID] 를 해보자.
## cicd 컨테이너 내부로 진입
docker exec -it cicd bash
## 실제 jenkins 파일이 생성되는 위치
cd /var/jenkins_home/workspace/
여기서 중요한 것은 실제 jenkins를 이용하여 빌드 배포를 진행 할때 jenkins 컨테이너 내부에서 위의 위치에 파일들이 생성된다.
이제 컨테이너 내부에서 한번 더 도커를 설치해주자. 방법은 위에서 한 것과 똑같다.
## 컨테이너 내부로 진입
docker exec -it jenkins bash
## jenkins 컨테이너 안에 docker 설치
apt-get update && \
apt-get -y install apt-transport-https \
ca-certificates \
curl \
gnupg2 \
zip \
unzip \
software-properties-common && \
curl -fsSL https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg > /tmp/dkey; apt-key add /tmp/dkey && \
add-apt-repository \
"deb [arch=amd64] https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") \
$(lsb_release -cs) \
stable" && \
apt-get update && \
apt-get -y install docker-ce
- apt-get update: 패키지 목록을 업데이트합니다.
- apt-get -y install apt-transport-https ca-certificates curl gnupg2 zip unzip software-properties-common: 필요한 패키지를 설치합니다. 이 패키지들은 HTTPS 통신 및 소프트웨어 소스를 추가하는 데 필요합니다.
- curl -fsSL https://download.docker.com/linux/$(. /etc/os-release; echo "$ID")/gpg > /tmp/dkey: Docker의 GPG 키를 다운로드합니다. 여기서는 호스트의 운영 체제 식별자를 사용하여 해당 GPG 키를 가져옵니다.
- apt-key add /tmp/dkey: 다운로드한 GPG 키를 시스템에 추가합니다.
- add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/$(. /etc/os-release; echo "$ID") $(lsb_release -cs) stable": Docker 소프트웨어의 소스를 시스템 소스 목록에 추가합니다. 여기서도 호스트의 운영 체제 정보를 사용하여 Docker 소프트웨어의 저장소를 추가합니다.
- apt-get update: 패키지 목록을 다시 업데이트합니다.
- apt-get -y install docker-ce: Docker Community Edition을 설치합니다. -y 플래그는 설치 도중 나오는 확인 프롬프트에 자동으로 "yes"를 응답하도록 합니다.
docker --version을 통해 잘 설치되었는지 확인하자.
본인의 인스턴스 ip주소:8080 을 통해 jenkins를 접속 후 새로운 item에서 Pipeline을 선택 해 주자.
Tip) 잊지 말아야 할 것은, Ec2 인바운드 규칙 설정을 통하여 접속하는 포트를 열어놔야한다.
GitHub project를 선택하고, 본인의 프로젝트 repository주소를 설정한다.
리포지토리와 Jenkins를 깃헙 hook을 통하여 main branch에 소스코드가 업데이트 되면 자동으로 빌드를 실행하도록 해주었다.
위와 같이 설정해준다. 여기서 Credentials은 자신의 리포지토리가 pubilc이라면 굳이 설정할 필요가 없다.
branch가 main으로 되어있기 때문에 /main으로 변경했다. 본인의 브랜치명을 확인하자.
Jenkins Pipeline 구성
지금까지 고생 많았다. 이제 모든 설정은 끝났고, 위에서 설정한 것을 실행 시켜줄 스크립트 파일 작성만이 남았다.
나는 Dockerfile과 Jenkinsfile을 이용하여 빌드와 배포 구성을 하였다. Jenkinsfile은 각각의 단계 설정을 해주고, Dockerfile은 도커를 이용한 빌드를 설정해준다. 작성법은 환경에 따라 자유롭게 변경 할 수 있으니 공식문서를 참고하자.
첫 시도에는 Dockerfile 안에서 RUN npm install 및 build 과정을 시도하였지만, 계속된 타임아웃 오류와 네트워크 오류를 경험하였다. 오랜시간을 들여 파악한 문제가능성은
- 첫번째로, docker in docker 환경설정에서 네트워크 설정부분이 필요한 구성으로 세팅되어있지 않아서 오류가 발생하는 것,
- 두번째로는 프리티어 성능의 한계로 서버가 죽는 것 두가지였다.
실제로 빌드를 실행하고 htop명령어를 실행 하여 cpu와 memory자원을 모니터링 해보았는데 cpu가 한계치를 넘어서는 순간이 발생하고 중지되는 것을 확인하였다. 네트워크 설정 부분은 아직 정확한 구성 방법을 찾아내지 못하였다. (도커 전문 책도 구매하였다...방법을 찾으면 다시 공유하겠다)
고민의 끝에서 생각한 방법은, Dockerfile내에서 소스 코드 빌드과정이 오류가 나니까, 그 부분을 Jenkinsfile 빌드 스테이지로 빼내서 실행 후 만들어진 빌드파일을 도커라이징하는 방법을 시도하였다. 결과적으로 성공하였다.
Tip) Jenkinsfile의 agent 부분에서 docker를 사용하기위해서 Jenkins plugin에서 Docker Pipeline 플러그인 설치가 필요하다.
pipeline {
agent {
docker {
image 'node:18.16.0'
}
}
stages {
stage('Checkout') {
steps {
git branch: 'main',
url: 'https://github.com/your project'
}
post {
success {
echo 'Successfully Cloned Repository'
}
failure {
echo 'Fail Cloned Repository'
}
}
}
stage('Test') {
steps {
echo '테스트 단계와 관련된 몇 가지 단계를 수행합니다.'
}
}
stage('Build') {
steps {
sh 'npm install'
sh 'npm run build'
}
}
stage('Docker Clear') {
steps {
script {
echo 'Docker Rm Start, docker 컨테이너가 현재 돌아갈시 실행해야함'
def containerId = sh(script: 'docker ps -q -f "name=docker-jenkins-pipeline-test"', returnStdout: true).trim()
if (containerId) {
sh "docker stop $containerId"
sh "docker rm $containerId"
sh "docker rmi -f dockerhub-id/docker-jenkins-pipeline-test"
} else {
echo 'No such container: docker-jenkins-pipeline-test'
}
}
}
post {
success {
echo 'Docker Clear Success'
}
failure {
echo 'Docker Clear Fail'
}
}
}
stage('Dockerizing') {
steps {
sh 'echo "Image Build Start"'
sh 'docker build . -t dockerhub-id/docker-jenkins-pipeline-test'
}
post {
success {
echo 'Build Docker Image Success'
}
failure {
echo 'Build Docker Image Fail'
}
}
}
stage('Deploy') {
steps {
sh 'docker run --name docker-jenkins-pipeline-test -d -p 8083:5002 dockerhub-id/docker-jenkins-pipeline-test'
}
post {
success {
echo 'Deploy success'
}
failure {
echo 'Deploy failed'
}
}
}
}
}
마무리
docker를 사용하여 CI/CD환경을 구성하면서, 도커의 강력함을 몸소 체험 할 수 있었다. 완벽히 해결하지 못한 오류들도 있지만 이번 프로젝트를 진행 하면서 개발부터 인프라 운영 과정까지 조금 더 이해의 폭을 넓힐수 있어서 좋았다.