1편에서 Jenkins를 구축하는 것까지 해보았으니, 2편에서는 본격적으로 Jenkins에서 'Item / Script / Plugin / Credential' 을 작성해보면서 CI/CD를 구축해보자.
1. Jenkins의 Item
Jenkins에서 하나의 CI/CD 프로젝트를 구축하기 위해서는 Item을 생성해야 한다.
Freestyle
- GUI로 쉽게 작업을 설정할 수 있다.
- 단일 빌드 작업에 많이 사용된다.
Pipeline
- 하나의 스크립트 파일(Jenkinsfile)을 통해 파이프라인을 코드로 정의할 수 있다. (버전 관리 가능)
- 빌드, 테스트, 배포 등 여러 단계를 하나의 스크립트 파일로 쉽게 정의하여 작업에 대한 흐름을 파악하기 쉽다.
- FreeStyle과 다르게 스크립트를 작성해야 하는 번거로움이 있다. (Groovy 문법 학습 필요)
Freestyle 방식은 간단한 프로젝트나 단일 빌드 작업에 적합하고,
Pipeline 방식은 복잡한 CI/CD 프로세스와 Jenkinsfile을 통해 파이프라인의 버전 관리가 필요할 때 적합하다.
현재 프로젝트의 경우에는 전체 동작 흐름을 하나의 스크립트에서 한 눈에 파악할 수 있고, 이후 복잡한 프로세스 또한 바로 적용할 수 있도록 하기 위해 `Pipeline`으로 item을 생성하도록 한다.
2. Pipeline Script 작성
이제 본격적으로 Pipeline의 스크립트를 작성해야 한다.
Pipeline script을 작성하는 방식과 문법에 대한 정리는 넘어가려 했으나,
현재 파이프라인에서 적용하고 있는 기능들이여서 제대로 짚고 넘어가야 한다고 생각해 글을 적었다..!
1) Script 방식
Pipeline script (default)
: Jenkins의 기본 파이프라인 스크립트 작성 방법이다.
- Jenkins UI 내에서 파이프라인 스크립트를 직접 작성하고 수정할 수 있다.
- 변경 사항을 빠르게 테스트할 수 있다.
- 파이프라인 스크립트가 Jenkins 서버에 직접 저장되어 버전 관리가 불가능하다.
Pipeline script from SCM
: 소스 코드 관리 시스템(SCM)에서 Jenkinsfile을 가져와 파이프라인을 정의하는 방법이다.
- Git, SVN 저장소에서 Jenkinsfile을 불러올 수 있다.
- SCM에 저장되어 있어, 파이프라인 스크립트의 변경 이력을 추적할 수 있다.
2) Script 문법
Pipeline Script는 Groovy 문법 기반으로 2가지 타입으로 작성이 가능하다.
참고로 두 문법을 혼용해서 사용할 수 없다.
Declarative Pipeline
간단하고 구조화된 문법으로, CI/CD 프로세스를 쉽게 설정할 수 있다. (최상단에 pipeline 키워드 사용)
Scripted Pipeline
Declarative보다 Groovy 언어의 다양한 기능을 사용할 수 있어 복잡한 프로세스를 쉽게 구현할 수 있다. (최상단에 node 키워드 사용)
3) Script 작성
스크립트가 매우 길어, 전체 스크립트를 먼저 보여주고 각 부분 스크립트를 설명하는 것이 나을 것 같다..!
플로우는 다음과 같이 진행된다.
0. 환경 변수 설정 > 1. 레포지토리 클론 > 2. 프로젝트 빌드 > 3. 도커 이미지 빌드 > 4. 도커 이미지 푸시
> 5. 서버에서 애플리케이션 실행 > 6. 후처리 작업
pipeline {
agent any
environment {
// 크레덴셜 설정
IMAGE_STORAGE_CREDENTIAL = "docker_credential"
BITBUCKET_CREDENTIAL = "bitbucket_credential"
AWS_ACCESS_KEY_CREDENTIAL = "aws_access_key"
SSH_CREDENTIAL = "ssh_credential"
// 도커 이미지 설정
IMAGE_NAME = "-"
IMAGE_STORAGE = "index.docker.io/v1/"
IMAGE_REPOSITORY = "wchos"
TAG_VERSION = "1.0"
// Git 설정
GIT_URL = "-"
GIT_BRANCH = "develop"
// 배포 설정
SSH_CONNECTION = "-"
}
stages {
// 1. 프로젝트를 클론하는 작업을 수행한다.
stage("Repository Clone") {
steps {
// git branch 이름과, git repo URL, private repo일 경우 자격 증명을 추가한다.
git branch: "${env.GIT_BRANCH}", credentialsId: "${env.BITBUCKET_CREDENTIAL}", url: "${env.GIT_URL}"
}
}
// 2. 프로젝트를 빌드하는 작업을 수행한다.
stage("Clean Build") {
steps {
// gradlew 스크립트를 실행할 수 있도록 gradlew 파일에 실행 권한을 부여하고, gradle wrapper를 사용하여 프로젝트를 클린 빌드하고 jar 파일을 생성한다.
sh "chmod +x gradlew"
sh "./gradlew clean bootjar"
}
}
// 3. 프로젝트 내의 Dockerfile로 도커 이미지를 빌드하는 작업을 수행한다.
// Dockerfile을 미리 정의하고 있음 → 추후 설명 | Dockerfile: 일정한 JDK Version에서 .jar 파일을 실행하는 스크립트
stage("Build Container Image") {
steps {
script {
// `myrepo/myapp` 이름의 도커 이미지 생성
image = docker.build("${IMAGE_REPOSITORY}/${IMAGE_NAME}")
}
}
}
// 4. 빌드된 도커 이미지를 Docker registry(여기서는 도커 허브)에 push 하는 작업을 수행한다.
stage("Push Container Image") {
steps {
script {
// Docker registry URL을 설정하고, registry에 로그인하기 위한 자격 증명을 포함한다.
// 이미지 버전 관리를 위해, 2가지 태그로 이미지를 푸시한다.
docker.withRegistry("https://${IMAGE_STORAGE}", "${env.IMAGE_STORAGE_CREDENTIAL}") {
image.push("${env.TAG_VERSION}-${env.BUILD_NUMBER}") // 참고로 env.BUILD_NUMBER는 젠킨스가 제공하는 기본 환경 변수이다.
image.push("latest")
}
}
}
}
// 5. AWS에서 EC2 인스턴스에서 애플리케이션을 실행하는 작업을 수행한다.
stage("Server Run") {
steps {
withCredentials([
[$class: 'AmazonWebServicesCredentialsBinding', credentialsId: "${env.AWS_ACCESS_KEY_CREDENTIAL}"]
]) {
// SSH 자격 증명을 통해 EC2 인스턴스에 접속
sshagent(["${env.SSH_CREDENTIAL}"]) {
sh """
ssh -o StrictHostKeyChecking=no ubuntu@${env.SSH_CONNECTION} << 'EOF'
docker pull ${IMAGE_REPOSITORY}/${IMAGE_NAME}
CONTAINER_NAME="project"
if [ \$(docker ps -a -f name=\$CONTAINER_NAME) ]; then
docker stop \$CONTAINER_NAME
docker rm \$CONTAINER_NAME
fi
docker run -d --name \$CONTAINER_NAME \
-e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \
-e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
${IMAGE_REPOSITORY}/${IMAGE_NAME}
"""
}
}
}
}
}
// 빌드 이후 성공, 실패 여부에 따른 작업 실행 정의
post {
always {
script {
// 클린업 작업 실행
sh '''
docker image prune -a -f
docker buildx prune -f
docker system prune -f
'''
}
}
}
}
0. 환경 변수 설정
- 프로젝트에 접근 및 실행하기 위한 여러 자격 증명과 설정 정보를 환경 변수로 정의한다.
(도커 허브에 이미지를 push, pull / 프로젝트 Repo에 접근 / 프로젝트에서 사용되는 AWS Secret Manager / 실행할 서버 SSH 연결 / 슬랙 빌드 성공 여부)
environment {
// 크레덴셜 설정
IMAGE_STORAGE_CREDENTIAL = "docker_credential"
BITBUCKET_CREDENTIAL = "bitbucket_credential"
AWS_ACCESS_KEY_CREDENTIAL = "aws_credential"
SSH_CREDENTIAL = "ssh_credential"
// 도커 이미지 설정
IMAGE_STORAGE = "index.docker.io/v1/"
IMAGE_REPOSITORY = "wchos"
IMAGE_NAME = "-"
TAG_VERSION = "1.0"
// Git 설정
GIT_URL = "-"
GIT_BRANCH = "develop"
// 배포 설정
SSH_CONNECTION = "-"
}
1. 레포지토리 클론
지정된 Git repo URL과 Branch에서 프로젝트 레포지토리를 클론한다. (private repo일 경우 credential 사용)
stages {
// 1. 프로젝트를 클론하는 작업을 수행한다.
stage("Repository Clone") {
steps {
git branch: "${env.GIT_BRANCH}", credentialsId: "${env.BITBUCKET_CREDENTIAL}", url: "${env.GIT_URL}"
}
}
2. 프로젝트 빌드
gradlew 파일에 실행 권한을 부여하고, Gradle을 사용하여 프로젝트를 클린 빌드하고 jar 파일을 생성한다.
// 2. 프로젝트를 빌드하는 작업을 수행한다.
stage("Clean Build") {
steps {
sh "chmod +x gradlew"
sh "./gradlew clean bootjar"
}
}
3. 도커 이미지 빌드
프로젝트 내의 Dockerfile을 사용하여 도커 이미지를 빌드한다.
빌드된 이미지는 지정된 레포지토리와 이름으로 저장되는데 이는 도커 허브에 저장될 레포와 이름이다.
// 3. 프로젝트 내의 Dockerfile로 도커 이미지를 빌드하는 작업을 수행한다.
stage("Build Container Image") {
steps {
script {
// `myrepo/myapp` 이름의 도커 이미지 생성
image = docker.build("${IMAGE_REPOSITORY}/${IMAGE_NAME}")
}
}
}
+. Dockerfile
Dockerfile은 일정한 JDK Version에서 .jar 파일을 실행시키기 위한 스크립트로 다음 예시와 같이 작성하면 된다.
4. 도커 이미지 푸시
Dockerfile로 생성된 도커 이미지를 도커 레지스트리 중 하나인 Docker Hub에 접속하여 푸시한다.
이미지 버전 관리를 위해, 이미지 태그는 latest와 현재 빌드 번호를 푸시한다.
// 4. 빌드된 도커 이미지를 Docker registry(여기서는 도커 허브)에 push 하는 작업을 수행한다.
stage("Push Container Image") {
steps {
script {
docker.withRegistry("https://${IMAGE_STORAGE}", "${env.IMAGE_STORAGE_CREDENTIAL}") {
image.push("${env.TAG_VERSION}-${env.BUILD_NUMBER}") // 참고로 env.BUILD_NUMBER는 젠킨스가 제공하는 기본 환경 변수이다.
image.push("latest")
}
}
}
}
5. 서버에서 애플리케이션 실행
배포를 진행하고자 하는 AWS EC2 인스턴스에 SSH로 접속하여 최신 도커 이미지를 Pull 한다.
그리고 기존 컨테이너를 중지하고 삭제, 새로운 도커 컨테이너를 실행한다.
이 과정에서 실행되는 도커 컨테이너의 프로젝트에서 AWS Secret Manager를 사용해서 주요 키 값을 관리하는 경우, 해당 AWS Credential을 환경 변수로 전달해주어야 한다.
// 5. AWS에서 EC2 인스턴스에서 애플리케이션을 실행하는 작업을 수행한다.
stage("Server Run") {
steps {
withCredentials([
[$class: 'AmazonWebServicesCredentialsBinding', credentialsId: "${env.AWS_ACCESS_KEY_CREDENTIAL}"]
]) {
// SSH 자격 증명을 통해 EC2 인스턴스에 접속
sshagent(["${env.SSH_CREDENTIAL}"]) {
sh """
ssh -o StrictHostKeyChecking=no ubuntu@${env.SSH_CONNECTION} << 'EOF'
docker pull ${IMAGE_REPOSITORY}/${IMAGE_NAME}
CONTAINER_NAME="project"
if [ \$(docker ps -a -f name=\$CONTAINER_NAME) ]; then
docker stop \$CONTAINER_NAME
docker rm \$CONTAINER_NAME
fi
docker run -d --name \$CONTAINER_NAME \
-e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \
-e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
${IMAGE_REPOSITORY}/${IMAGE_NAME}
"""
}
}
}
}
6. 후처리 작업
모든 stage / 빌드가 완료된 후, 사용하지 않는 도커 이미지와 캐시 및 도커 리소스를 삭제한다.
// 빌드 이후 성공, 실패 여부에 따른 작업 실행 정의
post {
always {
script {
// 클린업 작업 실행
sh '''
docker image prune -a -f
docker buildx prune -f
docker system prune -f
'''
}
}
}
4) env 환경 변수를 어떻게 접근할까?
스크립트에서 environment 블록 내에 정의된 변수들을, ${env.GIT_URL}, ${env.SSH_CREDENTIAL} 와 같이 접근을 한다.
하지만 보통은 단순히 environment 블록 내의 정의된 변수들을 그대로 사용하지만, 어떤 변수들은 Jenkins Credential Store에서 추가로 검색을 진행하고 해당 값들을 사용하기도 한다.
파이프라인 스크립트를 작성하는데, 문득 궁금증이 들어 찾아보게 되었다.
- 환경 변수 접근
Jenkins 파이프라인에서 environment 블록 내에 정의된 변수들은 ${env.VARIABLE_NAME} 형식으로 접근할 수 있다.
즉, GIT_URL이나 SSH_CREDENTIAL과 같은 변수를 ${env.GIT_URL}처럼 사용할 수 있다.
- 크레덴셜 변수 접근
Jenkins에서 크레덴셜을 사용할 때는 일반 변수와 다르게 withCredentials 블록을 사용하여 Jenkins Credential Store에서 안전하게 값을 가져올 수 있다. 또한 간편하게 withCredentials를 사용하지 않고 credentialsId를 직접 지정하여, Store에서 인증 정보를 자동으로 가져와 사용할 수 있다.
git branch: "${env.GIT_BRANCH}", credentialsId: "${env.BITBUCKET_CREDENTIAL}", url: "${env.GIT_URL}"
withCredentials([usernamePassword(credentialsId: 'my-credentials-id', usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) {
// USERNAME과 PASSWORD를 사용할 수 있는 코드
}
추가로 플러그인 함수에서 해당 기능을 제공할 수 있다. (docker.withRegistry, sshagent())
3. 마주친 이슈
1) docker: not found
stage("Build Container Image") {
steps {
script {
image = docker.build("${IMAGE_REPOSITORY}/${IMAGE_NAME}")
}
}
}
젠킨스에서 docker 명령어를 실행하려 하지만, docker 명령어를 찾지 못할 때 나타나는 에러다.
도커 이미지를 빌드하는 stage에서 `docker.build()`를 사용하는데 docker 명령어를 찾지 못한 것이다.
구글에 검색을 해 보면 정말 자주 접하는 에러인 듯 하나, 단편적인 해결 방안들을 소개하는 곳들이 많아 해결하는데 꽤 애를 먹었지만 도커의 기본적인 아키텍처와 리눅스 쉘 명령어를 더 알아갈 수 있는 시간이었다.👍
1. 컨테이너를 -v/var/run/docker.sock:/var/run/docker.sock 실행
docker.sock 파일은 Docker 클라이언트와 Docker daemon 간의 통신을 담당한다.
클라이언트가 docker 명령어를 실행하면, 해당 파일을 통해 Docker daemon으로 전달된다.
docker run -d --name jenkins -p 8080:8080 -v /var/run/docker.sock:/var/run/docker.sock jenkins/jenkins
2. 호스트와 컨테이너 내부의 docker.sock 파일 권한을 동일하게 설정
호스트의 /var/run/docker.sock 의 상세 정보를 다음과 같다.
컨테이너로 마운트 된 /var/run/docker.sock 의 상세 정보는 다음과 같다.
호스트 docker.sock의 소유자는 root로, docker 그룹의 사용자만이 이 소켓에 접근할 수 있다.
따라서 docker 명령어를 실행하려면 해당 사용자 계정이 docker 그룹에 포함되어야 한다.
하지만 컨테이너 내부에는 docker 그룹이 없어, 호스트의 그룹 아이디 988이 그대로 나온다.
컨테이너의 jenkins 유저는 root도 아니고, 988 그룹에 속해있지도 않기 때문에 docker 명령어를 사용할 수 없는 것이다.
따라서 컨테이너 내부에 988 아이디로 docker 그룹을 만들고, jenkins 유저를 docker 그룹에 추가하면서 젠킨스 job에서 `docker` 명령어를 사용할 수 있도록 한다.
// 컨테이너 내부에서 GID(Group ID)가 988인 docker 그룹을 생성한다.
groupadd -g 988 docker
// docker 그룹에 jenkins 사용자를 추가
usermod -aG docker jenkins
3. 컨테이너 내부에 docker cli 설치
호스트의 `/var/run/docker.sock` 파일과 컨테이너의 `/var/run/docker.sock` 파일의 권한이 동일하게끔까지 맞춰줬는데, 여전히 docker 명령어를 실행할 수 없는 것을 확인할 수 있다. 위의 작업은 컨테이너에서 호스트의 Docker 소켓에 접근할 수 있도록 한 것이고, docker 명령어를 이해하고 실행할 수 있는 Docker CLI의 설치가 별도로 필요하다
(1) 패키지 목록 업데이트
apt-get update
(2) Docker 패키지를 설치하기 위해 Docker의 공식 저장소 추가
(패키지 설치 관련 명령어가 다를 수 있음, 아래는 Debian 기반의 명령어)
apt-get install -y apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add -
add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable"
(3) 패키지 목록 다시 업데이트
apt-get update
(4) Docker CLI 설치
apt-get install -y docker-ce-cli
2) SSH Connection Error
EC2 인스턴스와 SSH 연결을 하는 스크립트 작성에서 만나고 해결하는 과정을 기록했다.
2-1) Credentials 'ssh_credential' is of type 'SSH Username with private key' where 'com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials' was expected
Credential에 등록한 `ssh_credential`의 타입이 SSH Username with private key 으로 등록되어 있는데,
스크립트에서는 StandardUsernamePasswordCredentials 타입으로 인지하고 처리하는 과정에서 에러가 발생한 것이다.
이를 해결하기 위해서는, 적절하게 Username with password 타입으로 `ssh_credential`을 다시 등록해주면 된다.
stage("Server Run") {
steps {
withCredentials([
usernamePassword(credentialsId: "${env.SSH_CREDENTIAL}", usernameVariable: 'SSH_USERNAME', passwordVariable: 'SSH_PASSWORD')
]) {
script {
sh """
sshpass -p ${SSH_PASSWORD} \\
ssh -t -o StrictHostKeyChecking=no ubuntu@${env.SSH_CONNECTION} \\
${env.DEPLOY_LOCATION}/deploy.sh'
"""
}
}
}
}
2-2) ssh agent 플러그인
첫번째 방법에서 credential 타입만 수정해서 진행해도 된다. 하지만 이 방법은 usernamePassword를 사용해 SSH 자격증명을 가져와서 쉘 내에서 직접 SSH 명령어 스크립트에 SSH-key-pair을 전달되어 실행한다. 이는 로그에 포함되거나 하는 등의 혹시 모를 방법으로 유출될 수 있기에 Jenkins에서의 SSH 플러그인을 찾아 사용하게 됐다.
참고로, EC2 인스턴스 내에 `deploy.sh`을 미리 정의해야 한다.
(지금 프로젝트에서는 docker hub에서 image를 가져와 실행하므로, 이전에 Github Actions에서 `.jar` 파일 전송을 위한 scp 스크립트는 불필요하다.)
https://plugins.jenkins.io/ssh-agent/
stage("Server Run") {
steps {
sshagent(["${env.SSH_CREDENTIAL}"]) {
sh """
ssh -o StrictHostKeyChecking=no ubuntu@${env.SSH_CONNECTION} \\
'${env.DEPLOY_LOCATION}/deploy.sh'
"""
}
}
}
참고문헌
1) 전반적인 플로우 설명
https://seongwon.dev/DevOps/20220717-CICD%EA%B5%AC%EC%B6%95%EA%B8%B02/
https://hyeinisfree.tistory.com/23
https://jijs.tistory.com/entry/docker%EB%A1%9C-%EC%A0%A0%ED%82%A8%EC%8A%A4-%EC%84%A4%EC%B9%98
2) DinD, DooD
https://medium.com/dawn-cau/dind-dood-1af72851224b
3) Docker(Project) in Docker(Jenkins)
https://itnext.io/docker-in-docker-521958d34efd
https://postlude.github.io/2020/12/26/docker-in-docker/
4) Docker image 빌드 시 발생하는 이슈
https://froggydisk.github.io/21th-post/
'DevOps > CI-CD' 카테고리의 다른 글
Nginx 무중단 배포 (0) | 2024.09.19 |
---|---|
Jenkins CI/CD 구축하기 1편 (with. Docker) (0) | 2024.09.03 |
Github Actions CI/CD 프로젝트 적용기 (1) | 2024.06.08 |
SSH (Secure SHell) 이란? (1) | 2024.06.04 |