CI/CD
CI/CD는 Continuous Integration(지속적 통합)과 Continuous Deployment(지속적 배포)의 약자입니다.
CI는 개발자들이 코드 변경사항을 메인 저장소에 병합하고 자동으로 테스트하는 과정을 의미하고,
CD는 검증된 코드를 자동으로 개발/검증/운영 환경에 배포하는 괒어을 말합니다.
이를 통해 소프트웨어 개발의 속도와 품질을 높일 수 있습니다.
특히 CI/CD는 MSA 환경에서 없어서는 안되는 하나의 프로세스로 현재 클라우드 기술에 애용되고 있습니다.
CI / CD에는 여러 툴들이 존재하지만 이번 글에서는 Docker에 Jenkins와 Gogs로 테스트하도록 하겠습니다.
Docker 맛보기
1) 소스를 배포하기 위해 간단한 Python 파일을 만들고, Docker Image로 만들어 컨테이너로 실행합니다.
# hello.py
print ('Hello Docker')
2) 생성된 Python 파일을 이미지화 하기위해 Dockerfile을 작성합니다.
FROM python:3
COPY . /app
WORKDIR /app
CMD python3 hello.py
FROM : 사용할 이미지를 지정
COPY : {로컬 경로}의 파일을 {컨테이너 경로}로 복사
WORKDIR : Docker 실행 시 사용할 디렉토리를 지정
CMD : 프로그램을 실행할 명령어
3) 컨테이너 이미지 빌드
$ docker build . -t hello
$ docker image ls -f reference=hello
# 컨테이너 실행 (rm은 컨테이너 종료와 함께 삭제)
$ docker run --rm hello
💡 Docker Image의 Tag는 환경별로 hello:dev, hello:stg, hello:prod로 사용할 수 있습니다.
버전 관리를 위해 hello:1.0.3-dev, hello:1.0.2:stg, hello:1.0.0:prod와 같이 사용할 수 있습니다.
보통 latest 태그는 가장 Stable한 최신 버전으로 설정하지만 운영 환경에서는 Specific 한 태그를 사용하는 것을 권장합니다.
또, Git commit hash를 이용하여 태그를 사용하는 경우도 있습니다.
ex) hello:8e2f789
hash tag를 사용하는 이유는 빌드 이미지를 추적하고, Uniqueness를 보장하고, Human error로 덮어씌워 지는 것을 방지하기 위함입니다.
이후 CI/CD의 자동화에서는 별도의 수동 작업 없이 고유한 태그를 사용하는데에 사용되기도 합니다.
최종 빌드 전에 컴파일 등 실행하는 임시 컨테이너를 사용하여, 서비스 컨테이너의 보안성 향상과 이미지 크기를 최적화하여 컨테이너 이미지의 크기를 경량화합니다.
ex) Single stage build
- hello.java
class Hello {
public static void main(String[] args) {
System.out.pringIn("Hello docker");
}
}
- Dockerfile
FROM openjdk
COPY . /app
WORKDIR /app
RUN javac hello.java
CMD java hello
- 컨테이너 이미지 빌드 및 실행
## 이미지 빌드
$ docker build . -t hello:2
## 이미지 최신화
$ docker tag hello:2 hello:latest
## 이미지 확인
$ docker image ls -f reference=hello
## 컨테이너 실행
$ docker run --rm hello:2
$ docker run --rm hello
- 컨테이너 내부 파일 확인
이렇게 컨테이너 내부를 확인했을 때 소스코드를 포함해 컴파일러가 모두 존재하는 것을 확인할 수 있습니다.
실제 실행에 불필요 한 파일로 인해 용량과 소스코드가 보여지는 문제가 발생합니다.
Multi Stage Build
- Hello.java
class Hello {
public static void main(String[] args) {
System.out.println("Hello Multistage container build");
}
}
- Dockerfile
FROM openjdk:11 AS buildstage
COPY . /app
WORKDIR /app
RUN javac Hell.java
FROM openjdk:11-jre-slim
COPY --from=buildstage /app/Hello.class /app/
WORKDIR /app
CMD java Hello
- 컨테이너 실행 및 확인
## 컨테이너 이미지 빌드
$ docker build . -t hello:3
$ docker tag hello:3 hello:latest
$ docker image ls -f reference=hello
## 컨테이너 실행
$ docker run --rm hello:3
$ docker run --rm hello
## 컨테이너 내부 파일 리스트 확인
$ docker run --rm hello ls -l
$ docker run --rm hello javac --help
buildstage가 별도로 동작하는 것을 확인할 수 있고, 빌드가 완료된 뒤에 java가 실행되는 것을 확인해 볼 수 있습니다.
컨테이너 실행 후 내부를 확인해보면 java 컴파일러가 없는 것고, 실행을 위한 class 파일만 남아 있는 것을 확인할 수 있습니다.
아래 그림과 같이 하나의 컨테이너에 두개의 Stage로 나뉘어 지고, Build와 Run 컨테이너 두개가 각각 동작합니다.
Build 컨테이너는 빌드가 완료된 후 Binary를 Run 컨테이너로 넘겨주고 장렬히 삭제됩니다.
K8S의 Init container도 비슷한 맥락으로 사용하는 느낌
간단한 Timeserver 만들어보기
- Timeserver python 코드
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from datetime import datetime
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
now = datetime.now()
response_string = now.strftime("The time is %-I:%M:%S %p, CloudNeta Study.\n")
self.wfile.write(bytes(response_string, "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()
- Dockerfile 작성
FROM python:3.12
ENV PYTHONUNBUFFERED 1 ## 버퍼링 비활성화
COPY . /app
WORKDIR /app
CMD python3 server.py
- Docker build 및 실행
## Docker 이미지 빌드
$ docker build . -t timeserver:1 ; docker tag timeserver:1 timserver:latest
## Docker 이미지 확인
$ docker images ls -l reference=timeserver
## Docker container 실행
# -d는 detach로 background 실행
# -p는 로컬포트:컨테이너 포트로 컨테이너 외부에서 접속 할 수 있도록 Port binding
$ docker run -d -p 8080:80 --name=timeserver timeserver
- HTTP 접속 확인
$ curl http://localhost:8080
코드가 바뀌면 어떻게 해야할까?
위에서 진행했던 방식으로 코드가 변경된다면, 단순 문구가 변경된다면 build를 다시 해야합니다.
이는 생산성도 떨어지게됩니다.
이럴 경우에 Volume을 마운트하여 사용하여 동적으로 코드 변경 내용이 반영되도록 합니다.
또 어플리케이션에서 감지할 수 있도록 reloading 을 build 과정에 설치하고,
@reloading 데코레이터를 추가하여 Python 어플리케이션에서 Hot reloading이 가능하도록 합니다.
- server.py
from reloading import reloading
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from datetime import datetime
class RequestHandler(BaseHTTPRequestHandler):
@reloading
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
now = datetime.now()
response_string = now.strftime("The time is %H:%M:%S, Docker End.")
self.wfile.write(bytes(response_string,"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()
- Dockerfile
FROM python:3
RUN pip install reloading
ENV PYTHONUNBUFFERED 1
WORKDIR /app
- Docker compose
services:
frontend:
build: .
command: python3 server.py
volumes:
- type: bind
source: .
target: /app
environment:
PYTHONDONTWRITEBYTECODE: 1
ports:
- "8080:80"
- 컨테이너 기동
## 이미지 빌드 및 Background 실행
$ docker compose build; docker compose up -d
## Compose 프로세스 확인
$ docker compose ps
## Compose 이미지 확인
$ docker compose images
## 서비스 접근
$ curl http://localhost:8080
## Compose Log 확인
$ docker compose logs
- server.py에서 문구 변경
CI/CD 실습 해보기
환경 배포 : CI - gogs , CD - Jenkins
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
- 컨테이너 실행
$ docker compose up -d
$ docker compose ps
## 기본 정보 확인
$ for i in gogs jenkins ; do echo ">> container : $i <<" ; docker compose exec $i sh -c "whoami && pwd"; echo ; done
- Jenkins 컨테이너 초기 설정
$ docker compose exec jenkins cat /var/jenkins_home/secrets/initialadminPassword
## Jenkins WEB 접속
$ open "http://127.0.0.1:8080"
DinD, DooD
DinD : Docker in Docker은 Docker 컨테이너 내부에서 완전히 독립된 Docker 데몬을 실행합니다.
이로 인해 완전한 격리를 제공합니다.
DooD : 호스트의 Docker 데몬을 컨테이너 내부에서 사용하고, Docker 소켓을 마운트하여 사용합니다.
두 방식의 차이점
- DinD
1) 컨테이너에 Privileged 권한 필요
2) 추가 layer로 인한 오버헤드 발생
3) 매번 새로운 환경이기 때문에 캐시 공유에 제약
- DooD
1) Docker 소켓 공유로 호스트 시스템에 접근 가능
2) 호스트 Docker를 직접 사용하기 때문에 상대적으로 속도에 이점
3) 호스트의 캐시 활용 가능
DinD 실습
- Privileged 설정
## Jenkins 컨테이너 내부에서 명령어 실행
$ docker compose jenkins exec --privileged -u root jenkins bash
$ 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 화인
$ docker info
$ docker ps
- Jenkins 유저에서 docker 명령어 사용하도록 설정
$ chgrp docker /var/run/docker.sock
$ ls -l /var/run/docker.sock
$ usermod -aG docker jenkins
$ cat /etc/group | grep docker
gogs
- 초기 컨테이너 접속 설정
$ open "http://127.0.0.1:3000/install
- 데이터베이스 유형 : SQLite3
- 어플리케이션 URL : http://{PC IP}:3000/
- 기본 브랜치 : main
- 로그인 후 Token 생성
- Repository 생성
- Repository Name : dev-app
- Visibility : Default
- .gitignore : Python
- Readme : Default
- gogs 실습을 위한 저장소 설정 : Jenkins 컨테이너 bash 내부 진입해 git 작업
## jenkins 컨테이너 진입
$ docker compose exec jenkins bash
## jenkins 컨테이너 내부
$ cd /var/jenkins_home
$ git config --global user.name "<gogs 계정>"
$ git config --global user.email "a@a.com"
$ git config --global init.defaultBranch main
$ git clon <gog repo url>
$ cd dev-app
$ git branch
$ git remote -v
- Git Push 를 위한 작업
# server.py 파일 작성
cat > server.py <<EOF
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from datetime import datetime
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
now = datetime.now()
response_string = now.strftime("The time is %-I:%M:%S %p, CloudNeta Study.\n")
self.wfile.write(bytes(response_string, "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
# Dockerfile 생성
cat > Dockerfile <<EOF
FROM python:3.12
ENV PYTHONUNBUFFERED 1
COPY . /app
WORKDIR /app
CMD python3 server.py
EOF
# VERSION 파일 생성
echo "0.0.1" > VERSION
#
git add .
git commit -m "Add dev-app"
git push -u origin main
Jenkins 사용
Jenkins 첫 배포 해보기
- Jenkins item 생성
- Build Steps : Excute shell -> Save 후 지금 빌드 : Console Output 확인
- Build 실행
Gogs repo Token Credential에 추가하기
- Gogs Repo 자격 증명 설정 :
- Globals > Add Credentials
- Kind : Username with Password
- Username : devops
- Password : gogs dev-app 토큰
- ID : gogs-dev-app
추가한 Credential 로 배포해보기
- ITEM 생성
- "이 빌드는 매개변수가 있습니다." 체크
- 매개변수 명 : FirstPara
- Default Value : CICD
- 소스 코드 관리 : Git 선택
- Repository URL : gogs dev-app repo url 입력 (URL 뒤에 .git은 제외)
- Credentials : 앞에서 등록한 devops 선택
- build Steps : Execute shell 선택
- Save 후 파라미터와 함께 빌드 진행
- Console Output 확인
파이프라인
- Jenkins 도커 계정/암호 자격 증명 설정 : Add Credentials(Global) - Kind(Username with password)
- Username : Docker hub 계정
- Password : Docker hub 암호 또는 토큰
- 파이프라인 장점
- 코드 : 어플리케이션 CI/CD 프로세스를 코드 형식으로 작성할 수 있고, 핻아 코드를 중앙 레포에 저장하여 팀원과 공유 및 작업 가능
- 내구성 : 젠킨스 서비스가 의도적으로 또는 우발적으로 재시작되더라도 문제없이 유지됨
- 일시 중지 가능 : 파이프라인을 실행하는 도중 사람의 승인이나 입력을 기다리기 위해 중단하거나 기다리는 것이 가능
- 다양성 : 분기나 반복, 병렬 처리와 같은 다양한 CI/CD 요구 사항을 지원
- 파이프라인 2가지 구문 : 선언형 파이프라인, 스크립트형 파이프라인
- 선언형 파이프라인 : 쉽게 작성 가능, 최근 문법으로 젠킨스에서 권장하는 방법, Step 필수
- 스크립트형 파이프라인 : 커스텀 작업에 용이, 복잡하여 난이도가 높음, Step 필수 아님
Pipeline-ci 실습
- pipeline script
pipeline {
agent any
environment {
DOCKER_IMAGE = 'tjdgns789/dev-app' // Docker 이미지 이름
}
stages {
stage('Checkout') {
steps {
git branch: 'main',
url: 'http://169.254.113.99:3000/devops/dev-app.git', // Git에서 코드 체크아웃
credentialsId: 'gogs-dev-app' // 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."
}
}
}
- 지금 빌드 > 콘솔 Output 확인
- Docker hub Push 확인
Docker 기반 어플리케이션 CI/CD 구성
- gogs app.ini 에 Local Network가 허용되도록 Allow List에 추가
[security]
INSTALL_LOCK = true
SECRET_KEY = LYS1PPbp89Fmewe
LOCAL_NETWORK_ALLOWLIST = 169.254.113.99
- gogs webhook 설정
- repository settings > webhooks
- Payload URL : http://169.254.113.99:8080/gogs-webhook/?job=SCM-Pipeline/
- Content Type : application/json
- Secret : "임의 설정"
- When should this webhook be triggered? : Just the Push event
- Active : Check
[Add webhook]
- Jenkins Item 생성
- GitHub preject : http://169.254.113.99:3000/devops/dev-app/
- use gogs secret : gogs webhook에서 생성한 패스워드
- Build Triggers : Build when a change is pushed to Gogs
- pipeline
- script Path : Jenkinsfile
- Jenkinsfile 생성 후 git push
pipeline {
agent any
environment {
DOCKER_IMAGE = 'tjdgns789/dev-app' // Docker 이미지 이름
CONTAINER_NAME = 'dev-app'
}
stages {
stage('Checkout') {
steps {
git branch: 'main',
url: 'http://169.254.113.99:3000/devops/dev-app.git',
credentialsId: 'gogs-dev-app' // 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."
}
}
}
- 작성한 파일 Push
$ docker compose exec jenkins bash
$ cd /var/jenkins_home/dev-app/
$ git add . && git commit -m "jenkinsfile add & VERSION 0.0.5 Changed" && git push -u origin main
- Docker hub 확인
- 트리거 확인
- gogs webhook 기록 확인
- 버전 수정하여 다시 한번 트리거 확인하기
- 도커 빌드 후 기존 컨테이너 중지/제거 후 신규 컨테이너 실행 Jenkinsfile pipeline 수정 후 빌드 (SCM-Pipeline)
- Jenkinsfile 수정 후 git push
pipeline {
agent any
environment {
DOCKER_IMAGE = 'tjdgns789/dev-app' // Docker 이미지 이름
CONTAINER_NAME = 'dev-app' // 컨테이너 이름
}
stages {
stage('Checkout') {
steps {
git branch: 'main',
url: 'http://169.254.113.99:3000/devops/dev-app.git', // Git에서 코드 체크아웃
credentialsId: 'gogs-dev-app' // 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()
appImage.push("latest") // 빌드 이미지 push 할 때, 2개의 버전(현재 버전, latest 버전)을 업로드
}
}
}
}
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:80 ${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."
}
}
}
- 배포 후 생성된 컨테이너 확인
$ docker images
$ docker ps
$ curl http://127.0.0.1:4000
'Cloud > CICD' 카테고리의 다른 글
Jenkins CI/ArgoCD + K8S (0) | 2024.12.23 |
---|---|
GitHub Actions CI/CD (1) | 2024.12.15 |