💡 CICD 과정LINK의 개요에 따라 작성되었습니다.
순서를 따르면 순조롭게 진행할 수 있습니다.

SSH 접속 및 Docker Container 실행

이제 docker hub에 push 작업까지 완료되었으니 각 branch에 맞는 서버에 접속해 배포를 진행하면 된다.

서버에 Docker 설치

먼저 배포하고자 하는 서버에 접속해 Docker를 설치한다.

설치 과정은 각 OS에 맞게 LINK 를 확인하여 설치하면 된다.

docker는 항상 root로 실행되기 때문에 sudo를 사용하여 명령어를 입력해야하는데, 해당 사용자를 docker 그룹에 추가함으로써 sudo 명령어를 사용하지 않고 docker 명령어를 사용할 수 있다.

1
2
3
4
5
6
# docker 그룹에 유저 추가
$ sudo usermod -aG docker [username]
# docker daemon restart
$ sudo service docker restart
# 서버 재부팅
$ sudo reboot

서버에 접속해 위의 명령어를 실행하고 다시 접속하면 sudo 없이도 docker 명령어를 사용할 수 있게 된다. 개발 서버와 배포 서버, 모든 서버에서 위 과정을 진행하면 된다.

ssh credential 생성

먼저 서버에 접속하기 위한 ssh credential을 생성해야한다.

해당 private key는 cicd-test라는 프로젝트에 국한된 것이기 때문에, cicd-test item 스코프에 국한된 credential을 생성해준다.

위와 같이 입력해 개발 서버와 운영 서버에 대한 credential을 생성한다. 나의 서버는 password인증을 사용하고 있어 passphrase에 비밀번호를 채워 넣었지만, 공개키 인증을 사용하는 경우 passphrase 대신 private key의 Enter directly를 활성화 해, 비밀키를 적어주고 credential을 생성하면 된다.

host의 이름과 port도 Jenkinsfile에 입력되면 보안상 좋지 않으므로 credential을 추가해주었다. host에는 접속 ip나 주소를 입력해 준다. PORT는 일반적인 ssh포트인 22를 사용하는 경우 해당 Credential을 만들 필요는 없다. SSH Pipeline Steps의 Default Port가 22이기 때문이다.

Plugin 설치

Jenkins에 SSH 접속을 원활하게 돕는 SSH Pipeline Steps를 설치한다. 대표적인 SSH관련 플러그인으로는 SSH Pipeline StepsPublish Over SSH가 있다. 하지만 Publish Over SSH의 마지막 업데이트가 22년에 멈추었기 떄문에 업데이트가 활발한 SSH Pipeline Steps를 사용하겠다.

위처럼 SSH Pipeline Steps Plugin을 설치해 준다. 공식 문서에서 해당 플러그인의 사용방법을 확인할 수 있다.

이제 branch에 따라 변수들이 유동적으로 변경될 수 있도록 Set Varibles Stage에 다음과 같이 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
stage('Set Variables') {  
    steps {  
        echo 'Set Variables'  
        script {  
            // BASIC  
            PROJECT_NAME = 'cicd-test'  
            REPOSITORY_URL = 'https://github.com/dukcode/cicd-test'  
            PROD_BRANCH = 'main'  
            DEV_BRANCH = 'develop'  
            BRANCH_NAME = env.BRANCH_NAME  
            OPERATION_ENV = BRANCH_NAME.equals(PROD_BRANCH) ? 'prod' : 'dev'  
  
            // DOCKER  
            DOCKER_HUB_URL = 'registry.hub.docker.com'  
            DOCKER_HUB_FULL_URL = 'https://' + DOCKER_HUB_URL  
            DOCKER_HUB_CREDENTIAL_ID = 'DOCKER_HUB_CREDENTIAL'  
            DOCKER_IMAGE_NAME =  OPERATION_ENV + '-' + PROJECT_NAME  
  
            // SSH  
            SSH_CREDENTIAL_ID = OPERATION_ENV.toUpperCase() + '_SSH'  
            SSH_PORT_CREDENTIAL_ID = OPERATION_ENV.toUpperCase() + '_SSH_PORT'  
            SSH_HOST_CREDENTIAL_ID = OPERATION_ENV.toUpperCase() + '_SSH_HOST'  
        }  
    }  
}

위와 같이 SSH 관련 변수들을 추가로 작성해준다. 그러면 SSH_CREDENTIAL_ID, SSH_HOST_CREDENTIAL_ID, SSH_PORT_CREDENTIAL_ID에 유동적으로 변수가 담기게 된다.

내부 포트 파싱하기

jenkins가 ssh로 접속해 docker container를 실행시키려면 -p 옵션으로 내부포트와 외부포트를 적어주어야 한다.

프로젝트가 실제로 작동할 포트는 application.yml에 존재한다. 이것을 Docker Container가 내부포트로 적어주어야 외부와 통신이 가능하다.

이 포트를 jenkins에서 credential이나 환경변수로 관리한다면, 내부포트에 대한 정보가 두 곳에 존재하게 된다.

yqyaml파일을 파싱할 수 있는 프로그램이다. 우리는 환경에 맞는 application.yml파일에서 port번호를 파싱할 것이다.

포트번호까지 숨길 수 있다면 보안상 이점이 있다고 생각하지만, 굳이 그렇게 생각하지 않는다면 이 부분을 넘겨도 좋다.

Jenkins에 yq 설치

가장 먼저 Jenkins에 접속해 yq를 설치하자.

1
2
3
4
5
6
7
8
9
10
11
12
# Jenkins bash 접속
$ docker exec -it jenkins /bin/bash

# gpg, git-secret 설치
$ apt update
$ apt install gpg git-secret -y

#
$ apt install wget
$ wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64
$ chmod a+x /usr/local/bin/yq
$ exit

위와 같은 명령어를 통해 yq를 Jenkins Container에 설치한다.

Port Parsing Stage 작성

먼저 포트 정보가 있는 yml파일의 이름을 Set Variables stage에서 변수에 담아주자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
stage('Set Variables') {  
    steps {  
        echo 'Set Variables'  
        script {  
            // BASIC  
            PROJECT_NAME = 'cicd-test'  
            REPOSITORY_URL = 'https://github.com/dukcode/cicd-test'  
            PROD_BRANCH = 'main'  
            DEV_BRANCH = 'develop'  
            BRANCH_NAME = env.BRANCH_NAME  
            OPERATION_ENV = BRANCH_NAME.equals(PROD_BRANCH) ? 'prod' : 'dev'  
  
            // DOCKER  
            DOCKER_HUB_URL = 'registry.hub.docker.com'  
            DOCKER_HUB_FULL_URL = 'https://' + DOCKER_HUB_URL  
            DOCKER_HUB_CREDENTIAL_ID = 'DOCKER_HUB_CREDENTIAL'  
            DOCKER_IMAGE_NAME =  OPERATION_ENV + '-' + PROJECT_NAME  
  
            // SSH  
            SSH_CREDENTIAL_ID = OPERATION_ENV.toUpperCase() + '_SSH'  
            SSH_PORT_CREDENTIAL_ID = OPERATION_ENV.toUpperCase() + '_SSH_PORT'  
            SSH_HOST_CREDENTIAL_ID = OPERATION_ENV.toUpperCase() + '_SSH_HOST'
            
            // PORT  
            PORT_PROPERTIES_FILE = 'application-' + OPERATION_ENV + '.yml'  
        }  
    }

위 처럼 작성함녀 PORT_PROPERTIES_FILEapplication-dev.yml이나 application-prod.yml과 같은 형태로 문자열이 담간다.

이제 환경에 맞는 yml파일에서 port를 추출해 변수에 담는 Stage를 Build Stage 전에 추가해준다.

1
2
3
4
5
6
7
8
stage('Parse Internal Port') {  
    steps {  
        script {  
            INTERNAL_PORT = sh(script: "yq e '.server.port' ./src/main/resources/${PORT_PROPERTIES_FILE}"  
                , returnStdout: true).trim();  
        }  
    }  
}

yq를 이용해 server.port의 정보를 INTERNAL_PORT에 저장한다. 이 정보를 가지고 실제로 ssh 접속 후 docker container를 실행할 때 사용한다.

returnStdOut은 성공시 기존 exit 0 실패시 exit 1로 리턴되는 shreturn값을 StdOut으로 받아준다. 따라서 파싱된 포트 정보를 INTERNAL_PORT에 담을 수 있게 한다.

배포 stage 작성

이제 Build & Push Docker Image stage 다음에 실제 SSH로 접속해 Docker Container를 실행하는 stage를 작성하면 된다. 해당 stage는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
stage('Deploy to Server') {  
    steps {  
        echo 'Deploy to Server'
        withCredentials([
            usernamePassword(credentialsId: DOCKER_HUB_CREDENTIAL_ID,
                                usernameVariable: 'DOCKER_HUB_ID',
                                passwordVariable: 'DOCKER_HUB_PW'),
            sshUserPrivateKey(credentialsId: SSH_CREDENTIAL_ID,
                                keyFileVariable: 'KEY_FILE',
                                passphraseVariable: 'PW',
                                usernameVariable: 'USERNAME'),
            string(credentialsId: SSH_HOST_CREDENTIAL_ID, variable: 'HOST'),
            string(credentialsId: SSH_PORT_CREDENTIAL_ID, variable: 'PORT')]) {
            
            script {
                def remote = [:]
                remote.name = OPERATION_ENV
                remote.host = HOST
                remote.user = USERNAME
                // remote.password = PW
                // remote.identity = KEY_FILE
                remote.port = PORT as Integer
                remote.allowAnyHosts = true
                
                sshCommand remote: remote, command:  
                    'docker pull ' + DOCKER_HUB_ID + '/' + DOCKER_IMAGE_NAME + ":latest"
    
                sshCommand (remote: remote, failOnError: false, 
                command: 'docker rm -f springboot')

                sshCommand remote: remote, command:
                    ('docker run -d --name springboot'
                    + ' -p 80:' + INTERNAL_PORT
                    + ' -e \"SPRING_PROFILES_ACTIVE=' + OPERATION_ENV + '\"'
                    + ' ' + DOCKER_HUB_ID + '/' + DOCKER_IMAGE_NAME  + ':latest')
            }
        }
    }
}

Jenkins의 해당 stage 동작 과정으 다음과 같다. 서버에서 접속해 docker image를 내려받고 springboot 이름으로 실행되고 있는 컨테이너를 내린다. 그리고 새로운 컨테이너를 띄운다.

서버의 SSH 접속방식이 공개키 방식이면 remote.identity의 주석을 풀면 되고, 비밀번호 방식이면 remote.password의 주석을 풀면 된다.

또한 SSH 접속 기본 포트인 22를 사용하고 있는 경우 remote.port 라인을 제거하고 SSH_PORT_CREDENTIAL_ID의 내용을 가져오는 부분을 제거하면 된다.

기존 작동하던 Docker Container를 내리는 부분은 현재 서버에 Docker Container가 실행 중이 아니라면 오류가 발생해 해당 stage가 중지되므로 failOnErrorfalse처리해주었다.

이제 실제로 서버에 배포하는 과정을 진행하기 위해 push해보자.

1
2
3
$ git add .
$ git commit -m "chore: Jenkinsfile Deploy stage 추가"
$ git push

위와 같이 커밋해주고 Jenkins에서 빌드가 잘 진행되었는지 살펴보자.

위와 같이 빌드가 잘 진행된 것을 확인할 수 있다.

위와 같이 server의 주소로 /hello URL로 접속해 보면 정상 작동을 확인할 수 있다. 이제 외부에서 나의 프로젝트에 접속이 가능해졌다.

최종 Jenkinsfile은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
pipeline {  
  
    agent any  
  
    environment {  
        GPG_SECRET_KEY = credentials('GPG_SECRET_KEY')  
    }  
  
    stages {  
        stage('Set Variables') {  
            steps {  
                echo 'Set Variables'  
                script {  
                    // BASIC  
                    PROJECT_NAME = 'cicd-test'  
                    REPOSITORY_URL = 'https://github.com/dukcode/cicd-test'  
                    PROD_BRANCH = 'main'  
                    DEV_BRANCH = 'develop'  
                    BRANCH_NAME = env.BRANCH_NAME  
                    OPERATION_ENV = BRANCH_NAME.equals(PROD_BRANCH) ? 'prod' : 'dev'  
  
                    // DOCKER  
                    DOCKER_HUB_URL = 'registry.hub.docker.com'  
                    DOCKER_HUB_FULL_URL = 'https://' + DOCKER_HUB_URL  
                    DOCKER_HUB_CREDENTIAL_ID = 'DOCKER_HUB_CREDENTIAL'  
                    DOCKER_IMAGE_NAME =  OPERATION_ENV + '-' + PROJECT_NAME  
  
                    // SSH  
                    SSH_CREDENTIAL_ID = OPERATION_ENV.toUpperCase() + '_SSH'  
                    SSH_PORT_CREDENTIAL_ID = OPERATION_ENV.toUpperCase() + '_SSH_PORT'  
                    SSH_HOST_CREDENTIAL_ID = OPERATION_ENV.toUpperCase() + '_SSH_HOST'  
  
                    // PORT  
                    PORT_PROPERTIES_FILE = 'application-' + OPERATION_ENV + '.yml'  
                }  
            }  
        }  
  
        stage('Git Checkout') {  
            steps {  
                echo 'Checkout Remote Repository'  
                git branch: "${env.BRANCH_NAME}",  
                url: REPOSITORY_URL  
            }  
        }  
  
        stage('Git Secret Reveal') {  
            steps {  
                echo 'Git Secret Reveal'  
            sh(script:  
                ('gpg --batch --import ' + GPG_SECRET_KEY + ' && '  
                + ' git secret reveal -f'))  
            }  
        }  
  
        stage('Parse Internal Port') {  
            steps {  
                script {  
                    INTERNAL_PORT = sh(script: "yq e '.server.port' ./src/main/resources/${PORT_PROPERTIES_FILE}"  
                        , returnStdout: true).trim();  
                }  
            }  
        }  
  
        stage('Build') {  
            steps {  
                echo 'Build With gradlew'  
                sh '''  
                    ./gradlew clean build
                '''
                    }  
        }  
  
        stage('Build & Push Docker Image') {  
            steps {  
                echo 'Build & Push Docker Image'  
                withCredentials([usernamePassword(  
                        credentialsId: DOCKER_HUB_CREDENTIAL_ID,  
                        usernameVariable: 'DOCKER_HUB_ID',  
                        passwordVariable: 'DOCKER_HUB_PW')]) {  
  
                    script {  
                        docker.withRegistry(DOCKER_HUB_FULL_URL,  
                                            DOCKER_HUB_CREDENTIAL_ID) {  
                        app = docker.build(DOCKER_HUB_ID + '/' + DOCKER_IMAGE_NAME)  
                        app.push(env.BUILD_ID)  
                        app.push('latest')  
                        }  
  
                    sh(script: """  
                        docker rmi \$(docker images -q \  
                        --filter \"before=${DOCKER_HUB_ID}/${DOCKER_IMAGE_NAME}:latest\" \                        ${DOCKER_HUB_URL}/${DOCKER_HUB_ID}/${DOCKER_IMAGE_NAME})      """, returnStatus: true)  
                    }  
                }  
            }  
        }  
  
        stage('Deploy to Server') {  
            steps {  
                echo 'Deploy to Server'  
                withCredentials([  
                    usernamePassword(credentialsId: DOCKER_HUB_CREDENTIAL_ID,  
                                        usernameVariable: 'DOCKER_HUB_ID',  
                                        passwordVariable: 'DOCKER_HUB_PW'),  
                    sshUserPrivateKey(credentialsId: SSH_CREDENTIAL_ID,  
                                        keyFileVariable: 'KEY_FILE',  
                                        passphraseVariable: 'PW',  
                                        usernameVariable: 'USERNAME'),  
                    string(credentialsId: SSH_HOST_CREDENTIAL_ID, variable: 'HOST'),  
                    string(credentialsId: SSH_PORT_CREDENTIAL_ID, variable: 'PORT')]) {  
  
                    script {  
                        def remote = [:]  
                        remote.name = OPERATION_ENV  
                        remote.host = HOST  
                        remote.user = USERNAME  
                        remote.password = PW  
                        // remote.identity = KEY_FILE  
                        remote.port = PORT as Integer  
                        remote.allowAnyHosts = true  
  
                        sshCommand remote: remote, command:  
                            'docker pull ' + DOCKER_HUB_ID + '/' + DOCKER_IMAGE_NAME + ":latest"  
  
                        sshCommand (remote: remote, failOnError: false,  
                        command: 'docker rm -f springboot')  
  
                        sshCommand remote: remote, command:  
                            ('docker run -d --name springboot'  
                            + ' -p 80:' + INTERNAL_PORT  
                            + ' -e \"SPRING_PROFILES_ACTIVE=' + OPERATION_ENV + '\"'  
                            + ' ' + DOCKER_HUB_ID + '/' + DOCKER_IMAGE_NAME + ':latest')  
                    }  
                }  
            }  
        }  
  
    }  
}

문제점

해당 서버에 docker container가 잘 올라가 외부에서 접속이 가능하다. 하지만 현재 배포 방식은 문제가 존재한다.

서버에서 배포가 일어날 때 docker container가 내려가고 다시 올라가는 동안은 서버 접속이 불가능하다. 즉, 무중단 배포가 필요하다.

다음 포스트부터 Blue Green 무중단 배포 방식을 적용해 사용자가 배포가 일어나도 중단없이 서비스를 이용할 수 있게 해보자.

또한 추가기능도 넣고 싶다.

  1. 매 스테이지가 끝날 때, DiscordSlack같은 SNS로 빌드 실패 또는 성공여부를 받고싶다.
  2. 소스파일을 정적분석하여 기준에 충족하지 못하면 빌드 과정을 멈추고 싶다.

위에서 부족한 부분과 추가적인 기능을 다음 포스트부터 하나하나 구현해보자.

댓글남기기