티스토리 뷰

docker-compose를 이용하여 로컬 개발환경 구성하기

 


배경

최근 Gmarket Member Engineering 팀에서 Quilt - 로그인 개선 프로젝트를 진행하였습니다.
기존 닷넷 기반의 레거시 어플리케이션을 Java + Container 기반으로 전환하는 것을 시작으로, 수년간 쌓여온 기술 부채를 해결하고 보다 나은 회원 인증 체계를 개발하기 위한 첫 발을 내디뎠습니다.
이를 위하여 저희 팀에서는 지속 가능 하고 확장 가능한 개발환경 구성을 고민하였고, docker를 로컬 개발환경 구성에 이용했습니다.
그동안 Java 프로젝트 구성을 위해 1~2개의 port를 사용하는 어플리케이션들을 로컬에서 구동하고 디버깅하는 방식으로 진행했었습니다.

 

그러나 MSA기반의 로컬 개발 환경에서는 어플리케이션마다 포트를 서로 충돌되지 않게 설정해야 하고, 상황에 필요한 미들웨어들을 초기 설치하고 설정을 관리해야 하는데에 어려움이 있었습니다.
이런 어려움은 개인마다 로컬 개발환경을 조금 다르게 구성할 수 있고 이로 인해 서로 다른 개발 경험을 하게 되며, 초기 개발 환경 구성이 번거로워지는 등 개발 생산성을 저해하는 요소가 될 수 있습니다.

 

그래서 여러 개의 도커 컨테이너를 묶어서 관리할 수 있는 docker compose[1]를 이용하여 언제 어디서나 동일한 환경을 재현할 수 있고 디버깅할 수 있는 환경을 구성하였고, 본문을 통하여 그 경험을 소개해 드리고자 합니다.

 


개발환경을 구성해보자

1. spring boot 프로젝트 생성하기

아래는 본문에서 설명할 techblog-backend 샘플 어플리케이션의 구성도입니다. 우리는 로컬에서 운영과 동일한 환경을 docker-compose를 이용하여 구성할 예정입니다.


해당 환경의 특징은 아래와 같습니다.

1) 4개의 인스턴스가 각각의 port로 구동되어야 합니다. 이중 두 개의 개발 어플리케이션(subscriber, api)은 8080 포트를 중복으로 사용합니다.
2) 나머지는 오픈소스 미들웨어로 pub/sub기능을 위한 redis와 각종 proxy를 구성할 nginx 입니다.
3) hostname pub.gmarket.co.kr, sub.gmarketc.co.kr을 hosts파일에 정의하여 목적에 맞는 어플리케이션에 접근합니다.

  127.0.0.1 pub.gmarket.co.kr
  127.0.0.1 sub.gmarket.co.kr

Fig.1 - 어플리케이션 구성도

 


2. docker-compose.yml 생성하기

Fig.1 - 어플리케이션 구성도 는 하나의 프로젝트이며 멀티 프로젝트입니다. 우선 Intellij 이용하여 해당 프로젝트를 생성해 보겠습니다.


전체 소스는 github에서 확인 가능합니다. : https://github.com/jayjlee29/gmarket-techblog-backend
이중 핵심이 되는 docker-compose.yml 파일을 살펴보겠습니다.

  version: '3.9'
  services:
    builder:
      image: azul/zulu-openjdk:11
      volumes:
        - .:/opt/build
        - type: volume
          source: app_home
          target: /opt/app
          volume:
            nocopy: true
        - type: volume
          source: gradle_home
          target: /opt/.gradle:rw
      working_dir: /opt/build
      command: "/opt/build/gradlew copyDeps --gradle-user-home=/opt/.gradle -x test"
    nginx:
      image: nginx
      restart: always
      volumes:
        - ./nginx.conf:/etc/nginx/nginx.conf
      ports:
        - 80:80
        - 5005:5005
        - 5006:5006
    redis:
      image: redis:6.2.6
    subscriber:
      build:
        context: subscriber
        dockerfile: Dockerfile
      volumes:
        - app_home:/opt/app
      depends_on:
        nginx:
          condition: service_started
  #      builder:
  #        condition: service_completed_successfully
        redis:
          condition: service_started
      restart: always
    api:
      build:
        context: api
        dockerfile: Dockerfile
      volumes:
        - app_home:/opt/app
      depends_on:
        nginx:
          condition: service_started
  #      builder:
  #        condition: service_completed_successfully
        redis:
          condition: service_started
      restart: always
  volumes:
    gradle_home:
    app_home:
  • docker-compose.yml 설명
    • docker-compose에 정의된 service는 builder, nginx, redis, subscriber, api 5개로 구성되어 있습니다. 빌더 + 어플리케이션 서버군 이라고 생각하시면 됩니다 기능은 다음과 같습니다.
    • builder : api, subscriber의 빌드를 담당합니다. build.gradle를 확인해보시면, 최종적으로 app_home이라는 docker volume의 /opt/app/{api/subscriber}/classes 경로에 jar와 *.class파일이 복사 됩니다.
    builder:
      image: azul/zulu-openjdk:11
      volumes:
        - .:/opt/build
        - type: volume
          source: app_home
          target: /opt/app
          volume:
            nocopy: true
        - type: volume
          source: gradle_home
          target: /opt/.gradle:rw
      working_dir: /opt/build
      command: "/opt/build/gradlew copyDeps --gradle-user-home=/opt/.gradle -x test"
    • nginx : proxy를 담당하고 있습니다. api, subscriber를 하나의 포트(80)를 통해서 라우팅 해줍니다. 또한 Remote Debugging을 위하여 5005, 5006을 forward 해주게 됩니다.
    nginx:
      image: nginx
      restart: always
      volumes:
        - ./nginx.conf:/etc/nginx/nginx.conf
      ports:
        - 80:80
        - 5005:5005
        - 5006:5006
    • redis : 샘플 techblog-backend 프로젝트의 pub/sub를 지원할 미들웨어입니다.
    redis:
      image: redis:6.2.6
    • api/subscriber : techblog-backend의 샘플 어플리케이션 입니다. 기능은 간단합니다. api는 publish하고 subscriber는 redis의 topic을 subscribe합니다. 또한 subscriber는 api형태의 long pulling형태로 샘플이 개발되었습니다.
    subscriber:
      build:
        context: subscriber
        dockerfile: Dockerfile
      volumes:
        - app_home:/opt/app
      depends_on:
        nginx:
          condition: service_started
        redis:
          condition: service_started
      restart: always
    api:
      build:
        context: api
        dockerfile: Dockerfile
      volumes:
        - app_home:/opt/app
      depends_on:
        nginx:
          condition: service_started
        redis:
          condition: service_started
      restart: always
    • docker volumes은 gradle_home, app_home을 생성했습니다.
      • gradle_home은 반복되는 빌드시 사용될 gradle repo입니다.
      • app_home은 위에서 언급한 대로 api, subscriber가 builder를 통하여 생성된 결과물(lib, class)이 /opt/app/{api/subscriber}/classes 경로에 복사됩니다.
    volumes:
      gradle_home:
      app_home:
    • 상세 설명에는 없지만 전체 docker-compose.yml의 subscriberapi service의 depends_on을 보시면 builder가 주석되어 있는 것을 보실 수 있습니다. 일부러 남겨놓은 것인데요 만일 docker compose up을 통해서 빌드 후 어플리케이션을 동작시키기 원한다면 주석을 해제하시면 됩니다. 참고로 알아두시면 도움이 됩니다.
      • condition의 service_completed_successfully는 builder service가 동작 후 완전 종료된다면 subscriber, api가 동작한다는 의미입니다. (docker compose - depends_on[3])
    builder:
      condition: service_completed_successfully

 


3. api, subscriber를 위한 Dockerfile 정의하기

  • docker-compose.yml 명세의 api, subscriber service를 살펴보면 각각의 contextDockerfile이 지정된 것을 확인하실 수 있습니다.
  • 이것은 redis, nginx와는 다르게 docker-compose에서 직접 이미지를 다운로드하여 service를 생성하는 것이 아닌 지정된 Dockerfile을 이용하여 service image를 생성합니다.
  • Subscriber Dockerfile 예시
FROM azul/zulu-openjdk:11

ENV APPDIR=/opt/app/subscriber/classes
WORKDIR ${APPDIR}
ENV JAVA_DEBUG_OPT="-Xdebug -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
CMD java ${JAVA_DEBUG_OPT} -Djava.security.egd=file:/dev/./urandom  -cp .:${APPDIR}/* com.gmarket.techblog.backend.subscriber.SubscriberApplication
EXPOSE 8080 5005
  • Subscriber Dockerfile 설명
    • subscriber dockerfile을 예시로 설명드리겠습니다.
    • FROM azul/zulu-openjdk:11는 사용할 이미지 정보를 나타냅니다. builder service의 이미지도 동일한 이미지가 지정된 것을 확인하실 수 있고 build와 runtime시 동일한 환경을 사용하는 것을 알 수 있습니다.
    • ENV APPDIR=/opt/app/subscriber/classes를 통하여 환경변수 APPDIR을 지정하고, 해당 경로는 각 어플리케이션의 build.gradle의 copyDeps task 정의된 경로와 동일합니다. 또한 docker-compose에 정의된 volumes app_home을 이용한다는 것을 확인 하실 수 있습니다.
      이는 자신의 개발 PC 즉 host os의 파일시스템이 아닌 docker volume를 통하여 빌드된 결과 파일 격리되어 관리 되는 것을 아실수 있고, docker를 통해서 관리가 가능합니다.
    • CMD를 통하여 docker run시 기본으로 실행할 명령을 foreground로 실행 시킵니다.
    • EXPOSE {PORT} ... 는 동일한 docker network상에서 다른 container와 통신하기 위하여 PORT를 공개 하는것입니다. 만일 host와도 통신이 필요하시면 추가로 docker run ...-p 옵션을 이용하거나, docker-compose.yaml{service}.ports forward 설정을 정의해야 합니다.

 


4. 어플리케이션 실행하기

이제 어플리케이션을 실행하기 위해서는 terminal에서 docker-compose cli[2]를 이용하여 실행시켜야 합니다.

  1. 어플리케이션 빌드하기
docker compose up builder
  1. 어플리케이션 실행하기
docker compose up api subscriber redis nginx

 


5. 디버깅하기

  • 디버깅은 Intellij을 이용하려 합니다. Intellij가 아닌 다른 IDE에서도 Remote Debug 기능을 지원한다면 사용 가능합니다.
  • Run Configuration > Remote JVM Debug을 통하여 api와 subscriber 디버그 항목을 생성하고 아래 그림처럼 설정 해줍니다.
  • 어플리케이션을 실행한 상태에서 방금 생성한 Remote JVM Debug를 실행하고 원하는 위치에 break point 설정하면 디버그 모드를 확인할 수 있습니다.

Fig 2. Remote JVM Debug

 


6. 수정된 코드 재빌드 하기

  • builder service를 통하여 다시 빌드를 실행합니다.
docker compose start bulider
  • 새로 반영될 내용이 있다면 builder의 동작이 끝나고 /opt/app/{api/subscriber}/classes에 새로운 *.class, jar가 생성되고 build.gradle에 정의된 devtools dependency를 통하여 Auto Restart 기능에 의해 바로 확인하실 수 있습니다.
  • 수정된 소스가 동작중인 spring boot application 에 자동으로 반영되기 위하여 devtools의 auto restart기능[4]을 사용하였습니다.

 


7. 기타 docker-compose 명령어

  • docker-compose.yml이 변경되었다면 docker compose up 만으로 service가 다시 생성됩니다. 그러나 만일 api/subscriber service의 Dockerfile이 변경되었다면 아래 명령을 통하여 변경된 이미지를 재 생성 시켜주셔야 합니다.
docker compose up --build
  • 만일 docker-compose.yml이 변경되어도 만들어진 service를 재생성하고 싶지 않고 다시 구동하고 싶으시면 다음과 같이 하시면 됩니다
docker compose start
  • 만일 service 선택적으로 빌드를 윈하신다면 다음과 같이 뒤에 service명을 입력하시면 됩니다.
docker compose up --build api
  • 만일 docker compose를 통하여 생성된 모든 오브젝트(service, network, volume) 삭제하고 싶으시다면 다음과 같이 하시면 됩니다.
docker compose down
  • 만일 생성된 image까지 삭제하고 싶으시다면 -rmi 옵션을 추가하시면 됩니다.
docker compose down --rmi all

 


평가

docker-compose를 이용하여 개발환경을 구성하면서 다음과 같은 장/단점을 경험할 수 있었습니다.

  • 장점
    • 언제/어디서나, 심지어 개발 PC의 OS가 다른 상황에서도 동일한 환경구성이 가능합니다.
    • 모두 동일한 개발환경을 경험하기 때문에 개발환경에 이슈가 발생해도 소통이 쉽습니다.
    • 복잡한 환경도 스크립트화 할 수 있기 때문에 자동화가 가능하고 조작이 쉽습니다.
    • docker-compose cli를 이용하여 쉽게 애플리케이션을 관리할 수 있고 자동화가 가능합니다.
  • 단점
    • 여러 container가 동작을 하니 개발 pc의 사양이 높아야 합니다. (저희 팀 내의 mac pro m1을 사용하시는 분들은 쾌적하다고 하네요)
    • 문제 발생 또는 형상이 변경될 경우 docker 기술에 대한 이해도가 부족하면 관리하기 어렵습니다.

최근 container의 활용도가 높아지고 운영에서 container가 차지하는 비중이 점점 늘고 있습니다.
다만 개발 시에 이런 container 기술이 아직은 잘 활용되지 않는 것 같아, 저희 팀의 경험을 공유하는 자리를 가지게 되었습니다.
감사합니다.

 


참고

[1] docker compose overview : https://docs.docker.com/compose/
[2] docker compose reference : https://docs.docker.com/compose/reference/
[3] docker compose - depends_on https://docs.docker.com/compose/compose-file/compose-file-v3/#depends_on
[4] devtools - Automatic Restart : https://docs.spring.io/spring-boot/docs/2.7.9/reference/html/using.html#using.devtools.restart
[5] example : https://github.com/jayjlee29/gmarket-techblog-backend

댓글