Post

최종 프로젝트 인프라 구축④-배포환경 구성

최종 프로젝트 인프라 구축④-배포환경 구성

젠킨스와 로컬 쿠버 연동

개요

minikube 클라스터 설치가 완료되었으니, 이제 젠킨스로 이곳에 배포하는 환경을 만들어 보겠다.

젠킨스 컨테이너 설치

우선은 혹시 모를 SELinux 오류 방지를 위해

1
2
3
4
5
getenforce
# Enforcing
setenforce 0
getenforce
# Permissive

잠시 꺼두겠다.

podman파일 만들기

젠킨스 서버도 Rocky Linux 8이니까 podman으로 젠킨스 컨테이너를 설치해 주겠다.

그냥 공개되어 있는 젠킨스 이미지를 받아도 되겠지만… 아래 젠킨스파일 단계를 위해 이미지를 커스텀해줘야 한다.

1
2
3
4
5
6
7
8
9
10
11
FROM jenkins/jenkins:lts-jdk17

USER root

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    openssh-client \
    rsync && \
    rm -rf /var/lib/apt/lists/*

USER jenkins

컨테이너 파일을 만들어 봤다.

요지는 공개되어 있는 jenkins:lts-jdk17를 가져다, 거기에 openssh-client와 rsync 패키를 추가해준다는 것이다.

아무튼 이 파일을 Containerfile로 저장한 다음

1
podman build -t my-jenkins .

my-jenkins 이라는 이름으로 빌드해 주자.

이미지 작동

do6-img1

우선 컨테이너가 재시작돼도 초기화되지 않게 볼륨을 만들어 주고

1
sudo podman volume create jenkins_data

podman run은 위 사진처럼 하지 말고

1
systemctl enable podman.socket

을 먼저 해준 다음에(호스트의 podman을 컨테이너와 연결하는 소켓 활성화)

1
2
3
4
5
6
7
8
sudo podman run -d \
  -p 8080:8080 \
  -p 50000:50000 \
  -v jenkins_data:/var/jenkins_home \
  -v /run/podman/podman.sock:/var/run/docker.sock \
  --name jenkins-server \
  --restart=always \
  my-jenkins

이렇게 해주자. 위 사진과의 차이는
-v /run/podman/podman.sock:/var/run/docker.sock \이랑
my-jenkins 이 추가된 것인데,

-v /run/…은 쉽게 말해 컨테이너에서 docker 명령어 수행할 때 podman을 docker로 속이겠다라는 말이다.
(소켓을 통해, 지난 시간에 말하지 못했는데 docker와 podman은 호환된다)

젠킨스 구성

젠킨스 초기 설정은 예전 포스트처럼 하면 되는데,

그때랑 다른 점이 몇 개 있다. 우선 플러그인 설치 부분인데

do6-img2

사진에 나와있는 플러그인 말고도

  • ssh agent

이것을 추가로 설치해줘야 한다. 이유는 뒤의 젠킨스 파일에서…

그리고, 젠킨스 호스트PC(즉 192.168.56.101)에서

do6-img3

1
sudo ln -sf $(which podman) /usr/local/bin/docker

이렇게 podman 위치(which podman, 위치여서 which네!)를 /usr/local/bin/docker로 연결시키는 심볼릭 링크(바로가기) 하나를 만들어 준다.

이거는 아까 말했듯이, 소켓 연결로 인해 컨테이너에서 docker 명령어를 수행할 때 호스트의 podman 명령어를 대신 사용하는데,

do6-img4

여기 Tools 설정의 Docker 항목에서 실행 경로를 지정해줘야 하기 때문이다. 따라서 docker의 installation root에 /usr/local/bin/docker로 설정해 주면 된다.

젠킨스와 minikube 연동

이제, 젠킨스와 minikube를 연결해줘야 하는데, 젠킨스 호스트(192.168.56.101)에서

1
2
3
4
podman exec -it -u root jenkins-server /bin/bash
su - jenkins
ssh-keygen -t rsa
ssh-copy-id (클러스터서버 아이디)@192.168.56.100

jenkins 컨테이너에 접속해 jenkins 아이디로 minikube 서버에 ssh 키를 만들어 복사해 준다.

이러면 젠킨스 컨테이너는 이제 minikube 서버에 아이디 없이 접속할 수 있다!

그 다음에는 반대로 minikube를 젠킨스랑 연동시키면 되는데,

do6-img6

젠킨스 설정→Credentials→global→Add Credential 한 다음

do6-img7

위 사진처럼 Kind에 SSH Username with private key,
Username에 minikube 사용자 아이디,
Private Key에 Enter directly를 체크하고
아까 컨테이너에서 생성한 rsa_key 내용(cat /var/jenkins_home/.ssh/id_rsa)을 그대로 넣어주면 된다.
(주의할 점이 양 끝에 공백이나 개행이 있으면 안돼요!)

이 크리덴션의 이름을 잘 기억해두자…젠킨스파일 만들 때 필요하다!

젠킨스파일 만들기

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.my_project.backend.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    @GetMapping("/test")
    public String Test() {
        return "Hello Asset!";
    }
}

이렇게 간단한 api를 구현한 스프링부트 서비스를 만들었다.

application.yaml은 아래와 같다.

1
2
3
4
5
spring:
    application:
        name: backend_test
server:
    port: 8080

```java
tasks.register('getAppName') {
	doLast {
		println "${rootProject.name}" }
}
tasks.register('getAppVersion') {
	doLast {
		println "${project.version}" }
}

이 두 줄을 추가해준 다음, 서비스 루트에 k8s 폴더를 만들어

*deployment.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend-test-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: backend-test
  template:
    metadata:
      labels:
        app: backend-test
    spec:
      containers:
      - name: backend-test
        image: localhost/backend_test:0.0.1 # build.gradle에 있는 getAppName:getAppVersion 과 동일하게
        # Minikube 내부에 이미지를 직접 빌드하므로 외부 레포에서 이미지를 가져오지 않도록
        imagePullPolicy: Never
        ports:
        - containerPort: 8080

(여기서 spec-containers-image 이름을
localhost/(application.yaml의 spring:application:name 명):(build.gradle의 version 명)
이렇게 지정해줘야 한다. 그 이유는 나중에)

service.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: v1
kind: Service
metadata:
  name: backend-test-service
spec:
  selector:
    app: backend-test
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080
      nodePort: 30008
  type: NodePort

(spec: selector: app 명을 위 deployment의 app 명과 동일하게,
port 및 targetPort는 application.yaml의 port와 동일하게,
nodePort는 30000-32767 범위 내에서 아무거나, 없어도 자동으로 설정)

이 두 파일을 작성해준 다음, Containerfile을 프로젝트 루트에 생성해 준다.

1
2
3
4
5
FROM amazoncorretto:17
VOLUME /app
EXPOSE 8080
COPY build/libs/*.jar /app.jar
ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"]

컨테이너파일 설명

  • FROM amazoncorretto:17 amazoncorretto:17(jdk17의 변종) 이미지를 기반으로
  • VOLUME /app /app 디렉토리를 컨테이너 볼륨으로 설정하고
  • EXPOSE 8080 8080 포트를 사용해
  • COPY build/libs/*.jar /app.jar 빌드된 jar 파일을 컨테이너의 /app.jar로 복사하고 ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"] 컨테이너를 실행할 때 이 명령어들을 실행한다

다시 젠킨스파일 만들기

드디어 Jenkinsfile을 마찬가지로 프로젝트 루트에 생성해 준다.

#!/usr/bin/env groovy
def APP_NAME
def APP_VERSION
def DOCKER_IMAGE_NAME

pipeline {
    agent any

    environment {
        KUBE_IP = '192.168.56.100'
        KUBE_USER = 'admin'
        KUBE_SSH_KEY_ID = 'local-cluster-key' // 젠킨스 global credential key
    }

    tools {
        gradle 'Gradle 8.14.2' // 젠킨스 Tools의 Gradle 이름
        jdk 'OpenJDK 17' // 젠킨스 Tools의 JDK 이름
        dockerTool 'Docker' // 젠킨스 Tools의 Docker 이름
    }

    stages {
        stage('Set Version') {
            steps {
                script {
                    APP_NAME = sh (
                            script: "gradle -q getAppName",
                            returnStdout: true
                    ).trim()
                    APP_VERSION = sh (
                            script: "gradle -q getAppVersion",
                            returnStdout: true
                    ).trim()

                    DOCKER_IMAGE_NAME = "${APP_NAME}:${APP_VERSION}"

                    sh "echo IMAGE_NAME is ${APP_NAME}"
                    sh "echo IMAGE_VERSION is ${APP_VERSION}"
                    sh "echo DOCKER_IMAGE_NAME is ${DOCKER_IMAGE_NAME}"
                }
            }
        }

        stage('Checkout Dev Branch') {
            steps {
                // Git에서 dev 브랜치의 코드를 가져옵니다.
                checkout scm
            }
        }

        stage('Build Spring Boot App') {
            steps {
                // gradlew 권한부여
                sh 'chmod +x gradlew'
                // 디버그
                sh 'ls -al src/main/resources/'
                sh 'cat src/main/resources/application.yaml'
                // Gradlew로 빌드
                sh './gradlew clean build'
            }
        }

        stage('Copy Artifacts to Minikube') {
            steps {
                sshagent(credentials: [KUBE_SSH_KEY_ID]) {
                    sh """
                        ssh -o StrictHostKeyChecking=no ${KUBE_USER}@${KUBE_IP} 'mkdir -p ~/app'
                        rsync -avz --delete --exclude '.git/' ./ ${KUBE_USER}@${KUBE_IP}:~/app/
                    """
                }
            }
        }

        stage('Remote Podman Build & K8s Deploy') {
            steps {
                sshagent(credentials: [KUBE_SSH_KEY_ID]) {
                    withKubeConfig([credentialsId: KUBE_CONFIG_ID]) {
                        script {
                            def remoteBuildScript = """
                                cd ~/app
                                podman build -t ${DOCKER_IMAGE_NAME} .
                                kubectl apply -f k8s/
                                kubectl rollout restart deployment/backend-test-deployment
                            """

                            sh """
                                ssh -o StrictHostKeyChecking=no ${KUBE_USER}@${KUBE_IP} '${remoteBuildScript}'
                            """
                        }
                    }
                }
            }
        }
    }
}

젠킨스파일 설명

본 배포 프로세스는 이미지 레지스트리를 거치지 않고 바로 이미지를 만들어 쿠버에 집어넣는 방식이다.

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
def APP_NAME
def APP_VERSION
def DOCKER_IMAGE_NAME

pipeline {
    agent any

    environment {
        KUBE_IP = '192.168.56.100'
        KUBE_USER = 'admin'
        KUBE_SSH_KEY_ID = 'local-cluster-key' // 젠킨스 global credential key 이름
    }

    tools {
        gradle 'Gradle 8.14.2' // 젠킨스 Tools의 Gradle 이름
        jdk 'OpenJDK 17' // 젠킨스 Tools의 JDK 이름
        dockerTool 'Docker' // 젠킨스 Tools의 Docker 이름
    }

    stages {
        stage('Set Version') {
            steps {
                script {
                    APP_NAME = sh (
                            script: "gradle -q getAppName",
                            returnStdout: true
                    ).trim()
                    APP_VERSION = sh (
                            script: "gradle -q getAppVersion",
                            returnStdout: true
                    ).trim()

                    DOCKER_IMAGE_NAME = "${APP_NAME}:${APP_VERSION}"

                    sh "echo IMAGE_NAME is ${APP_NAME}"
                    sh "echo IMAGE_VERSION is ${APP_VERSION}"
                    sh "echo DOCKER_IMAGE_NAME is ${DOCKER_IMAGE_NAME}"
                }
            }
        }

이 부분은 빌드 사전설정을 진행한다. 쿠버서버 접속을 위한 KUBE_* 변수들도 등록하는 것을 볼 수 있다.
KUBE_SSH_KEY_ID는 아까 젠킨스 크리덴션으로 등록한 키 이름으로~

APP_NAMEAPP_VERSION을 gradle -q 로 가져오는 것을 볼 수 있는데,
이것 때문에 아까 build.gradle에 tasks.register 두 개를 추가하라 한 것이다.

아무튼 이 두 개로 이미지 이름(DOCKER_IMAGE_NAME)도 만든다.
이 이미지 이름을 아까 deployment.yaml에 localhost/를 붙여 동일하게 입력해야 한다.
(앞에 localhost/를 붙이는 이유는 레포에 올리지 않기 때문)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
stage('Checkout Dev Branch') {
            steps {
                // Git에서 dev 브랜치의 코드를 가져옵니다.
                checkout scm
            }
        }

        stage('Build Spring Boot App') {
            steps {
                // gradlew 권한부여
                sh 'chmod +x gradlew'
                // Gradlew로 빌드
                sh './gradlew clean build'
            }
        }

여기는 젠킨스 파이프라인과 연결된 깃허브 레포에서 소스 코드를 가져와 자바 빌드하는 프로세스이다.

1
2
3
4
5
6
7
8
9
10
stage('Copy Artifacts to Minikube') {
            steps {
                sshagent(credentials: [KUBE_SSH_KEY_ID]) {
                    sh """
                        ssh -o StrictHostKeyChecking=no ${KUBE_USER}@${KUBE_IP} 'mkdir -p ~/app'
                        rsync -avz --delete --exclude '.git/' ./ ${KUBE_USER}@${KUBE_IP}:~/app/
                    """
                }
            }
        }

이 곳이 중요한데, 젠킨스 서버에서 쿠버 서버에 ssh 접속해서 ~/app 디렉토리를 만든 다음,
다시 돌아와 rsync를 통해 빌드된 자바파일을 쿠버 서버의 ~/app/으로 복사하는 과정이다.

컨테이너 레포지토리를 만들지 않을 것이기에 이미지 빌드도 쿠버서버에서 진행할 것이기 때문이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
stage('Remote Podman Build & K8s Deploy') {
            steps {
                sshagent(credentials: [KUBE_SSH_KEY_ID]) {
                    withKubeConfig([credentialsId: KUBE_CONFIG_ID]) {
                        script {
                            def remoteBuildScript = """
                                cd ~/app
                                podman build -t ${DOCKER_IMAGE_NAME} .
                                kubectl apply -f k8s/
                                kubectl rollout restart deployment/backend-test-deployment
                            """

                            sh """
                                ssh -o StrictHostKeyChecking=no ${KUBE_USER}@${KUBE_IP} '${remoteBuildScript}'
                            """
                        }
                    }
                }
            }
        }

마지막으로 쿠버서버에 원격 접속해서 이미지를 빌드하고,
쿠버 등록(프로젝트 k8s 폴더에 있는 deployment, service)까지 하는 곳이다.

sshagent(credentials: [KUBE_SSH_KEY_ID]) ssh 키 기반으로 원격 접속하기에 젠킨스가 비밀번호 없이도 원격 접속 할 수 있다!

만약 젠킨스에서 ssh agent 플러그인을 설치하지 않았으면, sshagent 명령어 실행이 안 된다.
위에서 ssh agent를 추가 설치하라는 것이 이 때문이다.

파이프라인 생성, 깃허브 웹훅 구축

이렇게 만든 프로젝트를 깃허브 레포지토리에 푸쉬 하고..

do6-img9

젠킨스 파이프라인을 위 사진처럼 생성해 준다. 본인은 dev 브랜취를 대상으로 할 것이다.

do6-img10

그 다음, 깃허브 레포지토리에서 웹훅을 등록할 것이다.

젠킨스는 웹훅을 지원하기 때문에 Payload URL에 (젠킨스 주소)/github-webhook/ 이렇게 설정하면
dev 브랜취에 푸쉬가 생길 때 마다 바로 젠킨스에 빌드를 수행하는 진기명기를 볼 수 있다.

문제는 로컬 IP로는 웹훅 등록이 불가능하기 때문에 ngrok 등을 사용해 젠킨스 ip&포트만 퍼블릭 도메인으로 바꿔줘야 하는 수고가 있지만 말이다…

참고로 ngrok은 로컬에 특정 포트를 넣어 작동시키면 해당 PC의 그 포트만 도메인으로 공개시키는, 편리한 프로그램이다.
사용법은 여기에 친절하게 나와있습니다~

아무튼 이렇게 해서 파이프라인을 만들고 dev 브랜취에 푸쉬 하면…

do6-img11

와우! 된다…………

do6-img12

일 리가 없지.

요점은 현재 minikube 드라이버의 런타임이 docker 기반이라 발생한 오류라고 한다.

1
2
3
minikube stop
minikube delete
minikube start --driver=podman --container-runtime=crio

이렇게 minikube 런타임을 crio로 하여 다시 실행해 주자.

그 다음, 다시 푸쉬 하면…

do6-img13

이렇게 디플로이먼트&포드와 서비스가 올라오는 것을 확인할 수 있다!
(실제 프로젝트로 넣었기 때문에 이름이 달라요)

요청 테스트

1
minikube service backend-test-service(서비스명)

do6-img14

이렇게 입력하면 쿠버노드 IP가 뜬다.

이 IP로 API 요청을 보내보면…

do6-img15

이렇게 나온다!

do6-img16

문제는 서비스로 노드포트를 열어 놓아도 호스트 PC에서 접근을 할 수 없었는데,
여기를 보니까 minikube 자체가 로컬 테스트를 목적으로 한 것이라 vm 밖에서 기본적으로 접근할 수 없다고 한다…

vm에 nginx를 깔아 리버스 프록싱을 구현하던가, 아님 정통 kubeadm으로 뜯어 고칠 방안을 생각해 봐야겠다.

This post is licensed under CC BY 4.0 by the author.