CI/CD를 위한 많은 툴들이 있다.
Buildkite
Github Actions
GitLab CI/CD
Circlec
...
그 가운데 젠킨스는 다음의 이점이 있다
JDK
MAVEN
GRADLE
등 설정이 용이장점이면서 단점은 다음이 있다
이는 CasC(Configuration as Code) 연관 플러그인을 이용해 코드를 이용한 설정이 가능하게 할 수 있다.
Jenkins 설치는 그냥 하면 된다.
단순히 설치만 할 거라면 진짜로 그냥 하면 되는데, 도커로 띄운 젠킨스를 이용해서 도커를 조작할 거라면 DIND
DOOD
에 대해 알아야 할 필요가 있다.
자세한 내용은 아래 링크를 참조하자.
https://jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/
DIND는 말 그대로 도커 컨테이너 안에 또 다른 도커를 설치해 운용하는 구조이다.
이게 왜 필요하냐?
젠킨스가 도커 관련 명령을 실행할 수 있으려면 도커 호스트(Daemon)에 해당 명령을 전달할 수가 있어야 한다.
도커를 에이전트로 띄우려고 해도 마찬가지다.
젠킨스가 호스트 머신에 쌩으로 설치되어 있다면 이는 어려운 일이 아니다.
하지만 도커로 띄운 젠킨스라면? 컨테이너 내부는 또 다른 환경이기 때문에 도커가 없다! 그러니 명령을 실행할 수가 없다.
이 문제를 해결하기 위한 초기 접근은 바로 DIND, 도커 안에 도커를 설치하는 것이었다.
docker:dind
같은 이미지가 따로 나올 정도로 흥했던 해결책인데, 도커 측에서는 이런 구조를 권장하지 않는다.
자세한 이유들은 위 링크에 나와 있고, 내가 말해봐야 앵무새 마냥 말을 반복하는 것에 지나지 않기 때문에 디테일 언급은 하지 않겠지만,
보안, 파일 시스템, 중복 이미지 pull (공간 효율 down) 문제 등이 있다.
참고로 중복 이미지 pull을 피하려고 호스트 머신의 도커 호스트의 라이브러리를 모든 DIND 호스트에게 공유했다가는 전체 시스템이 아작날 수도 있다고 한다.
젠킨스 문제를 어떻게든 해결하려다가 알게 된 것인데, 도커는 명령을 받는 방법이 2가지가 있다.
docker-cli
와 docker api
이다.
위와 같은 구조를 갖는데, 이 때문에 도커가 server-client 구조를 가지고 있다고 한다.
REST API는 GET
POST
등으로 이루어진 그 REST API가 맞다.
포스트맨 등을 통해서 도커에 명령을 내려보는 예제를 찾아보면 꽤 있으니까 해보면 바로 이해할 수 있다.
아무튼 이게 서버처럼 동작하고 있다면, 어디서 listening을 하고 있을 거 아닌가.
그게 바로 docker.sock
이다.
docker.sock
은 기본적으로 UNIX 소켓으로 도커 호스트가 listening 하고 있는 소켓이다. 참고로, REST API도 받기 때문에 설정 변경을 통해 TCP 소켓으로의 활용도 가능하다.
간단히 말해서 이 docker.sock
에 야 컨테이너 띄워
하고 속삭일 수 있다면 도커 호스트가 그걸 듣고 컨테이너를 띄운다는 거다.
이를 이용한 방법이 바로 docker.sock
마운팅이다.
# 흔한 docker-compose.yml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
이렇게 설정하면 호스트 머신의 디스크가 컨테이너 내부와 이어지기 때문에 젠킨스에서 호스트 머신의 도커 서버에 명령을 전달할 수 있게 된다.
그런데 딱 이렇게만 하고 실행하면 command not found
라는 에러를 만날 수 있다.
왜? 도커 호스트와 이어지는 통로는 있는데, 여기다가 뭘 어떻게 말해야 할 지 모르기 때문이다.
위 그림을 다시 보면, 도커 서버에 명령을 전달하는 것은 결국 docker-cli와 rest api이다.
rest api는 spec을 지켜 curl 등으로 명령을 전달하면 실행이 잘 될 거다. 하지만 cli에 비해 쓸데없이 구문이 길어지고 복잡해지기 때문에 잘 안 쓰는 것도 사실이다.
그러니까 한 마디로 말하자면 Jenkins 컨테이너에 docker-cli의 설치가 필요하다.
이는 dind와는 다른데, 도커 호스트를 포함하는 전체가 아니라 클라이언트에 해당하는 cli만 설치하는 것이기 때문이다.
FROM jenkins/jenkins:lts # 필요하면 버전 수정
USER root
RUN apt-get update && \
apt-get -y install apt-transport-https \
ca-certificates \
curl \
gnupg2 \
software-properties-common
RUN 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"
RUN apt-get update && apt-get -y install docker-ce-cli
RUN groupadd -f docker
RUN usermod -aG docker jenkins
젠킨스 이미지 그대로 받아다가 돌리지 말고, 대충 이런식으로 이미지를 따로 만들어서 실행해주면 된다.
마지막 구문들은 도커 실행을 위해서는 docker
그룹에 들어가 있을 필요가 있기 때문에 이에 대한 permission 문제를 해결하기 위한 부분이다.
인터넷을 돌아다니다 보면 install docker-ce-cli
이 부분을 install docker-ce
이렇게 해놓은 글이 보이던데, 그렇게 하면 dind를 사용하게 된다. ce
는 클라이언트가 아닌 커뮤니티 에디션의 약자다.
직접 만들기 귀찮다면 아래 이미지를 받아서 써도 된다. 위의 Dockerfile 그대로 만든 이미지다.
yaaloo/jenkins:docker-cli
docker-compose 예시
version: "3"
services:
jenkins:
image: #이미지
container_name: #컨테이너이름
ports:
- "8080:8080"
- "50000:50000"
environment:
- JAVA_OPS=-Xmx1g # 젠킨스가 JVM 위에서 돌아가기 때문에 가끔 공간없어서 버벅대는 거 보면 답-답
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./jenkins_home:/var/jenkins_home
젠킨스 컨테이너 실행 옵션에 -v /var/bin/docker:/var/bin/docker
이렇게 하라고 말하는 글도 있다.
이건 호스트 머신의 도커 바이너리 파일을 그대로 복사해다가 젠킨스 컨테이너에 넣어주면 cli를 따로 설치해주지 않아도 명령을 알아먹는다는 원리로 나온 해결책이다.
그런데 이게 예전에는 먹혔을 지 몰라도 도커 바이너리 파일에 따라 의존하고 있는 GNU libc
버전이 다르기 때문에, 이 방법으로 해결을 보려고 하다가는
docker: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.32' not found (required by docker)
docker: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by docker)
이런 에러를 보기 십상이다.
이에 대해 더 알고 싶다면 https://stackoverflow.com/questions/72990497/getting-glibc-2-32-and-glibc-2-34-not-found-in-jenkins-docker-with-dind-on
이거 해결하겠다고 호스트 머신의 GNU libc
버전과 컨테이너 내 GNU libc
버전을 맞추느니, 그냥 순순히 dind를 운용하거나 소켓을 마운팅하고 cli를 설치하자.
이거 처음에 차이를 몰랐는데,
polling SCM은 주기적으로
젠킨스에서
repo를
확인한다.
진짜 알아듣기 쉽게 설명하자면,
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
업데이트 있냐?
이 짓을 주기적으로 한다는 거다.
그에 반해, Webhook은 repo에서
업데이트가 있으면
젠킨스에
알린다.
어떻게 보면 Webhook이 더 깔끔하고 세련되어 보이지만, 장단점이 있다.
예를 들어 개발 초기여서 시간 당 커밋 수가 많을 때는 polling SCM 방식이 오히려 더 좋을 수 있다.
Webhook은 지정한 branch에 새 커밋이 있을 때마다 Job이 실행되기 때문에 잦은 변경이 일어날 때는 생각보다 더 불편하다.
https://www.jenkins.io/doc/book/pipeline/syntax/#agent
Jenkins Pipeline은 작성이 쉽고 가독성이 좋은 Declarative Pipeline
과 러닝 커브가 있는 편이지만 더 많은 기능을 제공하는 Scripted Pipeline
가 있다.
대부분이 사용하는 것은 Declarative Pipeline
이고, 이 글도 이에 맞춰서 작성할 것이다.
Pipeline은 크게 4가지 영역으로 나눌 수 있다.
agent
stages
steps
post
이렇게 4가지이다.
agent는 단순히 Jenkins 작업을 실행 할 프로세스라고 할 수 있다.
디폴트 값으로 build-in-agent
가 하나 있으며, 이 녀석 때문에 굳이 agent 설정을 하지 않아도 Jenkins 작업을 할 수가 있다.
agent는 Jenkins 호스트가 있는 머신 뿐만 아니라, ssh 등을 이용해 원격으로 동작하는 종류로 설계될 수도 있다.
agent 설정 시 별칭이라고 할 수 있는 label
이라는 값을 줄 수가 있는데,
agent { label 'my-agent-1'}
처럼 특정 agent만 사용하게 할 수도 있다.
이 label은 논리연산이 가능해서
agent { label 'my-label1 && my-label2' }
agent { label 'my-label1 || my-label2' }
따위로 선언할 수도 있다.
만약 아무 agent나 가져다 쓰고 싶다면 agent any
라고 하면 된다.
선언된 agent의 scope는 pipeline 전체가 대상이 되는데,
만약 stage별로 다른 agent를 사용해야 할 필요가 있을 시에는 아래와 같이 top-level agent를 none으로 선언하고, 각 stage의 내부에서 agent를 선언할 수도 있다.
pipeline {
agent none
stages {
stage('Example') {
agent any
steps {
echo 'Hello World'
}
}
}
}
조금 특별한 agent의 패러미터로는 docker
dockerfile
kubernetes
등이 있다.
pipeline 내에 반드시 하나가 필요하고, 패러미터는 따로 없다.
여러 stage들을 묶는 블락이다.
stage의 이름은 그냥 개발자 멋대로 지어도 상관없다. 하지만 남들도 이게 뭐하는 stage인지 알아보기 쉽게 짓는 것이 신상에 좋을 것이다.
실질적인 작업을 담고 있는 블락이다. step 블락은 따로 없다.
steps 안의 명령 한 줄 한 줄이 step이라고 보면 된다.
steps 블락 내부에서 사용할 수 있는 조금 특이한 블락이 하나 있는데, script
이다.
steps {
echo 'Hello World'
script {
def browsers = ['chrome', 'firefox']
for (int i = 0; i < browsers.size(); ++i) {
echo "Testing the ${browsers[i]} browser"
}
}
}
대충 이렇게 쓰는 건데, Declarative Pipeline
내에서 부분적으로 Scripted Pipeline
의 문법을 사용할 수 있게 해주는 블락이다.
한 마디로 후처리 작업이라고 할 수 있다.
이 친구는 stages
밑이나 steps
밑에 붙는데, 그 내부에 post-conditon
블락이라는 걸 담을 수 있다.
이게 뭐냐면, 바로 위에 있는 블락의 처리 결과를 조건으로 잡고 그 조건에 따라 실행될 지 / 말 지가 결정되는 블락이다.
예시를 들면 다음과 같다.
pipeline {
agent any
stages {
stage('Example') {
steps {
echo 'Hello World'
}
post {
success {
echo 'success' // 위 steps 전부 성공하면 실행됨
}
failure {
error 'failure' // steps에서 하나라도 실패하면 실행됨
}
}
}
}
post {
always {
echo 'I will always say Hello again!' // stages 끝나고 반드시 실행됨
}
}
}
post-condition
블락의 종류는 위 3가지가 대표적이지만, 저것 말고도 많으니 공식 문서에서 찾아보면 좋겠다.
이번에 Node.js
로 만든 백엔드 서버를
작업을 하는 pipeline을 예시로 가져왔다.
각 stage별로 agent any
를 선언한 이유는, 원래 agent 2개로 하려다가 갑자기 ssh를 통한 원격 조작에 꽂혀서 급히 수정을 해서 그렇다.
pipeline {
agent none
environment {
GIT_ID = 'github'
REPO = 'https://github.com/group4comp308/group4comp308-backend.git'
BRANCH = 'main'
PROJECT_NAME = 'group4comp308-backend'
VERSION_ID = '0'
DOCKERHUB_ID = credentials('dockerhub-id')
IMAGE_NAME = "${DOCKERHUB_ID}/${PROJECT_NAME}"
IMAGE_TAG = "${VERSION_ID}.${BUILD_NUMBER}"
SERVER_HOST = credentials('server-host')
}
stages {
stage('check out') { // git pull
agent any
steps {
echo 'checking out...'
git url: "$REPO",
branch: "$BRANCH",
credentialsId: "$GIT_ID"
sh 'ls -al'
}
post {
success {
echo 'success: check out'
}
failure {
error 'failure: check out'
}
}
}
stage('build') { // docker 이미지 빌드
agent any
steps {
echo 'building image...'
sh 'docker build . -t $IMAGE_NAME:$IMAGE_TAG'
}
post {
success {
echo 'success: build'
}
failure {
error 'failure: build'
}
}
}
stage('push') { // docker hub에 login 후 push
agent any
steps {
echo 'pushing image...'
withCredentials([string(credentialsId: 'dockerhub', variable: 'dockerhubPwd')]) {
sh 'echo $dockerhubPwd | docker login -u $DOCKERHUB_ID --password-stdin'
}
sh 'docker push $IMAGE_NAME:$IMAGE_TAG'
}
post {
success {
sh 'docker rmi $IMAGE_NAME:$IMAGE_TAG' // push 성공 시 로컬 이미지 삭제
echo 'success: push'
}
failure {
error 'failure: push'
}
}
}
stage('pull') { // ssh를 통한 원격 조작으로 도커 이미지 pull
agent any
steps {
echo 'pulling image...'
sshagent(['server']) {
sh 'ssh -o StrictHostKeyChecking=no $SERVER_HOST mkdir -p $PROJECT_NAME'
sh 'ssh -o StrictHostKeyChecking=no $SERVER_HOST docker pull $IMAGE_NAME:$IMAGE_TAG'
}
}
post {
success {
echo 'success: pull'
}
failure {
error 'failure: pull'
}
}
}
stage('clean') { // 컨테이너 이름이 겹칠 수 없기 때문에 동작 중인 컨테이너 삭제
agent any
steps {
echo 'cleaning container...'
sshagent(['server']) {
sh 'ssh -o StrictHostKeyChecking=no $SERVER_HOST "docker rm -f ${PROJECT_NAME} || true"'
}
}
post {
success {
echo 'success: clean'
}
failure {
error 'failure: clean'
}
}
}
stage('deploy') { // pull 한 이미지를 바탕으로 컨테이너를 돌린다
agent any
steps {
echo 'running container...'
sshagent(['server']) {
echo '$IMAGE'
sh 'ssh -o StrictHostKeyChecking=no $SERVER_HOST docker run -d --name $PROJECT_NAME --env-file $PROJECT_NAME/env.list -p 4000:4000 $IMAGE_NAME:$IMAGE_TAG'
}
}
post {
success {
echo 'success: deploy'
}
failure {
error 'failure: deploy'
}
}
}
}
}