스터디/AEWS

[AEWS] 8주차 Jenkins를 통한 CI/CD 구성하기

안녕유지 2025. 3. 30. 05:02
Cloudnet AEWS 8주차 스터디를 진행하며 정리한 글입니다.

 

이번 포스팅에서는 Jenkins에 대해 알아보고 Jenkins를 통한 CI/CD 구성하는 방법에 대해 소개하겠습니다.

 

 

목표 구성

 

Jenkins

Jenkins는 소프트웨어 개발 과정에서 지속적인 통합(Continuous Integration) 과 지속적인 배포(Continuous Delivery) 를 지원하기 위해 널리 사용되는 오픈소스입니다.

파이프라인을 통한 개발 주기 단축 및 자동화 (빌드, 배포, 테스트 등)를 위해 사용합니다. 

 

Jenkins 특징

  • 플러그인 기반 구조
    • 여러 플러그인 통해 개발, 테스트, 배포 도구와 통합 가능 
  • 파이프라인 기능
    • Jenkinsfile이라는 선언형 DSL을 이용해 빌드, 테스트, 배포 등 작업 흐름을 코드로 정의 가능
  • 웹 기반 UI
    • 사용자가 웹 브라우저 통해 손쉽게 Job 생성하고 관리할 수 있는 UI 제공

Jenkins 작업 소개

작업 소개 (프로젝트, Job, Item) : 3가지 유형의 지시 사항 포함

  1. 작업을 수행하는 시점 Trigger
    • 작업 수행 태스크 task가 언제 시작될지를 지시
  2. 작업을 구성하는 단계별 태스크 Built step
    • 특정 목표를 수행하기 위한 태스크를 단계별 step로 구성할 수 있다.
    • 이것을 젠킨스에서는 빌드 스텝 build step이라고 부른다.
  3. 태스크가 완료 후 수행할 명령 Post-build action
    • 예를 들어 작업의 결과(성공 or 실패)를 사용자에게 알려주는 후속 동작이나, 자바 코드를 컴파일한 후 생성된 클래스 파일을 특정 위치로 복사 등
  • (참고) 젠킨스의 빌드 : 젠킨스 작업의 특정 실행 버전
    • 사용자는 젠킨스 작업을 여러번 실행할 수 있는데, 실행될 때마다 고유 빌드 번호가 부여된다.
    • 작업 실행 중에 생성된 아티팩트, 콘솔 로드 등 특정 실행 버전과 관련된 모든 세부 정보가 해당 빌드 번호로 저장된다.

 

Jenkins 실습 환경 구성

# kind 클러스터 설치 
❯ brew install kind

# 유용한 도구 사전 설치
❯ brew install kubernetes-cli
❯ brew install helm
❯ brew install krew
❯ brew install kube-ps1
❯ brew install kubectx

❯ cat <<EOT > docker-compose.yaml
services:

  jenkins:
    container_name: jenkins
    image: jenkins/jenkins
    restart: unless-stopped
    networks:
      - cicd-network
    ports:
      - "8080:8080"
      - "50000:50000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - jenkins_home:/var/jenkins_home

  gogs:
    container_name: gogs
    image: gogs/gogs
    restart: unless-stopped
    networks:
      - cicd-network
    ports:
      - "10022:22"
      - "3000:3000"
    volumes:
      - gogs-data:/data

volumes:
  jenkins_home:
  gogs-data:

networks:
  cicd-network:
    driver: bridge
EOT


# 배포
❯ docker compose up -d
❯ docker compose ps
NAME      IMAGE             COMMAND                  SERVICE   CREATED         STATUS                                     PORTS
gogs      gogs/gogs         "/app/gogs/docker/st…"   gogs      2 seconds ago   Up Less than a second (health: starting)   0.0.0.0:3000->3000/tcp, :::3000->3000/tcp, 0.0.0.0:10022->22/tcp, :::10022->22/tcp
jenkins   jenkins/jenkins   "/usr/bin/tini -- /u…"   jenkins   2 seconds ago   Up Less than a second                      0.0.0.0:8080->8080/tcp, :::8080->8080/tcp, 0.0.0.0:50000->50000/tcp, :::50000->50000/tcp

# 기본 정보 확인
for i in gogs jenkins ; do echo ">> container : $i <<"; docker compose exec $i sh -c "whoami && pwd"; echo; done
>> container : gogs <<
root
/app/gogs

>> container : jenkins <<
jenkins
/

# 초기 계정 정보 확인
❯ docker compose exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
47d5dbcf2de64db3a3db2be247d26fb9

 

admin, 위에서 확인한 패스워드로 로컬에서 접근한 뒤 권장 플러그인을 설치한 뒤, admin 유저를 생성합니다.

admin/qwe123로 유저를 생성한 뒤, 내 로컬 주소 192.168.219.103로 아이피를 등록하였습니다. 

 

 

 

Gogs 설치

토큰 생성 (fd816432cf3fb1e9f6ecb60c70162c5d55ac047c)

)

 

❯ TOKEN=fd816432cf3fb1e9f6ecb60c70162c5d55ac047c
❯ MyIP=192.168.219.103
❯ git clone http://devops:$TOKEN@$MyIP:3000/yoo/dev-app.git                                                                                                               

Cloning into 'dev-app'...
remote: Enumerating objects: 4, done.
remote: Counting objects: 100% (4/4), done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 4 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
Unpacking objects: 100% (4/4), 691 bytes | 230.00 KiB/s, done.


❯ git config --local user.name "yoo"
❯ git config --local user.email "yoo@test.com"
❯ git config --local init.defaultBranch main
❯ git config --local credential.helper store
❯ git --no-pager config --local --list
❯ cat .git/config
core.repositoryformatversion=0
core.filemode=true
core.bare=false
core.logallrefupdates=true
core.ignorecase=true
core.precomposeunicode=true
remote.origin.url=http://devops:fd816432cf3fb1e9f6ecb60c70162c5d55ac047c@192.168.219.103:3000/yoo/dev-app.git
remote.origin.fetch=+refs/heads/*:refs/remotes/origin/*
branch.main.remote=origin
branch.main.merge=refs/heads/main
branch.main.vscode-merge-base=origin/main
user.name=yoo
user.email=yoo@test.com
init.defaultbranch=main
credential.helper=store
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
        ignorecase = true
        precomposeunicode = true
[remote "origin"]
        url = http://devops:fd816432cf3fb1e9f6ecb60c70162c5d55ac047c@192.168.219.103:3000/yoo/dev-app.git
        fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
        remote = origin
        merge = refs/heads/main
        vscode-merge-base = origin/main
[user]
        name = yoo
        email = yoo@test.com
[init]
        defaultBranch = main
[credential]
        helper = store
        
        
❯ git --no-pager branch                                                                                                                                        
❯ git remote -v
* main
origin  http://devops:fd816432cf3fb1e9f6ecb60c70162c5d55ac047c@192.168.219.103:3000/yoo/dev-app.git (fetch)
origin  http://devops:fd816432cf3fb1e9f6ecb60c70162c5d55ac047c@192.168.219.103:3000/yoo/dev-app.git (push)

# server.py 파일 작성
cat > server.py <<EOF
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from datetime import datetime
import socket

class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        match self.path:
            case '/':
                now = datetime.now()
                hostname = socket.gethostname()
                response_string = now.strftime("The time is %-I:%M:%S %p, VERSION 0.0.1\n")
                response_string += f"Server hostname: {hostname}\n"                
                self.respond_with(200, response_string)
            case '/healthz':
                self.respond_with(200, "Healthy")
            case _:
                self.respond_with(404, "Not Found")

    def respond_with(self, status_code: int, content: str) -> None:
        self.send_response(status_code)
        self.send_header('Content-type', 'text/plain')
        self.end_headers()
        self.wfile.write(bytes(content, "utf-8")) 

def startServer():
    try:
        server = ThreadingHTTPServer(('', 80), RequestHandler)
        print("Listening on " + ":".join(map(str, server.server_address)))
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()

if __name__== "__main__":
    startServer()
EOF


❯ python3 server.py
❯ curl localhost                                               
The time is 2:09:33 AM, VERSION 0.0.1
Server hostname: yujiyeon-ui-MacBookPro.local

# Dockerfile 생성
cat > Dockerfile <<EOF
FROM python:3.12
ENV PYTHONUNBUFFERED 1
COPY . /app
WORKDIR /app 
CMD python3 server.py
EOF

# 버전 파일 생성
echo "0.0.1" > VERSION


❯ tree                                                                                                                                                       
.
├── Dockerfile
├── README.md
├── VERSION
└── server.py

❯ git status                                                                                                                                                  
On branch main
Your branch is up to date with 'origin/main'.

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        Dockerfile
        VERSION
        server.py

nothing added to commit but untracked files present (use "git add" to track)

❯ git add . 
❯ git commit -m "Add dev-app"                                                                                                                                
[main bc171d0] Add dev-app
 3 files changed, 40 insertions(+)
 create mode 100644 Dockerfile
 create mode 100644 VERSION
 create mode 100644 server.py
 
❯ git push -u origin main                                                                                                                                     
Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 8 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (5/5), 1016 bytes | 1016.00 KiB/s, done.
Total 5 (delta 0), reused 0 (delta 0), pack-reused 0
To http://192.168.219.103:3000/yoo/dev-app.git
   67549eb..bc171d0  main -> main
branch 'main' set up to track 'origin/main'.

 

 

이미지 저장소로 Dockerhub를 사용하겠습니다.

 

 

 

Kind 클러스터 설치

❯ docker ps                                                    02:09:33
CONTAINER ID   IMAGE             COMMAND                  CREATED          STATUS                    PORTS                                                                                      NAMES
4e11b05e125b   jenkins/jenkins   "/usr/bin/tini -- /u…"   43 minutes ago   Up 23 minutes             0.0.0.0:8080->8080/tcp, :::8080->8080/tcp, 0.0.0.0:50000->50000/tcp, :::50000->50000/tcp   jenkins
2f04fce07f2b   gogs/gogs         "/app/gogs/docker/st…"   43 minutes ago   Up 43 minutes (healthy)   0.0.0.0:3000->3000/tcp, :::3000->3000/tcp, 0.0.0.0:10022->22/tcp, :::10022->22/tcp         gogs

❯ cat kind-3node.yaml                                          02:20:09
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
networking:
  apiServerAddress: "192.168.219.103"
nodes:
- role: control-plane
  extraPortMappings:
  - containerPort: 30000
    hostPort: 30000
  - containerPort: 30001
    hostPort: 30001
  - containerPort: 30002
    hostPort: 30002
  - containerPort: 30003
    hostPort: 30003
- role: worker
- role: worker

 

Jenkins 플러그인 설치

1. Pipeline stage view

2. Docker pipeline

3. Gogs Webhook plugin

 

 

 

 

유저 이름을 devops가 아닌 yoo로 바꾸어서 에러가 발생하여 올바른 주소로 변경하였습니다. 

pipeline {
    agent any
    environment {
        DOCKER_IMAGE = 'hellouz818/dev-app' // Docker 이미지 이름
    }
    stages {
        stage('Checkout') {
            steps {
                 git branch: 'main',
                 url: 'http://192.168.219.103:3000/yoo/dev-app.git',  // Git에서 코드 체크아웃
                 credentialsId: 'gogs-crd'  // Credentials ID
            }
        }
        stage('Read VERSION') {
            steps {
                script {
                    // VERSION 파일 읽기
                    def version = readFile('VERSION').trim()
                    echo "Version found: ${version}"
                    // 환경 변수 설정
                    env.DOCKER_TAG = version
                }
            }
        }
        stage('Docker Build and Push') {
            steps {
                script {
                    docker.withRegistry('https://index.docker.io/v1/', 'dockerhub-crd') {
                        // DOCKER_TAG 사용
                        def appImage = docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}")
                        appImage.push()
                        appImage.push("latest")
                    }
                }
            }
        }
    }
    post {
        success {
            echo "Docker image ${DOCKER_IMAGE}:${DOCKER_TAG} has been built and pushed successfully!"
        }
        failure {
            echo "Pipeline failed. Please check the logs."
        }
    }
}

 

 

이미지 풀백 에러가 발생했습니다.

❯ kubectl get deploy,pod -o wide
NAME                         READY   UP-TO-DATE   AVAILABLE   AGE   CONTAINERS             IMAGES                               SELECTOR
deployment.apps/timeserver   0/2     2            0           19s   timeserver-container   docker.io/hellouz818/dev-app:0.0.1   pod=timeserver-pod

NAME                            READY   STATUS             RESTARTS   AGE   IP           NODE            NOMINATED NODE   READINESS GATES
pod/timeserver-58468b68-jt89b   0/1     ErrImagePull       0          19s   10.244.1.2   myk8s-worker2   <none>           <none>
pod/timeserver-58468b68-wfgls   0/1     ImagePullBackOff   0          19s   10.244.2.2   myk8s-worker    <none>           <none>

 

 

❯ kubectl get secret -A                           
NAMESPACE     NAME                     TYPE                            DATA   AGE
kube-system   bootstrap-token-abcdef   bootstrap.kubernetes.io/token   6      14m

❯ kubectl describe pod
Events:
  Type     Reason     Age                 From               Message
  ----     ------     ----                ----               -------
  Normal   Scheduled  2m8s                default-scheduler  Successfully assigned default/timeserver-58468b68-wfgls to myk8s-worker
  Normal   Pulling    32s (x4 over 2m7s)  kubelet            Pulling image "docker.io/hellouz818/dev-app:0.0.1"
  Warning  Failed     29s (x4 over 2m5s)  kubelet            Failed to pull image "docker.io/hellouz818/dev-app:0.0.1": failed to pull and unpack image "docker.io/hellouz818/dev-app:0.0.1": failed to resolve reference "docker.io/hellouz818/dev-app:0.0.1": pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed
  Warning  Failed     29s (x4 over 2m5s)  kubelet            Error: ErrImagePull
  Normal   BackOff    2s (x7 over 2m4s)   kubelet            Back-off pulling image "docker.io/hellouz818/dev-app:0.0.1"
  Warning  Failed     2s (x7 over 2m4s)   kubelet            Error: ImagePullBackOff

 

 

❯ kubectl create secret docker-registry dockerhub-secret \
  --docker-server=https://index.docker.io/v1/ \
  --docker-username=$DHUSER \
  --docker-password=$DHPASS
secret/dockerhub-secret created

❯ kubectl get secrets -o yaml            
apiVersion: v1
items:
- apiVersion: v1
  data:
    .dockerconfigjson: eyJhdXRocyI6eyJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8iOnsidXNlcm5hbWUiOiJoZWxsb3V6ODE4IiwicGFzc3dvcmQiOiJkY2tyX3BhdF9pOHhydDlWVm0xaDFKVVdWRVNkOTZpWFMzWnciLCJhdXRoIjoiYUdWc2JHOTFlamd4T0Rwa1kydHlYM0JoZEY5cE9IaHlkRGxXVm0weGFERktWVmRXUlZOa09UWnBXRk16V25jPSJ9fX0=
  kind: Secret
  metadata:
    creationTimestamp: "2025-03-29T19:58:19Z"
    name: dockerhub-secret
    namespace: default
    resourceVersion: "2059"
    uid: 1a9d5026-3edb-46c5-81c9-1b2758f5670b
  type: kubernetes.io/dockerconfigjson
kind: List
metadata:
  resourceVersion: ""
  
Every 2.0s: kubectl get deploy,rs,pod -o wide          yujiyeon-ui-MacBookPro.local: 05:01:05
                                                                                in 0.088s (0)
NAME                         READY   UP-TO-DATE   AVAILABLE   AGE    CONTAINERS             I
MAGES                               SELECTOR
deployment.apps/timeserver   2/2     2            2           6m6s   timeserver-container   d
ocker.io/hellouz818/dev-app:0.0.1   pod=timeserver-pod

NAME                                    DESIRED   CURRENT   READY   AGE    CONTAINERS
     IMAGES                               SELECTOR
replicaset.apps/timeserver-545b8cf698   2         2         2       67s    timeserver-contain
er   docker.io/hellouz818/dev-app:0.0.1   pod=timeserver-pod,pod-template-hash=545b8cf698
replicaset.apps/timeserver-58468b68     0         0         0       6m6s   timeserver-contain
er   docker.io/hellouz818/dev-app:0.0.1   pod=timeserver-pod,pod-template-hash=58468b68

NAME                              READY   STATUS    RESTARTS   AGE   IP           NODE
     NOMINATED NODE   READINESS GATES
pod/timeserver-545b8cf698-dhffl   1/1     Running   0          40s   10.244.1.3   myk8s-worke
r2   <none>           <none>
pod/timeserver-545b8cf698-s6jj5   1/1     Running   0          67s   10.244.2.3   myk8s-worke
r    <none>           <none>