들어가며

스터디 시간이 다시 돌아왔습니다. ^^ 이번 스터디는 3주차의 다소 짧은 스터디로 CI/CD에 관련해서 진행됩니다. 즐거운 연말 다시 한번 과제로 달려보겠습니다. :laughing:

이번 주에는 Jenkins와 Git, Docker를 이용하여 CI/CD 파이프라인을 구축해보겠습니다.

Jenkins CI/CD + Docker

CI/CD란?

img.png CI/CD 파이프라인 (출처:Dev Genius)

  • CI/CD는 Continuous Integration(지속적 통합)과 Continuous Deployment(지속적 배포)의 약자로 소프트웨어 개발의 계획단계에서 부터 배포/운영까지 전 과정에 걸쳐 자동화된 프로세스를 통해 소프트웨어를 빠르게, 안정적으로 배포할 수 있도록 하는 방법론입니다.
  • CI와 CD로 나눠서 살펴보겠습니다.
    • CI : 여러 개발자들이 작성한 코드를 하나로 통합하는 코드의 통합을 지속적으로 진행하는 것을 의미합니다.
      • CI의 단계 : 계획 -> 코딩 -> 빌드 -> 테스트 -> 패키징
    • CD : CI를 통해 빌드된 결과물을 배포하고 운영하고, 모니터링을 통해 개선할 점을 파악하는 과정을 지속적으로 진행하는 것을 의미합니다.
      • CD의 단계 : 배포 -> 운영 -> 모니터링 -> 피드백
  • CI/CD는 위의 그림과 같이 다양한 툴들로 구성이 되며 이번주에는 Jenkins와 Git, Docker를 이용하여 CI/CD 파이프라인을 구축해보겠습니다.

컨테이너를 이용한 어플리케이션 개발

CI/CD 파이프라인을 구축하기 위해 Docker를 이용하여 어플리케이션을 컨테이너화하겠습니다.

ruby로 특정 문자열 출력하는 간단한 어플리케이션 만들기

  $ mkdir 1.1 && cd 1.1
  $ echo 'puts "Hello Docker!"' > hello.rb
  
  $ cat > Dockerfile <<EOF
  FROM ruby:3.3
  COPY hello.rb /app/
  WORKDIR /app
  CMD ["ruby", "hello.rb"]
  EOF
  
  # 이미지 빌드
  $ docker build -t hello .
  # => [+] Building 36.6s (9/9) FINISHED
  $ docker image ls -f reference=hello
  # => REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
  #    hello        latest    d0c55f1ebe18   35 seconds ago   1GB
  
  # 실행
  $ docker run hello
  # => Hello Docker!
  • 코드 수정

    # 코드 수정
    $ echo "puts 'Hello CloudNet@'" > hello.rb
      
    # 컨테이너 이미지 빌드
    $ docker build . -t hello:1
    $ docker image ls -f reference=hello
    # => REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
    #    hello        1         7fe4f428d492   3 seconds ago   1GB
    #    hello        latest    d0c55f1ebe18   3 minutes ago   1GB  
    # <span style="color: green;">👉 Latest 태그는 IMAGE ID를 통해 아직 이전의 이미지를 갖고 있는 것을 확인할 수 있습니다.</span>
      
    # 1번 태그에 추가적으로 latest 태그를 붙여보겠습니다.
    $ docker tag hello:1 hello:latest
    $ docker image ls -f reference=hello
    # => REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
    #    hello        1         7fe4f428d492   36 seconds ago   1GB
    #    hello        latest    7fe4f428d492   36 seconds ago   1GB
    # <span style="color: green;">👉 Latest 태그도 동일한 IMAGE ID를 갖게되었습니다.</span>
      
    # 컨테이너 실행
    $ docker run --rm hello:1
    # => Hello CloudNet@
    $ docker run --rm hello
    # => Hello CloudNet@
    

Compiling code in Docker

  # 코드 작성
  $ mkdir 1.2 && cd 1.2
  
  $ cat > Hello.java <<EOF
  class Hello {
      public static void main(String[] args) {
          System.out.println("Hello Docker");
      }
  }
  EOF
  
  $ cat > Dockerfile <<EOF
  FROM openjdk
  COPY . /app
  WORKDIR /app
  RUN javac Hello.java    # 컴파일
  CMD java Hello
  EOF
  
  # 컨테이너 이미지 빌드
  $ docker pull openjdk
  $ docker build . -t hello:2 -t hello:latest
  # => [+] Building 0.8s (9/9) FINISHED
  $ docker image ls -f reference=hello
  # => REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
  #    hello        2         ba37ddf45c26   10 seconds ago   487MB
  #    hello        latest    ba37ddf45c26   10 seconds ago   487MB
  #    hello        1         7fe4f428d492   9 minutes ago    1GB
  
  # 컨테이너 실행
  $ docker run --rm hello:2
  # => Hello Docker
  $ docker run --rm hello
  # => Hello Docker
  
  # 컨테이너 이미지 내부에 파일 목록을 보면 어떤가요? 꼭 필요한 파일만 있는가요? 보안적으로 어떨까요?
  $ docker run --rm hello ls -l
  # => -rw-r--r-- 1 root root  89 Dec  5 15:04 Dockerfile
  #    -rw-r--r-- 1 root root 416 Dec  5 15:05 Hello.class
  #    -rw-r--r-- 1 root root 111 Dec  5 15:04 Hello.java
  # <span style="color: green;">👉 꼭 필요한 파일 외에도 Dockerfile, *.java 파일, java 컴파일러 등이 있습니다.</span>
  # <span style="color: green;">   이 파일들은 정보 유출이나 공격 대상이 될 수 있기 때문에 컨테이너 이미지에 없어야 합니다.</span>
  
  # RUN 컴파일 시 소스코드와 java 컴파일러(javac)가 포함되어 있음. 실제 애플리케이션 실행에 필요 없음. 
  $ docker run --rm hello javac --help
  # => Usage: javac &lt;options&gt; &lt;source files&gt;
  #    where possible options include:
  #      @&lt;filename&gt;                  Read options and filenames from file
  #    ...

멀티 스테이지 빌드

  • 멀티 스테이지 빌드는 빌드를 여러 단계로 나누어서 진행하는 방법입니다.
  • 각 단계마다 필요한 환경을 구성하여 빌드를 진행하고, 최종적으로 필요한 파일만을 추출하여 불필요한 파일들이 제외된 가볍고 안전한 이미지를 생성할 수 있습니다. img.png 멀티 스테이지 빌드 동작
  # 코드 작성
  $ mkdir 1.3 && cd 1.3
  
  $ cat > Hello.java <<EOF
  class Hello {
      public static void main(String[] args) {
          System.out.println("Hello Multistage container build");
      }
  }
  EOF
  
  $ cat > Dockerfile <<EOF
  FROM openjdk:11 AS buildstage
  COPY . /app
  WORKDIR /app
  RUN javac Hello.java
  
  FROM openjdk:11-jre-slim
  COPY --from=buildstage /app/Hello.class /app/
  WORKDIR /app
  CMD java Hello
  EOF
  
  # 컨테이너 이미지 빌드 : 용량 비교 해보자!
  $ docker build . -t hello:3 -t hello:latest
  $ docker image ls -f reference=hello
  # => REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
  #    hello        3         4a058414ac44   11 seconds ago   216MB
  #    hello        latest    4a058414ac44   11 seconds ago   216MB
  #    hello        2         ba37ddf45c26   24 minutes ago   487MB
  #    ...
  # <span style="color: green;">👉 Compiler가 없는 가벼운 jre 이미지를 사용하여 컨테이너 이미지 크기도 줄어든 것을 확인할 수 있습니다.</span>
  
  # 컨테이너 실행
  $ docker run --rm hello:3
  # => Hello Multistage container build
  $ docker run --rm hello
  # => Hello Multistage container build
  
  # 컨테이너 이미지 내부에 파일 목록을 보면 어떤가요?
  $ docker run --rm hello ls -l
  # => total 4
  #    -rw-r--r-- 1 root root 436 Dec  5 15:26 Hello.class
  $ docker run --rm hello javac --help
  # => docker: Error response from daemon: OCI runtime create failed: container_linux.go:380: starting container process caused: exec: &quot;javac&quot;: executable file not found in $PATH: unknown.
  # <span style="color: green;">👉 javac가 없어서 이전보다 안전한것을 알 수 있습니다.</span>

Jib로 자바 컨테이너 빌드

문서, 관련 블로그1, 관련 블로그2

  • Jib는 Google에서 만든 오픈소스 도구로, Java 어플리케이션을 컨테이너 이미지로 빌드하는 도구입니다.
  • Jib는 docker 없이 컨테이너 이미지를 빌드할 수 있으며, 빌드 속도가 빠르고, 이미지 크기가 작아서 배포가 용이합니다.
  • Maven 또는 Gradle 플러그인으로 사용할 수 있습니다.
  • 기존의 Docker 이미지 빌드 흐름은 다음과 같습니다. img.png
  • Jib는 다음과 같이 빌드 흐름이 간소화됩니다. img.png
    • 빌드와 동시에 이미지가 만들어지고 저장소에 푸시까지 가능합니다.
    • Jenkins 등의 CI 서버에 Docker가 없어도 컨테이너 이미지를 빌드할 수 있습니다.
    • 이미지 레이어 캐싱을 통해 빌드 속도가 빠릅니다.
    • 이미지 크기가 작아서 배포가 용이합니다.

어플리케이션 서버 컨테이너화 하기

  • 데모를 위해 HTTP 웹 어플리케이션을 컨테이너화 해보겠습니다.
  • 다음은 ruby 언어로 작성한 기본적인 웹서버로, 현재 날짜와 시간을 표시합니다.
$ mkdir 1.4 && cd 1.4

$ cat > app.rb <<EOF
require 'sinatra'

get '/' do
  "Hello, World! The time is #{Time.now}"
end
EOF

$ cat > Dockerfile <<EOF
FROM ruby:3.3
RUN gem install sinatra rackup puma
COPY app.rb /app/
WORKDIR /app
CMD ["ruby", "app.rb", "-o", "0.0.0.0"]
EOF

# 컨테이너 이미지 빌드

$ docker build . -t timeserver:1 && docker tag timeserver:1 timeserver:latest
$ docker image ls -f reference=timeserver
# => REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
#    timeserver   1         6393669e5e68   12 seconds ago   1GB
#    timeserver   latest    6393669e5e68   12 seconds ago   1GB

# 컨테이너 실행
$ docker run -d -p 8080:4567 --name=timeserver timeserver

# 컨테이너 접속 및 로그 확인
$ curl http://localhost:8080
# => Hello, World! The time is 2024-10-01 15:28:18 +0000
$ docker logs timeserver
# => Puma starting in single mode...
#    * Puma version: 6.5.0 (&quot;Sky's Version&quot;)
#    == Sinatra (v4.1.1) has taken the stage on 4567 for development with backup from Puma
#    * Ruby version: ruby 3.3.6 (2024-10-01 revision 75015d4c1f) [aarch64-linux]
#    *  Min threads: 0
#    *  Max threads: 5
#    *  Environment: development
#    *          PID: 1
#    * Listening on http://0.0.0.0:4567
#    Use Ctrl-C to stop
#    172.17.0.1 - - [01/Oct/2024:15:28:07 +0000] &quot;GET / HTTP/1.1&quot; 200 51 0.0048

# 컨테이너 이미지 내부에 파일 확인
$ docker exec -it timeserver ls -l
# => total 4
#    -rw-r--r-- 1 root root 76 Dec  6 15:20 app.rb
  • 도커 컨테이너 내부의 소스코드를 수정해서 반영되는지 확인해보겠습니다.

20241205_cicd_lite_w1_5.png vscode에 docker 확장 설치

20241205_cicd_lite_w1_6.png timeserver 컨테이너 내부의 app.rb 파일 수정

# 컨테이너 이미지 내부에 app.rb 파일 수정 후 반영 확인 : VSCODE 경우 docker 확장프로그램 활용
$ docker exec -it timeserver cat app.rb
# => require 'sinatra'
#    
#    get '/' do
#      &quot;Hello, World! 😀 The time is #{Time.now}&quot;
#    end

# 컨테이너 접속 후 확인 
$ curl http://localhost:8080
# => Hello, World! The time is 2024-10-01 15:45:09 +0000%
# <span style="color: green;">👉 수정 사항이 반영되지 않았습니다!</span>

# 컨테이너 삭제
$ docker rm -f timeserver
  • 어플리케이션 수정해서 테스트
$ sed -i -e 's#World!#World! 😀 Hello CloudNeta Study!#' app.rb
$ cat app.rb
# => require 'sinatra'
#    
#    get '/' do
#      &quot;Hello, World! 😀 Hello CloudNeta Study! The time is #{Time.now}&quot;
#    end

# 컨테이너 이미지 빌드
$ docker build . -t timeserver:2 -t timeserver:latest
$ docker image ls -f reference=timeserver
# => REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
#    timeserver   2         80f230757e3c   2 seconds ago    1.01GB
#    timeserver   latest    80f230757e3c   2 seconds ago    1.01GB
#    timeserver   1         c7055ab70155   27 minutes ago   1.01GB

# 컨테이너 실행
$ docker run -d -p 8080:4567 --name=timeserver timeserver
# => 278108d26b3998c8281add75b631d59a9d44abd4eb3e4f173b0b156d66e5da75

# 컨테이너 접속 및 로그 확인
$ curl http://localhost:8080
# => Hello, World! 😀 Hello CloudNeta Study! The time is 2024-10-01 15:55:19 +0000

# 컨테이너 삭제
$ docker rm -f timeserver

로컬 개발을 위한 방안

  • 소스를 수정할 때마다 위와 같이 컨테이너 이미지를 빌드하고 실행하는 것은 번거롭습니다.
  • 이를 편리하게 하기 위해서 로컬 폴더와 컨테이너의 앱 소스를 매핑하고, 코드 내용을 동적으로 반영해보겠습니다.
$ mkdir 1.5 && cd 1.5

$ cat > app.rb <<EOF
require 'sinatra/base'

class App < Sinatra::Base
  get '/' do
    "Hello, World! The time is #{Time.now}"
  end
end
EOF

$ cat > config.ru <<EOF
require 'rack/unreloader'
require 'sinatra'
Unreloader = Rack::Unreloader.new{App}
Unreloader.require './app.rb'

run Unreloader
EOF

$ cat > Dockerfile <<EOF
FROM ruby:3.3
RUN gem install sinatra rackup puma \
    rack-unreloader # 소스코드 변경시 자동으로 반영하기위한 툴
COPY app.rb config.ru /app/
WORKDIR /app
CMD ["rackup", "--host", "0.0.0.0"]
EOF

$ cat > docker-compose.yaml <<EOF
services:
  frontend:
    build: .
    command: rackup --host 0.0.0.0
    volumes:
      - type: bind
        source: .
        target: /app
    ports:
      - "8080:9292"
EOF

# 도커 컴포즈로 컨테이너 빌드
$ docker compose build 
# 도커 컴포즈로 컨테이너 실행 
$ docker compose up -d
# => [+] Running 1/1
#     ⠿ Container 15-frontend-1  Started 0.4s

# 컴포즈로 실행 시 이미지와 컨테이너 네이밍 규칙을 알아보자!
$ docker compose ps
# => NAME                COMMAND                  SERVICE             STATUS              PORTS
#    15-frontend-1       &quot;rackup --host 0.0.0…&quot;   frontend            running             0.0.0.0:8080-&gt;9292/tcp
# <span style="color: green;">👉 컴포즈로 실행시 현재 디렉터리 이름에서 특수문자를 제외한 것에 컨테이너 이름에 "-1"를 붙이는듯 합니다.</span>

$ docker compose images
# => Container           Repository          Tag                 Image Id            Size
#    15-frontend-1       15_frontend         latest              4aa2b680f319        1.01GB

#
$ curl http://localhost:8080
# => Hello, World! The time is 2024-10-01 05:55:18 +0000!!!!!%
$ docker compose logs
# => 15-frontend-1  | Puma starting in single mode...
#    15-frontend-1  | * Puma version: 6.5.0 (&quot;Sky's Version&quot;)
#    15-frontend-1  | * Ruby version: ruby 3.3.6 (2024-10-01 revision 75015d4c1f) [aarch64-linux]
#    15-frontend-1  | *  Min threads: 0
#    15-frontend-1  | *  Max threads: 5
#    15-frontend-1  | *  Environment: development
#    15-frontend-1  | *          PID: 1
#    15-frontend-1  | * Listening on http://0.0.0.0:9292
#    15-frontend-1  | Use Ctrl-C to stop
#    15-frontend-1  | 172.23.0.1 - - [01/Oct/2024:03:58:23 +0000] &quot;GET / HTTP/1.1&quot; 200 51 0.0153
  • 소스코드 수정 후 반영 확인
$ cat app.rb
# => require 'sinatra/base'
#    
#    class App &lt; Sinatra::Base
#      get '/' do
#        &quot;Hello, World! The time is #{Time.now}&quot;
#      end
#    end

# 소스코드 수정
$ sed -i -e 's#World!#World! 😀 Hello CloudNeta Study!#' app.rb
$ cat app.rb
# => require 'sinatra/base'
#    
#    class App &lt; Sinatra::Base
#      get '/' do
#        &quot;Hello, World! 😀 Hello CloudNeta Study! The time is #{Time.now}!!!!!&quot;
#      end
#    end

$ curl http://localhost:8080
# => Hello, World! 😀 Hello CloudNeta Study! The time is 2024-10-01 05:57:05 +0000!!!!!
# <span style="color: green;">👉 변경사항이 잘 반영되었습니다. :)</span>

# 컨테이너 중지 및 삭제
$ docker compose down

CI/CD 실습환경 구성

  • Jenkins와 Gitlab을 이용하여 CI/CD 파이프라인을 구축해보겠습니다.
  • Jenkins는 Docker에 설치해서 사용하고 Gitlab은 gitlab.com를 사용하겠습니다.

Jenkins 소개

img.png

  • Jenkins는 오픈소스 CI/CD 도구로, 빌드, 테스트, 배포 등의 작업을 자동화할 수 있습니다.
  • Jenkins는 CI/CD라는 용어가 있기 전부터 사용되던 도구로, CI/CD에 국한되지 않고 다양한 작업을 자동화할 수 있습니다.
  • 주요 기능은 다음과 같습니다.
    1. 확장성 : 다양한 플러그인 생태계를 가지고 있어 기능을 확장할 수 있습니다. Git, Docker, Kubernetes 등 다양한 도구 및 플랫폼과 통합할 수 있습니다.
    2. 분산 빌드 : 분산 빌드를 지원하여 여러 머신에서 작업을 실행할 수 있습니다. 이를 통해 부하를 분산시키고 빌드 속도를 높일 수 있습니다.
    3. 자동화된 테스트 : 테스트 실행을 자동화하여 코드 품질에 대한 즉각적인 피드백을 제공합니다. 다양한 테스트 프레임워크 및 도구를 지원합니다.
    4. 코드 파이프라인 : Jenkinsfile을 사용하여 빌드, 테스트, 배포 파이프라인을 코드로 정의할 수 있습니다. 이를 통해 버전 관리와 협업이 용이합니다.
    5. 지속적 통합 및 지속적 배포 (CI/CD) : 코드 변경 사항을 통합하고, 애플리케이션을 빌드하고, 테스트를 실행하고, 배포하는 과정을 자동화합니다. 이를 통해 일관되고 신뢰할 수 있는 배포 프로세스를 보장합니다.
  • 흔히 사용되는 CI/CD 워크플로우는 다음과 같습니다.
    1. 최신 코드 가져오기 : 개발을 위해 중앙 코드 리포지터리에서 로컬 시스템으로 애플리케이션의 최신 코드를 가져
    2. 단위 테스트 구현과 실행 : 코드 작성 전 단위 테스트 케이스를 먼저 작성
    3. 코드 개발 : 실패한 테스트 케이스를 성공으로 바꾸면서 코드 개발
    4. 단위 테스트 케이스 재실행 : 단위 테스트 케이스 실행 시 통과(성공!)
    5. 코드 푸시와 병합 : 개발 소스 코드를 중앙 리포지터리로 푸시하고, 코드 병합
    6. 코드 병합 후 컴파일 : 변경 함수 코드가 병함되면 전체 애플리케이션이 컴파일된다
    7. 병합된 코드에서 테스트 실행 : 개별 테스트뿐만 아니라 전체 통합 테스트를 실행하여 문제 없는지 확인
    8. 아티팩트 배포 : 애플리케이션을 빌드하고, 애플리케이션 서버의 프로덕션 환경에 배포
    9. 배포 애플리케이션의 E-E 테스트 실행 : 셀레늄 Selenium과 같은 User Interface 자동화 도구를 통해 애플리케이션의 전체 워크플로가 정상 동작하는지 확인하는 종단간 End-to-End 테스트를 실행.
  • 이러한 워크플로우를 코드 커밋/푸시와 같은 이벤트가 발생할 때 자동으로 실행되도록 설정할 수 있습니다.
Jenkins 컨테이너에서 호스트에 도커 데몬 사용 설정
  • 컨테이너에서 도커를 사용하기 위해서는 DinD(Docker in Docker)를 사용하여 컨테이너 안에서 도커를 실행하거나 DooD(Docker outside of Docker)를 사용하여 호스트의 도커 데몬을 사용할 수 있습니다.
  • 이번에는 Docker outside of Docker를 사용하여 호스트의 도커 데몬을 사용하겠습니다. img.png DinD와 DooD 구조 비교

Jenkins 컨테이너 실행 및 설정

  • 먼저 Jenkins 컨테이너를 실행하겠습니다.
# 작업 디렉토리 생성 후 이동
$ mkdir cicd-labs && cd cicd-labs

# 
$ 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
volumes:
  jenkins_home:
networks:
  cicd-network:
    driver: bridge
EOT

# 배포
$ docker compose up -d
# => [+] Running 13/13
#     ⠿ jenkins Pulled                                                                                                                                                      13.7s
#    [+] Running 3/3
#     ⠿ Network cicd-labs_cicd-network   Created                                                                                                                             0.0s
#     ⠿ Volume &quot;cicd-labs_jenkins_home&quot;  Created                                                                                                                             0.0s
#     ⠿ Container jenkins                Started
$ docker compose ps
# => NAME                COMMAND                  SERVICE             STATUS              PORTS
#    jenkins             &quot;/usr/bin/tini -- /u…&quot;   jenkins             running             0.0.0.0:8080-&gt;8080/tcp, 0.0.0.0:50000-&gt;50000/tcp

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

# 도커를 이용하여 컨테이너로 접속
$ docker compose exec jenkins bash
$ exit
  • Jenkins 초기 설정을 진행하겠습니다.
# 초기 비밀번호 확인
$ docker compose exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
# => dcc757c8c4f14fc09795ed0440baf157

# 브라우저에서 접속하여 초기 비밀번호 입력후 설정 진행 : 계정 / 암호 입력 >> admin / qwe123
$ open http://localhost:8080
  • jenkins 컨테이너에서 호스트의 도커 데몬을 사용하기 위해 설정을 진행하겠습니다.
# jenkins 컨테이너에서 호스트의 도커 데몬을 사용하기 위한 설정
$ docker compose exec --privileged -u root jenkins bash
-----------------------------------------------------
$ id
# => uid=0(root) gid=0(root) groups=0(root)

$ curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
$ chmod a+r /etc/apt/keyrings/docker.asc
$ echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null
$ apt-get update && apt install docker-ce-cli curl tree jq -y

$ docker info
# => Client: Docker Engine - Community
#     Version:    27.3.1
#     Context:    default
#     Debug Mode: false
#     Plugins:
#      buildx: Docker Buildx (Docker Inc.)
#        Version:  v0.17.1
#        Path:     /usr/libexec/docker/cli-plugins/docker-buildx
#      compose: Docker Compose (Docker Inc.)
#        Version:  v2.29.7
#        Path:     /usr/libexec/docker/cli-plugins/docker-compose
#    
#    Server:
#     Containers: 27
#      Running: 3
#      Paused: 0
#      Stopped: 24
#     Images: 37
#     Server Version: 20.10.12
#     Storage Driver: overlay2
#      Backing Filesystem: extfs
#      Supports d_type: true
#    ...
$ docker ps
# => CONTAINER ID   IMAGE                  COMMAND                  CREATED          STATUS          PORTS                                              NAMES
#    06409fe2219f   jenkins/jenkins        &quot;/usr/bin/tini -- /u…&quot;   25 minutes ago   Up 25 minutes   0.0.0.0:8080-&gt;8080/tcp, 0.0.0.0:50000-&gt;50000/tcp   jenkins
# <span style="color: green;">👉 Docker-out-of-Docker 이기 때문에 호스트 도커 데몬에서 운영되는 컨테이너를 볼 수 있습니다!</span>
$ which docker
# => /usr/bin/docker

# Jenkins 컨테이너 내부에서 root가 아닌 jenkins 유저도 docker를 실행할 수 있도록 권한을 부여
$ groupadd -g 2000 -f docker
$ chgrp docker /var/run/docker.sock
$ chmod 770 /var/run/docker.sock
$ ls -l /var/run/docker.sock
# => srwxr-xr-x 1 root docker 0 Dec  7 03:12 /var/run/docker.sock
$ usermod -aG docker jenkins
$ cat /etc/group | grep docker
# => docker:x:2000:jenkins

$ exit
--------------------------------------------

# jenkins item 실행 시 docker 명령 실행 권한 에러 발생 : Jenkins 컨테이너 재기동으로 위 설정 내용을 Jenkins app 에도 적용 필요
$ docker compose restart jenkins
# $ sudo docker compose restart jenkins  # Windows 경우 이후부터 sudo 붙여서 실행

# jenkins user로 docker 명령 실행 확인
$ docker compose exec jenkins id
# => uid=1000(jenkins) gid=1000(jenkins) groups=1000(jenkins),2000(docker)
$ docker compose exec jenkins docker info
# => Client: Docker Engine - Community
#     Version:    27.3.1
#     Context:    default
#     ...
#    
#    Server:
#     Containers: 27
#      Running: 3
#      Paused: 0
#      Stopped: 24
#     Images: 37
#     Server Version: 20.10.12
#    ...
$ docker compose exec jenkins docker ps
# => CONTAINER ID   IMAGE                  COMMAND                  CREATED          STATUS         PORTS                                              NAMES
#    06409fe2219f   jenkins/jenkins        &quot;/usr/bin/tini -- /u…&quot;   30 minutes ago   Up 2 minutes   0.0.0.0:8080-&gt;8080/tcp, 0.0.0.0:50000-&gt;50000/tcp   jenkins
#    ...

Gitlab 소개

  • Gitlab은 Github와 유사한 Git 기반의 코드 저장소 서비스로, 코드 저장소, 이슈 트래커, CI/CD 파이프라인, 코드 검토 등의 기능을 제공합니다. Gitlab.com
  • 이번 실습에서는 코드 저장소 기능만 사용하고 Jenkins의 CI/CD 파이프라인을 사용하겠습니다.
  • 주요 기능
    • 소스 코드 관리 : GitLab은 Git 기반의 소스 코드 저장소를 제공하여 버전 관리를 쉽게 할 수 있습니다.
    • CI/CD 파이프라인 : GitLab은 CI/CD 파이프라인을 통해 코드의 빌드, 테스트, 배포를 자동화할 수 있습니다.
    • 이슈 트래킹 : 프로젝트의 버그, 기능 요청 등을 관리할 수 있는 이슈 트래킹 시스템을 제공합니다.
    • 코드 리뷰 : 병합 요청(Merge Request)을 통해 코드 리뷰를 쉽게 진행할 수 있습니다.
    • 위키 : 프로젝트 관련 문서를 작성하고 관리할 수 있는 위키 기능을 제공합니다.
    • 프로젝트 관리 : 마일스톤, 보드, 라벨 등을 통해 프로젝트를 체계적으로 관리할 수 있습니다.
    • 통합 및 확장성 : 다양한 외부 도구와의 통합 및 확장을 지원하여 유연한 개발 환경을 구축할 수 있습니다.
    • 셀프 호스트 가능 : GitLab은 오픈소스로 제공되어 무료로 자체 서버에 설치하여 사용할 수 있습니다. (일부 기능 차이가 있음)

Gitlab 프로젝트 생성 및 설정

  • Gitlab.com에서 새로운 프로젝트를 생성하겠습니다. img.png Gitlab 프로젝트 생성

  • 프로젝트 생성시 프로젝트 이름과 가시성을 설정하고 생성합니다.

    • 프로젝트 이름 : 2024-cicd-lite-w1
    • 가시성 : Private

img_1.png Jenkins와 연동을 위해 토큰 발급

  • 프로젝트 생성 후 프로젝트 설정에서 CI/CD 파이프라인을 위한 토큰을 발급받습니다.
  • 프로필 아이콘 클릭 > Preferences > Access Tokens > Add new token을 클릭하여 토큰을 발급받습니다.
    • 토큰 이름 : jenkins
    • 권한 : read_repository, write_repository

img_2.png 토큰 발급 완료

  • 토큰이 완료되면 복사할 수 있습니다. 이후에는 다시 확인할 수 없으므로 잘 기록해두어야 합니다.
Gitlab에서 소스 받기
  • 소스를 받기 위해 git 주소를 복사합니다. img.png Gitlab 프로젝트 주소 복사
  • 프로젝트 페이지에 접속 후 Code 버튼을 클릭하고 클립보드 아이콘을 클릭하여 주소를 복사할 수 있습니다.
  • 복사한 주소로 소스를 받아서 필요한 파일들을 생성해보겠습니다.
# <span style="color: green;">👉 아이디와 비밀번호를 물으면 토큰이름과 발급받은 토큰을 입력하시면 됩니다.</span>
$ git clone https://gitlab.com/littlebird/2024-cicd-lite-w1.git
# => Cloning into '2024-cicd-lite-w1'...
#    Username for 'https://gitlab.com': jenkins
#    Password for 'https://jenkins@gitlab.com': glpat-ABCD1234
#    remote: Enumerating objects: 3, done.
#    remote: Counting objects: 100% (3/3), done.
#    remote: Compressing objects: 100% (2/2), done.
#    remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
#    Receiving objects: 100% (3/3), done.
$ cd 2024-cicd-lite-w1/

# 소스코드 작성
$ cat > app.rb <<EOF
require 'sinatra'

get '/' do
  "Hello, World! The time is #{Time.now}"
end
EOF

# Dockerfile 작성
$ cat > Dockerfile <<EOF
FROM ruby:3.3
RUN gem install sinatra rackup puma
COPY app.rb /app/
WORKDIR /app
CMD ["ruby", "app.rb", "-o", "0.0.0.0"]
EOF

# VERSION 파일 생성
$ echo "0.0.1" > VERSION

#
$ git add .
$ git commit -m "Initial commit"
# => [main 569ce3c] Initial commit
#     3 files changed, 11 insertions(+)
#     create mode 100644 Dockerfile
#     create mode 100644 VERSION
#     create mode 100644 app.rb
$ 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), 536 bytes | 536.00 KiB/s, done.
#    Total 5 (delta 0), reused 0 (delta 0), pack-reused 0
#    To https://gitlab.com/littlebird/2024-cicd-lite-w1.git
#       fe2fb73..569ce3c  main -&gt; main
#    branch 'main' set up to track 'origin/main'.
  • Gitlab 리파지토리에서 확인해보겠습니다.

img.png

  • Gitlab 프로젝트에 소스가 정상적으로 업로드 되었습니다.

Docker Hub 소개

  • 도커허브(Docker Hub)는 도커 이미지를 저장하고 공유할 수 있는 클라우드 서비스입니다.
  • 여러 사용자가 자신이 만든 도커 이미지를 서로 자유롭게 공유할 수 있습니다.
  • 유의 사항
    • Docker Hub는 무료로 누구나 업로드 할 수 있기 때문에, 공식(Official) 라벨이 없는 이미지는 보안에 취약할 수 있고, 사용법을 알 수 없거나, 제대로 작동하지 않을 수 있습니다.
    • 도커 악성 이미지를 통한 취약점 공격 기사 모음
      • 도커도 이제 공격 통로! 악성 이미지 늘어나고 있다 - 링크
      • 도커 환경 공격하는 해커들, 전략을 또 변경했다 - 링크
      • 암호화폐 채굴 공격자들, 잘못 설정된 도커 집중 공략 - 링크
      • 리눅스 노리던 봇넷 멀웨어 둘, 최근 들어 도커 서버도 노리기 시작 - 링크
      • 도커 호스트 감염시켜가며 암호화폐 채굴하는 웜 발견 - 링크 *
    • 사용자당 1개의 Private Repository만 무료로 사용할 수 있습니다.
  • 주요 기능
    • 도커 이미지 저장소 : 도커 이미지를 저장하고 공유할 수 있습니다.
    • 자동 빌드 : Github, Gitlab과 연동하여 코드가 업데이트 될 때마다 자동으로 이미지를 빌드할 수 있습니다.
    • 웹훅 : 타 서비스와 연동하여 이벤트가 발생할 때마다 특정 URL로 요청을 보낼 수 있습니다.
    • Docker Hub CLI 도구 : 도커 이미지를 커맨드라인으로 관리할 수 있는 도구를 제공합니다.

Docker Hub에 dev-app (private) repo 생성

  • Docker Hub에 dev-app이라는 private 리포지토리를 생성하겠습니다.
  • Docker Hub에 로그인 후 Repositories > Create Repository를 클릭하여 리포지토리를 생성합니다.
    • Repository Name : dev-app
    • Visibility : Private

img.png

Jenkins 기본 사용

  • Jenkins를 사용하여 CI/CD 파이프라인을 구축해보겠습니다.
  • 작업 소개
    1. Trigger : 작업을 수행하는 시점을 지정합니다.
      • 작업 수행 태스크 task가 언제 시작될지를 지시할 수 있습니다.
    2. Built step : 작업을 구성하는 단계별 태스크를 지정합니다.
      • 특정 목표를 수행하기 위한 태스크를 단계별 step로 구성할 수 있습니다.
      • 이것을 젠킨스에서는 빌드 스텝 build step이라고 부릅니다.
    3. Post-build action : 태스크가 완료 후 수행할 명령을 지정합니다.
      • 예를 들어 작업의 결과(성공 or 실패)를 사용자에게 알려주는 후속 동작이나, 자바 코드를 컴파일한 후 생성된 클래스 파일을 특정 위치로 복사 등의 작업을 수행할 수 있습니다. - (참고) 젠킨스의 빌드 : 젠킨스 작업의 특정 실행 버전 - 사용자는 젠킨스 작업을 여러번 실행할 수 있는데, 실행될 때마다 고유 빌드 번호가 부여됩니다. - 작업 실행 중에 생성된 아티팩트, 콘솔 로드 등 특정 실행 버전과 관련된 모든 세부 정보가 해당 빌드 번호로 저장됩니다.
  • 첫번째 작업 생성
    • name : first
    • item type : freestyle project
    • Build Steps : Execute shell
      echo "docker check" | tee test.txt
      docker ps
      

      img.png Jenkins 첫번째 작업 생성

  • “Build Now”(지금 실행) 메뉴를 클릭하여 작업을 실행합니다. img.png 빌드 결과 (Console Output)

  • 작업 공간 확인
$ docker compose exec jenkins ls /var/jenkins_home/workspace
# => first

$ docker compose exec jenkins ls /var/jenkins_home/workspace/first
# => test.txt

# 작업 결과 확인
$ docker compose exec jenkins cat /var/jenkins_home/workspace/first/test.txt
# => docker check

Jenkins 플러그인 설치

  • Jenkins 플러그인을 설치하여 더 다양한 기능을 사용할 수 있습니다.
  • Dashboard > Manage Jenkins 메뉴를 클릭하고, 플러그인 관리를 클릭합니다.
  • Available plugins 를 클릭하여 다양한 플러그인을 설치할 수 있습니다.

img.png Jenkins 플러그인 설치 화면

  • 다음의 plugin 을 설치합니다.
    • Pipeline Stage View : 파이프라인 스테이지를 시각적으로 보여주는 플러그인 링크
    • Docker Pipeline : 파이프라인에서 도커를 사용할 수 있도록 지원하는 플러그인 링크
    • Gitlab : Gitlab과 Jenkins를 연동할 수 있도록 지원하는 플러그인 링크
  • Dashboard > Manage Jenkins > Credentials > System > Global credentials (unrestricted) > Add Credentials 를 클릭하여 자격증명을 추가합니다.
    • Docker hub 크레덴셜
      • Kind : Username with password
      • Username : <docker hub 계정>
      • Password : <docker hub 비밀번호 혹은 토큰>
      • ID : dockerhub-credentials => 자격증명 이름으로 pipeline에서 사용됩니다.
    • Gitlab 저장소 크레덴셜
      • Kind : Username with password
      • Username : <gitlab 토큰 이름>
      • Password : <gitlab 토큰>
      • ID : gitlab-credentials => 자격증명 이름으로 pipeline에서 사용됩니다.

파이프라인

  • pipeline은 CI/CD 파이프라인을 코드로 정의하는 플러그인 스크립트입니다. docs

img.png

  • 파이프라인의 장점
    • 코드 : 애플리케이션 CI/CD 프로세스를 코드 형식으로 작성할 수 있고, 해당 코드를 중앙 리포지터리에 저장하여 팀원과 공유 및 작업 가능합니다.
    • 내구성 : 젠킨스 서비스가 의도적으로 또는 우발적으로 재시작되더라도 문제없이 유지됩니다.
    • 일시 중지 가능 : 파이프라인을 실행하는 도중 사람의 승인이나 입력을 기다리기 위해 중단하거나 기다리는 것이 가능합니다.
    • 다양성 : 분기나 반복, 병렬 처리와 같은 다양한 CI/CD 요구 사항을 지원합니다.
  • 파이프라인 용어 img.png
    • Pipeline(파이프라인) : 전체 빌드 프로세스를 정의하는 코드
    • Node(노드) = Agent : 파이프라인을 실행하는 시스템
    • Stages : 순차 작업 명세인 stage 들의 묶음
    • Stage : 특정 단계에서 수행되는 작업들의 정의
    • Steps : 파이프라인의 특정 단계에서 수행되는 단일 작업을 의미.
    • Post : 빌드 후 조치, 일반적으로 stages 작업이 끝난 후 추가적인 steps/step
    • Directive - Docs
      • Environment (key=value) : 파이프라인 내부에서 사용할 환경변수
      • Parameters : 입력 받아야할 변수를 정의 - Type(string, text, choice, password …)
      • Triggers : 파이프라인을 실행하는 조건 설정
      • Input : 파이프라인 실행 중 사용자 입력을 받을 수 있도록 설정
      • When : stage 를 실행 할 조건 설정
  • 파이프라인 구성 형태 3가지
    1. Pipeline Script : 일반적인 방식으로 Jenkins 파이프라인을 생성하여 Shell Script 형태로 작성 링크
    2. Pipeline Script from SCM : Jenkinsfile을 git 등의 SCM(Source Code Management)에 저장하고, 빌드 시작 시 해당 파일을 읽어 파이프라인을 실행 링크
    3. Blue Ocean 기반 : Blue Ocean 플러그인을 설치하여 UI로 파이프라인을 구성하면 Jenkinsfile이 자동으로 생성됨 링크
  • 파이프라인 구문 형태 2가지 img.png 파이프라인 구문 형태별 구조
    1. Declarative Pipeline : 간결하고 가독성이 좋으며, 최근 문법이고, 권장하는 방법. step은 필수로 사용
      pipeline {
         agent any     # Execute this Pipeline or any of its stages, on any available agent.
         stages {
           stage('Build') {   # Defines the "Build" stage.
               steps {
                   //         # Perform some steps related to the "Build" stage.
               }
           }
           stage('Test') { 
               steps {
                   // 
               }
           }
           stage('Deploy') { 
               steps {
                   // 
               }
           }
         }
      }
      
    2. Scripted Pipeline : 커스텀이 용이하나 복잡도가 높고, step은 필수가 아님
      node {             # Execute this Pipeline or any of its stages, on any available agent.
        stage('Build') { # Defines the "Build" stage. stage blocks are optional in Scripted Pipeline syntax. However, implementing stage blocks in a Scripted Pipeline provides clearer visualization of each stage's subset of tasks/steps in the Jenkins UI.
         //              # Perform some steps related to the "Build" stage.
        }
        stage('Test') { 
         // 
        }
        stage('Deploy') { 
         // 
        }
      }
      
Jenkins Pipeline 실습
  • New Item > Pipeline 으로 파이프라인을 생성합니다.
    • Name : First-Pipeline
    • Definition : Pipeline script
    • Script : 아래의 파이프라인 스크립트를 입력합니다.
pipeline {
    agent any

    stages {
        stage('Hello') {
            steps {
                echo 'Hello World'
            }
        }
        stage('Deploy') {
            steps {
                echo "Deployed successfully!";
            }
        }
    }
}
  • Save 하여 저장후 “Build Now”를 클릭하여 파이프라인을 실행합니다. img.png 파이프라인 실행 결과

  • 아래 처럼 수정 후 확인: 환경변수 사용, 문자열 보간 → Console Output 확인

    pipeline {
        agent any
        environment { 
            CC = 'clang'
        }
          
        stages {
            stage('Example') {
                environment { 
                    AN_ACCESS_KEY = 'abcdefg'
                }
                steps {
                    echo "${CC}";
                    sh 'echo ${AN_ACCESS_KEY}'
                }
            }
        }
    }
    

    img.png Console Output

  • 아래 처럼 수정 후 확인: 파이프라인 빌드 시작(트리거) → Console Output 확인

    pipeline {
        agent any
        triggers {
            cron('H */4 * * 1-5')
        }
        stages {
            stage('Example') {
                steps {
                    echo 'Hello World'
                }
            }
        }
    }
    

    img.png Console Output

  • 아래 처럼 수정 후 확인: 파라미터와 함께 빌드 → Console Output 확인 ⇒ 다시 한번 더 빌드 클릭 (변수 입력 칸 확인)

    pipeline {
        agent any
        parameters {
            string(name: 'PERSON', defaultValue: 'Mr Jenkins', description: 'Who should I say hello to?')
            text(name: 'BIOGRAPHY', defaultValue: '', description: 'Enter some information about the person')
            booleanParam(name: 'TOGGLE', defaultValue: true, description: 'Toggle this value')
            choice(name: 'CHOICE', choices: ['One', 'Two', 'Three'], description: 'Pick something')
            password(name: 'PASSWORD', defaultValue: 'SECRET', description: 'Enter a password')
        }
        stages {
            stage('Example') {
                steps {
                    echo "Hello ${params.PERSON}"
                    echo "Biography: ${params.BIOGRAPHY}"
                    echo "Toggle: ${params.TOGGLE}"
                    echo "Choice: ${params.CHOICE}"
                    echo "Password: ${params.PASSWORD}"
                }
            }
        }
    }
    
  • 파이프라인 스크립트를 수정하고 저장 후 “Build with Parameters”를 클릭하여 파라미터를 입력하고 빌드를 실행하면 아래와 같이 지정된 파라미터로 빌드를 실행할 수 있습니다.

img.png Build with Parameters 화면

  • 아래처럼 post (빌드 후 조치) 블록을 추가하여 빌드 후 조치를 설정할 수 있습니다.

    pipeline {
        agent any
        stages {
            stage('Compile') {
                steps {
                    echo "Compiled successfully!";
                }
            }
      
            stage('JUnit') {
                steps {
                    echo "JUnit passed successfully!";
                }
            }
      
            stage('Code Analysis') {
                steps {
                    echo "Code Analysis completed successfully!";
                }
            }
      
            stage('Deploy') {
                steps {
                    echo "Deployed successfully!";
                }
            }
        }
        post { 
            always { 
                echo 'I will always say Hello again!'
            }
        }
    }
    

    img.png step 별 메시지와 post 메시지

    • post에는 always 외에도 다음과 같은 옵션을 사용할 수 있습니다.
      • always : 항상 실행
      • changed : 성공 또는 실패가 변경되었을 때 실행
      • success : 성공했을 때 실행
      • failure : 실패했을 때 실행
      • unstable : 불안정한 상태일 때 실행
  • Pipeline Syntax -> Snippet Generator 를 사용하여 파이프라인 스크립트를 생성할 수 있습니다. img.png

Gitlab과 Jenkins pipeline 연동 실습
  • Gitlab에서 소스를 받아 빌드 후 Docker Hub에 이미지를 업로드하는 파이프라인을 구성해보겠습니다.
  • Pipeline script
pipeline {
    agent any
    environment {
        DOCKER_IMAGE = 'sweetlittlebird/dev-app' // Docker 이미지 이름
    }
    stages {
        stage('Checkout') {
            steps {
                 git branch: 'main',
                 url: 'https://gitlab.com/littlebird/2024-cicd-lite-w1.git',  // Git에서 코드 체크아웃
                 credentialsId: 'gitlab-credentials'  // 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-credentials') {
                        // DOCKER_TAG 사용
                        def appImage = docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}")
                        appImage.push()
                    }
                }
            }
        }
    }
    post {
        success {
            echo "Docker image ${DOCKER_IMAGE}:${DOCKER_TAG} has been built and pushed successfully!"
        }
        failure {
            echo "Pipeline failed. Please check the logs."
        }
    }
}
  • Build Now -> Console Output 확인

    img.png

  • Docker Hub에서 이미지 확인

    img.png

도커 기반 어플리케이션의 CI/CD 구성

  • Jenkins와 Gitlab을 사용하여 다음 그림과 같은 형태의 도커 기반 어플리케이션의 CI/CD 파이프라인을 구성해보겠습니다.

img.png 목표 CI/CD 파이프라인

Gitlab에서 Jenkins 연동 설정
  • gitlab 프로젝트 페이지 > Settings > Integrations > Jenkins 연결후 아래의 정보를 입력 합니다.
    • Enable integration : Active 체크
    • Trigger : Push, Merge request 체크
    • URL : http://<Jenkins접속주소>:<Jenkins포트>
    • Project name : SCM-Pipeline (Jenkins 프로젝트 이름)
    • Username : <Jenkins 아이디>
    • Password : <Jenkins 비밀번호>

    img.png

Jenkins에서 Gitlab 연동 설정
  • Jenkins Item 생성
    • Dashboard > New Item > Pipeline (item name : SCM-Pipeline)
  • Build Triggers 설정
    • Configuration > Build Triggers
      • Build when a change is pushed to GitLab 체크
      • Push Events 체크
      • Accepted Merge Request Events 체크 img.png
  • Jenkins 파일 생성 후 push

    $ cat > Jenkinsfile <<EOF
    pipeline {
        agent any
        environment {
            DOCKER_IMAGE = 'sweetlittlebird/dev-app' // Docker 이미지 이름
        }
        stages {
            stage('Checkout') {
                steps {
                     git branch: 'main',
                     url: 'https://gitlab.com/littlebird/2024-cicd-lite-w1.git',  // Git에서 코드 체크아웃
                     credentialsId: 'gitlab-credentials'  // 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-credentials') {
                            // DOCKER_TAG 사용
                            def appImage = docker.build("\${DOCKER_IMAGE}:\${DOCKER_TAG}")
                            appImage.push()
                        }
                    }
                }
            }
        }
        post {
            success {
                echo "Docker image \${DOCKER_IMAGE}:\${DOCKER_TAG} has been built and pushed successfully!"
            }
            failure {
                echo "Pipeline failed. Please check the logs."
            }
        }
    }
    EOF
      
    # 버전 업데이트
    $ cat <<EOF > VERSION
    0.0.2
    EOF
      
    $ git add .
    $ git commit -m "Add Jenkinsfile"
    # => [main e5671f2] Add Jenkinsfile
    #     1 file changed, 45 insertions(+)
    #     create mode 100644 Jenkinsfile
    $ git push -u origin main
    # => Enumerating objects: 4, done.
    #    Counting objects: 100% (4/4), done.
    #    Delta compression using up to 8 threads
    #    Compressing objects: 100% (3/3), done.
    #    Writing objects: 100% (3/3), 859 bytes | 859.00 KiB/s, done.
    #    Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
    #    To https://gitlab.com/littlebird/2024-cicd-lite-w1.git
    #       41a6efc..e5671f2  main -&gt; main
    #    branch 'main' set up to track 'origin/main'.
    
  • Jenkins 트리거 빌드 확인 img.png
    • git push에 의해 자동으로 빌드가 잘 되었습니다.
  • Docker 저장소 확인 img.png
    • Docker Hub에 수정된 0.0.2 버전의 이미지가 업로드 되었습니다.
  • Gitlab Webhook 기록 확인 img.png

  • app.rb 소스와 VERSION 변경 후 Jenkins 트리거 작업 한번 더 확인

    # app.rb 수정
    $ sed -i -e 's/Hello, World!/Hello, Jenkins! 😀/' app.rb
      
    # VERSION 수정
    $ echo "0.0.3" > VERSION
      
    $ git add . && git commit -m "Update app.rb and VERSION $(cat VERSION)" && git push -u origin main
    # => [main a100e97] Update app.rb and VERSION 0.0.3
    #     3 files changed, 7 insertions(+), 2 deletions(-)
    #     create mode 100644 app.rb-e
    #    Enumerating objects: 7, done.
    #    Counting objects: 100% (7/7), done.
    #    Delta compression using up to 8 threads
    #    Compressing objects: 100% (3/3), done.
    #    Writing objects: 100% (4/4), 509 bytes | 509.00 KiB/s, done.
    #    Total 4 (delta 0), reused 0 (delta 0), pack-reused 0
    #    To https://gitlab.com/littlebird/2024-cicd-lite-w1.git
    #       1bd4fcb..a100e97  main -&gt; main
    #    branch 'main' set up to track 'origin/main'.
    

    img.png

    • 빌드가 잘 되고 Docker Hub에 이미지가 업로드 되었습니다.
Jenkins 빌드 후 컨테이너 실행
  • Jenkins pipline 빌드 후 Docker 컨테이너를 실행하는 파이프라인을 구성해보겠습니다.
  • Jenkinsfile 수정

    pipeline {
        agent any
        environment {
            DOCKER_IMAGE = 'sweetlittlebird/dev-app' // Docker 이미지 이름
            CONTAINER_NAME = 'dev-app'  // Docker 컨테이너 이름
        }
        stages {
            stage('Checkout') {
                steps {
                    git branch: 'main',
                    url: 'https://gitlab.com/littlebird/2024-cicd-lite-w1.git',  // Git에서 코드 체크아웃
                    credentialsId: 'gitlab-credentials'  // 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-credentials') {
                            // DOCKER_TAG 사용
                            def appImage = docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}")
                            appImage.push()
                        }
                    }
                }
            }
            stage('Check, Stop and Run Docker Container') {
                steps {
                    script {
                        // 실행 중인 컨테이너 확인
                        def isRunning = sh(
                            script: "docker ps -q -f name=${CONTAINER_NAME}",
                            returnStdout: true
                        ).trim()
                          
                        if (isRunning) {
                            echo "Container '${CONTAINER_NAME}' is already running. Stopping it..."
                            // 실행 중인 컨테이너 중지
                            sh "docker stop ${CONTAINER_NAME}"
                            // 컨테이너 제거
                            sh "docker rm ${CONTAINER_NAME}"
                            echo "Container '${CONTAINER_NAME}' stopped and removed."
                        } else {
                            echo "Container '${CONTAINER_NAME}' is not running."
                        }
                          
                        // 5초 대기
                        echo "Waiting for 5 seconds before starting the new container..."
                        sleep(5)
                          
                        // 신규 컨테이너 실행
                        echo "Starting a new container '${CONTAINER_NAME}'..."
                        sh """
                        docker run -d --name ${CONTAINER_NAME} -p 4000:4567 ${DOCKER_IMAGE}:${DOCKER_TAG}
                        """
                    }
                }
            }        
        }
        post {
            success {
                echo "Docker image ${DOCKER_IMAGE}:${DOCKER_TAG} has been built and pushed successfully!"
            }
            failure {
                echo "Pipeline failed. Please check the logs."
            }
        }
    }
    
  • git commit 후 push

    $ echo 0.0.4 > VERSION
      
    $ git add . && git commit -m "Update Jenkinsfile $(cat VERSION)" && git push -u origin main
    # => [main 25e5c95] Update Jenkinsfile 0.0.4
    #     2 files changed, 2 insertions(+), 1 deletion(-)
    #    Enumerating objects: 7, done.
    #    Counting objects: 100% (7/7), done.
    #    Delta compression using up to 8 threads
    #    Compressing objects: 100% (3/3), done.
    #    Writing objects: 100% (4/4), 385 bytes | 385.00 KiB/s, done.
    #    Total 4 (delta 2), reused 0 (delta 0), pack-reused 0
    #    To https://gitlab.com/littlebird/2024-cicd-lite-w1.git
    #       26c809d..25e5c95  main -&gt; main
    #    branch 'main' set up to track 'origin/main'.
    
  • 생성된 컨테이너 접속 확인

    $ docker image ls
    # => REPOSITORY                                                                   TAG           IMAGE ID       CREATED             SIZE
    #    sweetlittlebird/dev-app                                                      0.0.4         2f5f42fa7dd6   9 minutes ago       1.01GB
    #    ... 
    $ docker ps --filter name=dev-app
    # => CONTAINER ID   IMAGE                           COMMAND                  CREATED         STATUS         PORTS                  NAMES
    #    e5d4760ea725   sweetlittlebird/dev-app:0.0.4   &quot;ruby app.rb -o 0.0.…&quot;   2 minutes ago   Up 2 minutes   0.0.0.0:4000-&gt;80/tcp   dev-app
    $ curl http://localhost:4000
    # => Hello, Jenkins! 😀 The time is 2024-10-01 15:49:44 +0000!!
    
  • app.rb와 VERSION 수정 후 push 후 컨테이너 접속 후 반영 확인

    # app.rb 수정
    $ sed -i -e 's/Hello, Jenkins! 😀/Hello, Jenkins again!!! 😎/' app.rb
      
    # VERSION 수정
    $ echo "0.0.5" > VERSION
      
    $ git add . && git commit -m "Update app.rb and VERSION $(cat VERSION)" && git push -u origin main
    # => [main 70eaa32] Update app.rb and VERSION 0.0.5
    #     3 files changed, 3 insertions(+), 3 deletions(-)
    #    Enumerating objects: 7, done.
    #    Counting objects: 100% (7/7), done.
    #    Delta compression using up to 8 threads
    #    Compressing objects: 100% (3/3), done.
    #    Writing objects: 100% (4/4), 519 bytes | 519.00 KiB/s, done.
    #    Total 4 (delta 0), reused 0 (delta 0), pack-reused 0
    #    To https://gitlab.com/littlebird/2024-cicd-lite-w1.git
    #       cc87e97..70eaa32  main -&gt; main
    #    branch 'main' set up to track 'origin/main'.
      
    # 호스트 PC에서 반복 접속 실행 : 서비스 중단 시간 체크!
    $ while true; do curl -s --connect-timeout 1 http://127.0.0.1:4000 ; date; sleep 1 ; done
    # => Hello, Jenkins again!!! 😎 The time is 2024-10-01 15:52:25 +0000!!Sun Oct  1 00:52:25 KST 2024
    #    Hello, Jenkins again!!! 😎 The time is 2024-10-01 15:52:26 +0000!!Sun Oct  1 00:52:26 KST 2024
    #    Sun Oct  1 00:52:27 KST 2024
    #    Sun Oct  1 00:52:28 KST 2024
    #    Sun Oct  1 00:52:29 KST 2024
    #    Sun Oct  1 00:52:30 KST 2024
    #    Sun Oct  1 00:52:31 KST 2024
    #    Sun Oct  1 00:52:32 KST 2024
    #    Hello, Jenkins again!!! 😎 The time is 2024-10-01 15:52:33 +0000!!Sun Oct  1 00:52:33 KST 2024
    #    Hello, Jenkins again!!! 😎 The time is 2024-10-01 15:52:34 +0000!!Sun Oct  1 00:52:34 KST 2024
    #    Hello, Jenkins again!!! 😎 The time is 2024-10-01 15:52:35 +0000!!Sun Oct  1 00:52:35 KST 2024
    
  • 수정사항이 적용은 잘 되었지만 6~7초 가량 서비스가 중단되는 것을 확인할 수 있습니다. 이는 컨테이너 중지 및 재시작 시간이 소요되기 때문입니다.
  • 이러한 문제를 해결하기 위해 docker swarm이나 kubernetes 등의 컨테이너 오케스트레이션 툴을 사용하여 서비스 중단 없이 배포할 수 있습니다. 이 부분은 다음에 다루도록 하겠습니다.

마치며

이번 시간에는 Jenkins, Gitlab 등을 사용하여 CI/CD 파이프라인을 구성하는 방법을 알아보았습니다. 다양하게 테스트해보고 싶었는데 생각보다 정리하는데 시간이 많이 소요되어 다양한 예제를 다루지 못한 점이 아쉽습니다.

Jenkins는 예전에 써보고 Teamcity나 Github action을 주로 사용해왔는데, 다시 사용해보니 Jenkins도 Jenkinsfile과 Pipeline도 지원하고 예전에 비해서 훨씬 좋아진 것 같습니다. 이번 스터디를 통해 Jenkins를 재발견한것 같습니다. 준비해주신 Gasida 님께 감사드립니다.