IT/PaaS

쿠버네티스 클러스터에 React 서비스 컨테이너 배포

twofootdog 2020. 1. 21. 18:16

이번 글에서는 CentOS(On-Premise환경)내 구축된 쿠버네티스 클러스터 위에 React 서비스를 컨테이너로 배포하는 방법에 대해 알아볼 것이다.

보통 Web 서비스는 Scale-out 관련 요구사항이 없기 때문에 컨테이너화 하는 경우는 드물다고 들었지만(?), 스터디 프로젝트를 하며 쿠버네티스를 공부하면서 Web 서비스도 컨테이너화 하여 쿠버네티스 클러스터에 적용해 보았기에 이렇게 블로그로 남긴다. (갑자기 존댓말 해서 이상한데 혹시라도 다른 사례가 있으시다면 댓글로 남겨주시면 큰 도움이 될 것 같습니다. 감사합니다.)

 

 

 

1. 사전 준비사항

  • React로 개발된 소스 1본
  • CentOS(On-Premise환경)내 구축된 쿠버네티스 클러스터
  • CI/CD 파이프라인(로컬pc - gitlab - jenkins - docker hub - 쿠버네티스 클러스터) 생성 완료. 파이프라인 생성 방법은 스프링부트 애플리케이션 CI/CD 파이프라인을 제작하는 https://twofootdog.tistory.com/11 https://twofootdog.tistory.com/13 https://twofootdog.tistory.com/14 를 참고하기 바란다. Dockerfile, Jenkinsfile, yaml파일을 생성하는 부분을 제외한 모든 부분이 동일하다.
  • React는 Docker 배포에는 nginx를 활용할 예정

 

2. Dockerfile, Jenkinsfile, yaml파일 적용

스프링부트 애플리케이션 배포와 다른 점은(CI/CD 파이프라인이 구축되었다는 가정하에) Dockerfie, Jenkinsfile, yaml파일이 전부다. Jenkins/gitlab/dockerhub의 설정 및 연동은 완료되었다고 가정하고 Dockerfie, Jenkinsfile, yaml파일을 작성하는 법에 대해 알아보자.

 

2-1. /etc/nginx/conf.d/default.conf 용 신규 conf파일 생성

우선 첫번째로 React 프로젝트 최상위 디렉토리에 front-app.conf라는 설정파일을 생성한다(파일명은 아무렇게나 지어도 전혀 상관이 없다). front-app.conf를 별도로 만들어 놓은 이유는 React는 SPA 방식이기 때문에 index.html만을 사용하지만 nginx의 경우에는 URL에 맞는 html파일을 찾으려 하기 때문에 특정 URL로 페이지에 접속하게 되면 404 에러가 날 수 있다. 때문에 아래와 같이 conf파일을 만들어 놓고, Docker로 배포할 때 해당 conf파일을 컨테이너 내 nginx 디렉토리로 옮겨놓을 것이다. 

front-app.conf : 

server {
    listen       80;
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
        try_files $uri $uri/ /index.html;
    }
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {  
        root   /usr/share/nginx/html;
    }
}

 

2-2. Dockerfile 작성

다음으로 React 프로젝트 최상위 디렉토리에 Dockerfile을 작성한다. 

Dockerfile : 

FROM nginx:latest

RUN rm /etc/nginx/conf.d/default.conf

COPY ./front-app.conf /etc/nginx/conf.d/default.conf

COPY ./build /usr/share/nginx/html 

파일에 대해 간략히 설명하자면 nginx 도커 이미지 기반으로 도커 빌드를 수행할 것이고, 커네이너 내에 default로 깔려 있는 default.conf 설정파일을 제거하고 React 소스 최상위 디렉토리에 있는(좀전에 만들어 놓은) front-app.conf 파일을 default.conf 설정파일 위치로 옮겨놓는다. 

그리고 ./build에 있는 React 소스를(npm build나 yarn 명령어를 수행하면 최적화된 소스가 ./build 디렉토리로 이동한다)  nginx 컨테이너의 /usr/share/nginx/html 디렉토리로 옮겨놓는다(nginx 의 /usr/share/nginx/html 위에 있는 소스가 웹페이지로 구동되므로)

 

2-3. Jenkinsfile 작성

다음으로 React 프로젝트 최상위 디렉토리에 Jenkinsfile을 작성한다. Jenkinsfile은 CI/CD pipeline 의 STEP을 모아놓은 파일로 스프링부트 애플리케이션으르 배포하는 Jenkinsfile과 큰 차이가 없다. 단지 빌드하는 부분의 명령어만 조금 다를 뿐이다(스프링부트 애플리케이션 Jenkinsfile은 https://twofootdog.tistory.com/14 을 참고하면 된다). 

Jenkinsfile : 

/* pipeline 변수 설정 */
def DOCKER_IMAGE_NAME = "twofootdog/project-repo"           // 생성하는 Docker image 이름
def DOCKER_IMAGE_TAGS = "batch-visualizer-frontend-app"  // 생성하는 Docker image 태그
def DOCKER_CONTAINER_NAME = "frontend-app-container"    // 생성하는 Docker Container 이름
def NAMESPACE = "ns-project"
def SLACK_CHANNEL = "#frontend-app"
def VERSION = "${env.BUILD_NUMBER}"
def DATE = new Date();

def notifyStarted(slack_channel) {
    slackSend (channel: "${slack_channel}", color: '#FFFF00', message: "STARTED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
}
def notifySuccessful(slack_channel) {
    slackSend (channel: "${slack_channel}", color: '#00FF00', message: "SUCCESSFUL: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
}
def notifyFailed(slack_channel) {
  slackSend (channel: "${slack_channel}", color: '#FF0000', message: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
}
  
podTemplate(label: 'builder',
            containers: [
                containerTemplate(name: 'node', image: 'node:11-alpine', command: 'cat', ttyEnabled: true),
                containerTemplate(name: 'docker', image: 'docker', command: 'cat', ttyEnabled: true),
                containerTemplate(name: 'kubectl', image: 'lachlanevenson/k8s-kubectl:v1.15.3', command: 'cat', ttyEnabled: true),
                containerTemplate(name: 'scanner', image: 'newtmitch/sonar-scanner', ttyEnabled: true, command: 'cat')
            ],
            volumes: [
                //hostPathVolume(mountPath: '/home/gradle/.gradle', hostPath: '/home/admin/k8s/jenkins/.gradle'),
                hostPathVolume(mountPath: '/var/run/docker.sock', hostPath: '/var/run/docker.sock'),
                //hostPathVolume(mountPath: '/usr/bin/docker', hostPath: '/usr/bin/docker')
            ]) {
    node('builder') {
        try {
            stage('Start') {
                // Slack 메시지 전송
                notifyStarted(SLACK_CHANNEL)
            }
            stage('Checkout'){
                 checkout scm   // gitlab으로부터 소스 다운
            }
            stage('Build') {
                container('node') {
                    /* 도커 이미지를 활용하여 gradle 빌드를 수행하여 ./build/libs에 jar파일 생성 */
                    sh "npm install"
			 	    // sh "npm install --save axios"
			        sh "npm run build"
                }
            }
            stage('Inspection code') {
                container('scanner') {
                    sh "echo `pwd`"
                    sh "echo `ls`"
                    // withSonarQubeEnv('sonarqube-cluster') {
                    sh """sonar-scanner \
                            -Dsonar.projectName=batch-visualizer-frontend-app \
                            -Dsonar.projectKey=batch-visualizer-frontend-app \
                            -Dsonar.projectBaseDir=/home/jenkins/agent/workspace/batch-visualizer-frontend-app \
                            -Dsonar.sources=./src \
                            -Dsonar.host.url=http://66.42.43.41:30002/sonar \
                            -Dsonar.login=497e2c036cbc2cd1bcc987d97ca6dfc6af1134c9
                        """
                    // }
                }
            }
            stage('Docker build') {
                container('docker') {
                    withCredentials([usernamePassword(
                        credentialsId: 'docker_hub_auth',
                        usernameVariable: 'USERNAME',
                        passwordVariable: 'PASSWORD')]) {
                            /* ./build/libs 생성된 jar파일을 도커파일을 활용하여 도커 빌드를 수행한다 */
                            sh "docker build -t ${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAGS} ."
                            sh "docker login -u ${USERNAME} -p ${PASSWORD}"
                            sh "docker push ${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAGS}"
                    }
                }
            }
            stage('Run kubectl') {
                container('kubectl') {
                    withCredentials([usernamePassword(
                        credentialsId: 'docker_hub_auth',
                        usernameVariable: 'USERNAME',
                        passwordVariable: 'PASSWORD')]) {
                            /* namespace 존재여부 확인. 미존재시 namespace 생성 */
                            sh "kubectl get ns ${NAMESPACE}|| kubectl create ns ${NAMESPACE}"
                        
                            /* secret 존재여부 확인. 미존재시 secret 생성성 */
                            sh """
                                kubectl get secret my-secret -n ${NAMESPACE} || \
                                kubectl create secret docker-registry my-secret \
                                --docker-server=https://index.docker.io/v1/ \
                                --docker-username=${USERNAME} \
                                --docker-password=${PASSWORD} \
                                --docker-email=ekfrl2815@gmail.com \
                                -n ${NAMESPACE}
                            """

                            /* k8s-deployment.yaml 의 env값을 수정해준다(VERSION과 DATE로). 배포시 수정을 해주지 않으면 변경된 내용이 정상 배포되지 않는다. */
                            sh "echo ${VERSION}"
                            sh "sed -i.bak 's#VERSION_STRING#${VERSION}#' ./k8s/k8s-deployment.yaml"
                            sh "echo ${DATE}"
                            sh "sed -i.bak 's#DATE_STRING#${DATE}#' ./k8s/k8s-deployment.yaml"

                            /* yaml파일로 배포를 수행한다 */
                            sh "kubectl apply -f ./k8s/k8s-deployment.yaml -n ${NAMESPACE}"
                            sh "kubectl apply -f ./k8s/k8s-service.yaml -n ${NAMESPACE}"
                    }
                }
            }
            notifySuccessful(SLACK_CHANNEL)
        } catch(e) {
        /* 배포 실패 시 */
            currentBuild.result = "FAILED"
            notifyFailed(SLACK_CHANNEL)
        }
    }
}

 

만약 Slack 관련 설정과 SonarQube 관련 설정이 안되어있는 상황이라면 아래와 같이 작성해주자(Slack과 SonarQube 관련 소스만 제거해주면 된다).

Jenkinsfile : 

/* pipeline 변수 설정 */
def DOCKER_IMAGE_NAME = "twofootdog/project-repo"           // 생성하는 Docker image 이름
def DOCKER_IMAGE_TAGS = "batch-visualizer-frontend-app"  // 생성하는 Docker image 태그
def DOCKER_CONTAINER_NAME = "frontend-app-container"    // 생성하는 Docker Container 이름
def NAMESPACE = "ns-project"
// def SLACK_CHANNEL = "#frontend-app"
def VERSION = "${env.BUILD_NUMBER}"
def DATE = new Date();

// def notifyStarted(slack_channel) {
//     slackSend (channel: "${slack_channel}", color: '#FFFF00', message: "STARTED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
// }
// def notifySuccessful(slack_channel) {
//     slackSend (channel: "${slack_channel}", color: '#00FF00', message: "SUCCESSFUL: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
// }
// def notifyFailed(slack_channel) {
//   slackSend (channel: "${slack_channel}", color: '#FF0000', message: "FAILED: Job '${env.JOB_NAME} [${env.BUILD_NUMBER}]' (${env.BUILD_URL})")
// }
  
podTemplate(label: 'builder',
            containers: [
                containerTemplate(name: 'node', image: 'node:11-alpine', command: 'cat', ttyEnabled: true),
                containerTemplate(name: 'docker', image: 'docker', command: 'cat', ttyEnabled: true),
                containerTemplate(name: 'kubectl', image: 'lachlanevenson/k8s-kubectl:v1.15.3', command: 'cat', ttyEnabled: true),
                // containerTemplate(name: 'scanner', image: 'newtmitch/sonar-scanner', ttyEnabled: true, command: 'cat')
            ],
            volumes: [
                //hostPathVolume(mountPath: '/home/gradle/.gradle', hostPath: '/home/admin/k8s/jenkins/.gradle'),
                hostPathVolume(mountPath: '/var/run/docker.sock', hostPath: '/var/run/docker.sock'),
                //hostPathVolume(mountPath: '/usr/bin/docker', hostPath: '/usr/bin/docker')
            ]) {
    node('builder') {
        try {
            // stage('Start') {
            //     // Slack 메시지 전송
            //     notifyStarted(SLACK_CHANNEL)
            // }
            stage('Checkout'){
                 checkout scm   // gitlab으로부터 소스 다운
            }
            stage('Build') {
                container('node') {
                    /* 도커 이미지를 활용하여 gradle 빌드를 수행하여 ./build/libs에 jar파일 생성 */
                    sh "npm install"
			 	    // sh "npm install --save axios"
			        sh "npm run build"
                }
            }
            // stage('Inspection code') {
            //     container('scanner') {
            //         sh "echo `pwd`"
            //         sh "echo `ls`"
            //         // withSonarQubeEnv('sonarqube-cluster') {
            //         sh """sonar-scanner \
            //                 -Dsonar.projectName=batch-visualizer-frontend-app \
            //                 -Dsonar.projectKey=batch-visualizer-frontend-app \
            //                 -Dsonar.projectBaseDir=/home/jenkins/agent/workspace/batch-visualizer-frontend-app \
            //                 -Dsonar.sources=./src \
            //                 -Dsonar.host.url=http://66.42.43.41:30002/sonar \
            //                 -Dsonar.login=497e2c036cbc2cd1bcc987d97ca6dfc6af1134c9
            //             """
            //         // }
            //     }
            // }
            stage('Docker build') {
                container('docker') {
                    withCredentials([usernamePassword(
                        credentialsId: 'docker_hub_auth',
                        usernameVariable: 'USERNAME',
                        passwordVariable: 'PASSWORD')]) {
                            /* ./build/libs 생성된 jar파일을 도커파일을 활용하여 도커 빌드를 수행한다 */
                            sh "docker build -t ${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAGS} ."
                            sh "docker login -u ${USERNAME} -p ${PASSWORD}"
                            sh "docker push ${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAGS}"
                    }
                }
            }
            stage('Run kubectl') {
                container('kubectl') {
                    withCredentials([usernamePassword(
                        credentialsId: 'docker_hub_auth',
                        usernameVariable: 'USERNAME',
                        passwordVariable: 'PASSWORD')]) {
                            /* namespace 존재여부 확인. 미존재시 namespace 생성 */
                            sh "kubectl get ns ${NAMESPACE}|| kubectl create ns ${NAMESPACE}"
                        
                            /* secret 존재여부 확인. 미존재시 secret 생성성 */
                            sh """
                                kubectl get secret my-secret -n ${NAMESPACE} || \
                                kubectl create secret docker-registry my-secret \
                                --docker-server=https://index.docker.io/v1/ \
                                --docker-username=${USERNAME} \
                                --docker-password=${PASSWORD} \
                                --docker-email=ekfrl2815@gmail.com \
                                -n ${NAMESPACE}
                            """

                            /* k8s-deployment.yaml 의 env값을 수정해준다(VERSION과 DATE로). 배포시 수정을 해주지 않으면 변경된 내용이 정상 배포되지 않는다. */
                            sh "echo ${VERSION}"
                            sh "sed -i.bak 's#VERSION_STRING#${VERSION}#' ./k8s/k8s-deployment.yaml"
                            sh "echo ${DATE}"
                            sh "sed -i.bak 's#DATE_STRING#${DATE}#' ./k8s/k8s-deployment.yaml"

                            /* yaml파일로 배포를 수행한다 */
                            sh "kubectl apply -f ./k8s/k8s-deployment.yaml -n ${NAMESPACE}"
                            sh "kubectl apply -f ./k8s/k8s-service.yaml -n ${NAMESPACE}"
                    }
                }
            }
            // notifySuccessful(SLACK_CHANNEL)
        } catch(e) {
        /* 배포 실패 시 */
            currentBuild.result = "FAILED"
            // notifyFailed(SLACK_CHANNEL)
        }
    }
}

 

2-4. yaml파일 작성

다음으로 쿠버네티스 용 yaml파일을 작성해보자. yaml파일은 React 프로젝트 최상위디렉토리 밑에 k8s란 디렉토리를 만들어서 작성할 것이다. yaml파일은 스프링부트와 동일하게 deployment용 yaml과 service용 yaml파일이 필요하다.

./k8s/k8s-deployment.yaml : 

#apiVersion: apps/v1beta2 # for versions before 1.8.0 use apps/v1beta1
# apiVersion: extensions/v1beta1
apiVersion: apps/v1
kind: Deployment
metadata:
  name: batch-visualizer-frontend-app-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: batch-visualizer-frontend-app
  template:
    metadata:
      labels:
        app: batch-visualizer-frontend-app
    spec:
      containers:
        - name: batch-visualizer-frontend-app
          image: twofootdog/project-repo:batch-visualizer-frontend-app
          ports:
            - containerPort: 80
          imagePullPolicy: Always
          env:
            - name: VERSION
              value: 'VERSION_STRING'
            - name: DATE
              value: 'DATE_STRING'
      imagePullSecrets:
        - name: my-secret

 

./k8s/k8s-service.yaml : 

apiVersion: v1
kind: Service
metadata:
  name: batch-visualizer-frontend-app-service
spec:
  ports:
    - name: "8083"
      port: 8083
      targetPort: 80
  selector:
    app: batch-visualizer-frontend-app
  type: NodePort

 

4. React 서비스 TEST

설정파일을 모두 생성했으니, React 서비스를 배포해보자. 그러면 다음과 같이 pod와 서비스가 올라간 것을 확인할 수 있다.

 

그 다음 React 서비스의 외부포트번호(이 글에선 32151)를 확인한 후 http://[서버ip]:[외부포트번호]로 접속해보자. 

만들어 놓은 React 웹페이지가 정상적으로 뜨면 성공이다!

 

 

 

참고

https://www.barrydobson.com/post/react-router-nginx/

 

Configuring Nginx for React Router

This is a quick note on configuring Nginx to correctly proxy requests when using React router.

www.barrydobson.com