Play 애플리케이션 CI/CD (DooD 환경에서 Bind mounts)

오형택·2024년 3월 21일
0

Play-Spark

목록 보기
5/8
post-thumbnail

로컬 환경에서 Play 애플리케이션의 컨테이너 가상화를 성공적으로 끝내고 배포 환경으로 옮기기로 했다. Spring 서버와 마찬가지로 자동화를 위해 CI/CD를 적용하기로 했다.

앞선 Docker 실습에서는 sbt run 명령어를 통해 서버를 실행했는데, 사실 이는 개발/테스트 환경에서 사용하는 방식으로 소스 코드 변경 시 자동으로 애플리케이션을 리로드해주는 등 개발 과정에 유용한 기능을 제공하지만 이로 인한 성능 오버헤드가 발생할 수 있다.

프로덕션 환경에서는 아래와 같은 방식으로 Scala로 작성된 애플리케이션을 실행할 수 있다.

sbt compile   // 소스코드 컴파일 & 의존성 설치 및 빌드
sbt test      // 테스트 코드 실행
sbt dist      // 빌드된 애플리케이션 패키징
unzip target/universal/<application-name>-1.0-SNAPSHOT.zip <target-directory> // 패키징 파일 압축 해제
cd <target-directory>/bin  // 압축 해제 디렉토리로 이동
<application-name>         // 스크립트 실행

💡 패키징 후에는 프로젝트 루트 기준 /target/universal/ 폴더 밑에 .zip 파일이 생성된다.
(Spring 기준 gradle build 의 수행 후 .jar 파일이 생성되는 것과 비슷하다고 보면 된다.)
해당 .zip 파일은 애플리케이션, 모든 의존성, 실행 스크립트를 포함하고 있고, 압축 해제 후 해당 디렉토리 내 bin/ 폴더에는 두 개의 파일 <application-name>, <application-name>.bat이 존재한다.
<application-name>는 Unix 실행 스크립트이고, <application-name>.bat는 Windows용이니 OS 환경에 맞게 선택하여 실행해주면 된다.
.bat 파일의 경우, Windows cmd의 명령 길이 제한에 의해 The input line is too long. The syntax of the command is incorrect. 에러가 발생할 수 있다. 우리 배포 환경은 Linux이니 해결방법을 찾아보지는 않았다.


프로젝트 CI/CD를 담당하는 Jenkins도 컨테이너 상에서 실행되기 때문에 Play 애플리케이션을 Jenkins 상에서 자동 빌드하기 위해서는 2가지 정도의 옵션을 생각해볼 수 있었다.

  1. Jenkins 컨테이너 내에 sbt를 설치하고 빌드/테스트/패키징/압축해제(build stage) 한 후, 새로운 컨테이너에서 패키지 내 스크립트를 실행하여 배포(deploy stage)한다.
  2. build도 별도 컨테이너에서 수행하고 deploy 도 별도 컨테이너에서 수행한다.

배포 서버 내 Spring 서버의 빌드는 Jenkins 컨테이너에서 직접 수행되고 있었기 때문에 Play 서버도 비슷하게 1번 방식을 채택하려고 했다. 그러나 Jenkins 컨테이너에서 사용하고 있는 JDK는 17 버전으로 8 버전을 사용해야하는 sbt와 충돌이 생겼다. JDK 8, 17 버전을 모두 설치해놓고 sbt 빌드 단계에서만 JDK 환경 변수를 8버전으로 바꿨다가 빌드 후에 다시 돌려놓는 방식도 생각해보았으나 파이프라인 내에 spring 관련 프로세스와 문제를 일으킬 수 있을 것이 염려되어 2번 방식으로 결정했다.

build, deploy를 위해 작성한 docker-compose 파일은 각각 다음과 같다.

# build
version: "3.8"
services:
  play-app-build:
    image: sbtscala/scala-sbt:openjdk-8u342_1.7.2_2.12.16
    container_name: play-app-build
    volumes:
      - .:/app
    working_dir: /app
    command: >
      /bin/bash -c "
      sbt compile &&
      sbt test &&
      sbt dist &&
      cd /app/target/universal/ &&
      unzip -o play-spark-1.0-SNAPSHOT.zip
      "
# deploy
version: "3.8"
services:
  play-app-deploy:
    image: sbtscala/scala-sbt:openjdk-8u342_1.7.2_2.12.16
    container_name: play-app-deploy
    ports:
      - "9000:9000"
    volumes:
      - .:/app
    networks:
      - my-network
    working_dir: /app/target/universal/play-spark-1.0-SNAPSHOT/bin
    command: >
      /bin/bash -c "
      ./play-spark -Dplay.http.secret.key=${PLAY_HTTP_SECRET_KEY}"
    deploy:
      resources:
        limits:
          cpus: '2'
    environment:
      - MONGO_HOSTNAME=${MONGO_HOSTNAME}
      - MONGO_PORT=${MONGO_PORT}
      - MONGO_DATABASE=${MONGO_DATABASE}
      - MONGO_USERNAME=${MONGO_USERNAME}
      - MONGO_PASSWORD=${MONGO_PASSWORD}
      - PLAY_HTTP_SECRET_KEY=${PLAY_HTTP_SECRET_KEY}
networks:
  my-network:
    external: true

로컬 환경에서 두 compose 파일을 활용해 빌드 및 실행이 잘 되는 것을 확인하고 이어서 Jenkins 파이프라인에 아래 내용을 추가해주었다.

stage('build'){
	steps{
		// 그외 build 작업
		dir('backend/play-spark'){
			// 빌드 전에 docker-compose에서 사용할 .env 파일 복사해오기
	    	sh 'cp -r /var/jenkins_home/backend/env/.env /var/jenkins_home/workspace/dev-backend/backend/play-spark/'
	    	// 빌드 컨테이너 실행
	    	sh 'docker compose -f play-spark-build-compose.yml up -d'
	    	// jenkins에서는 컨테이너가 "실행"된 것만 확인하고 다음 step이나 stage로 넘어가므로 build가 완료(컨테이너 종료)되었는지를 확인
	    	sh 'docker wait play-app-build'
    	}
	}
}
stage('deploy'){
	steps{
		dir('backend/play-spark'){
			sh 'docker compose -f play-spark-deploy-compose.yml down'
      		sh 'docker compose -f play-spark-deploy-compose.yml up -d'
		}
	}
}

이제 됐다! 생각하고 있었는데 build 단계에서 생각지 못한 문제가 생겼다. 컨테이너 로그를 확인해봤더니 새로 생성된 build 컨테이너 내에 프로젝트 디렉토리가 제대로 bind mount되지 않고 있는 것 같았고 수많은 구글링의 결과, 컨테이너 안에서 다른 컨테이너를 실행하는 중에 발생할 수 있는 문제에 대해 알 수 있었다. 먼저 내가 예상한 컨테이너의 볼륨 마운팅 구조는 다음과 같았다.

  • 호스트 환경의 파일 시스템 경로 /x를 Jenkins 컨테이너(”A”) 내 경로 /y에 bind mount
  • A 내의 경로 /y 를 새로 생성할 컨테이너(”B”) 내 경로 /z에 bind mount
  • 결과적으로 호스트 경로 /x가 B 컨테이너 내의 경로 /z에 마운트(될 것을 기대했으나..)

현재 Jenkins 컨테이너 안에서 서버 컨테이너들을 실행하기 위해 DooD (Docker-outside-of-Docker) 방식을 채택하고 있는데, 이 경우 Jenkins 컨테이너 내에서 docker를 실행해도 해당 명령어는 컨테이너 밖 호스트 환경의 Docker Daemon에서 수행된다. 따라서 Jenkins 컨테이너 내에서 Docker CLI를 통해 새 컨테이너를 생성하고 Jenkins 컨테이너 내 경로를 마운트하려고 해도, 실제로 이 작업을 수행하는 호스트 환경의 Docker Daemon은 Jenkins 내의 파일 시스템이 아닌 호스트 환경 상의 경로와 마운트를 시도한다.

그 결과, 새 컨테이너에 마운트된 경로는 호스트 환경에서 존재하지 않는 경로로 빈 폴더가 마운트되는 것. 따라서 이를 해결하기 위해서는 compose 파일의 경로를 호스트 경로로 바꿔주어야 한다. 이에 따라 호스트 경로를 .env 파일로 빼고 compose 파일의 volume 요소를 ${SBT_PROJECT_HOST_PATH}:/app 로 변경하여 해결할 수 있었다.

🔧 Jenkins 파이프라인에서 build 컨테이너가 종료한 후에 deploy stage로 넘어가는 것은 구현되었으나, build 컨테이너가 정상적으로 수행을 완료했는가에 대한 체크가 이루어지고 있지 않다. 이에 대한 처리도 추후에 진행할 예정.

profile
개발자 지망생

0개의 댓글