본문 바로가기

도서/스프링으로하는 마이크로서비스 구축

도커를 사용한 마이크로서비스 배포

이번 포스팅은 저번 챕터에서 구현한 자바 애플리케이션을 도커 환경에서 실행하는 방법에 대해 학습한 내용을 토대로 글을 작성해보려고 합니다. 글은 도커가 가상머신에 비해 좋은 점과 도커 환경에서 자바 애플리케이션을 실행할 때의 문제와 해결방법을 중심으로 포스팅했습니다. 


도커 vs 가상머신

도커와 가상머신(Vritual Machine)은 모두 호스트 OS로부터 애플리케이션을 격리하고 배포하는 기술이지만 각각의 장단점을 갖고 있습니다. 도커를 사용하는 것에 제법 익숙해졌지만, 그 효용성에 대해 제대로 정리한 적은 없는 것 같다는 생각이 들었습니다. 도커는 분명 가상머신에 비해 다양한 장점을 갖고 있는 건 부정할 수 없는 사실입니다. 먼저 가상머신과 비교해서 도커의 장점에 대해 정리하자면 다음과 같습니다. 

 

 

도커의 가장 큰 장점은 가볍다는 것입니다. 여기서 말하는 '가볍다'라는 말의 의미는 더 적은 자원으로 더 많은 일을 할 수 있다 는 말과 동일합니다. 그럼 도커는 어떻게 더 적은 자원으로 더 많은 일을 할 수 있을까요? 도커는 가상머신과 다르게 호스트 운영체제의 커널을 공유하기 때문입니다. 그렇다면 커널을 공유한다는 건 뭘 의미할까요? 

 

커널이란 운영체제 내에서 하드웨어와 소프트웨어 간의 중개 역할을 담당하며, 프로세스 관리, 메모리 관리,  파일 시스템, 네트워크 등의 기능을 제공하는 역할을 합니다. 모든 운영체제는 고유한 커널을 가지고 있습니다. 

 

가상머신은 자체 운영체제와 커널을 포함합니다. 즉, 하이퍼바이저(예를 들어 VMware, Hyper-V)에 의해 호스트 시스템에서 실행되며 하드웨어를 가상화하여 독립된 시스템처럼 동작합니다. 도커는 가상머신과 다르게 호스트 운영체제의 커널을 공유하기에 때문에, 호스트 커널의 자원을 직접 사용합니다.

 

도커와 가상머신에서 커널의 공유 여부를 대중교통과 자가용을 비교함으로써 설명할 수 있습니다. 

 

대중교통과 개인차량은 다음과 같은 특징을 갖고 있습니다. 

  1. 버스는 여러 사람이 함께 이용하며, 좌석과 공간을 공유 합니다.
  2. 버스는 자가용과 동일하게 엔진을 소유하고 있지만 더 많은 사람을 운반할 수 있다는 점에서 개인 차량보다 연료 효율이 높습니다. 
  3. 버스는 정류장에서 승객을 빠르게 태우고 내릴 수 있다는 장점을 갖고 있습니다.
  4. 버스는 여러 사람이 버스요금을 지불하기에 이동하는데 비용을 분담해서 지불하고 있다고 볼 수 있지만, 개인차량은 한 사람이 이동의 모든 비용을 지불합니다. 

위의 예제에서 버스와 개인 차량을 다음과 같이 치환하면 다음과 같습니다. 

  • 도커 : 대중교통(버스)
  • 가상머신 : 자가용(개인차량)
  1. 도커는 호스트 운영체제의 커널을 공유하여(버스의 좌석과 공간을 공유하는 것과 유사) 여러 컨테이너가 효율적으로 자원을 사용합니다. 
  2. 도커 컨테이너는 독립적인 운영체제를 포함하지 않기 때문에 메모리와 디스크 사용이 가상머신에 비해 적습니다. 즉, 자원 사용 측면에서 효율이 좋습니다. 
  3.  도커 컨테이너는 가상머신처럼 부팅 과정이 필요 없어서 즉시 시작하고 종료할 수 있습니다. 
  4. 도커도 하이퍼바이저를 사용하지 않고 호스트 커널을 직접 사용하므로 성능 오버헤드가 적습니다. 

도커가 가볍다는 건, 자원을 효율적으로 사용하며, 빠르게 시작, 종료가 가능하며, 성능이 좋다는 것을 의미합니다.

 

다만, 도커가 커널을 공유하기 때문에 가볍다는 장점이 있지만, 도커는 가상머신에 비해 안정성이 낮다는 명확한 단점이 존재합니다. 


도커가 가상머신에 비해 안전성이 낮은 이유

도커는 가상머신과 다르게 호스트 운영체제의 커널을 공유한다는 특징이 있습니다. 이 문장에서 중요한 단어는 바로 공유입니다. 제가 생각했을 때 공유는 양날의 검과 같아서 효율적이다라는 측면의 이면에는 다양한 문제의 발생가능성을 내포하고 있다고 생각합니다. 여기에서 문제는 안정성인데, 도커가 가상머신에 비해 안정성이 낮은 이유는 컨테이너가 직접 커널과 상호작용하기 때문입니다. 

 

커널과 직접 상호작용한다는 것은 다른 의미로 호스트 운영체제에서 발생하는 문제들이 컨테이너에 직접 영향을 줄 수 있다는 말과 동일합니다. 호스트에서 발생하는 문제들이 컨테이너에 직접 영향을 주는 게 왜 문제가 될까요?

 

예를 들어 도커 컨테이너가 여러 대가 띄워져 있는 상황을 가정해 보겠습니다. 하나의 컨테이너가 호스트 운영체제의 커널을 통해 호스트의 자원을 과도하게 사용하고 있는 상황은 공유하고 있는 자원은 언제나 한정적이기 때문에 자원 부족 문제는 다른 컨테이너에 직접적으로 영향을 줄 수 있음을 의미합니다. 

 

가상머신은 하이퍼바이저를 통해 완전히 분리된 운영체제에서 실행되기 때문에 호스트 운영체제로부터 물리적으로 독립되어 있지만, 도커 컨테이너는 커널 네임스페이스와 cgroups(control groups)을 사용해서 논리적으로 격리되어 있기에 안정성이 떨어지는 것입니다. 

  • (커널) 네임 스페이스: 리눅스 커널에서 제공하는 격리 메커니즘으로 시스템 리소스를 서로 다른 프로세스 집합 간에 분리해서 마치 별도의 독립된 시스템처럼 보이게 한다. 
  • cgroups : 프로세스 그룹에 대한 자원 사용량을 제어하고 모니터링하는 역할을 한다. 

즉, 네임스페이스와 cgroups은 단지 도커 컨테이너 간의 논리적으로 자원을 분배하기에 물리적으로 자원을 분배하는 가상머신에 비해 안정성이 떨어지는 문제가 있습니다. 


도커에서 자바를 실행 시 문제

도커 컨테이너에서 자바 애플리케이션의 메모리 사용을 제한하지 않으면 전체 시스템 성능에 영향을 미칠 수 있다는 문제가 있습니다.

위의 그림처럼 자바 애플리케이션이 도커 컨테이너에 실행될 때 JVM이 컨테이너의 메모리 제한을 인식하지 못하고 전체 호스트 메모리를 사용하려고 시도할 수 있다는 것입니다. 그럼 왜 이와 같은 상황이 발생하는 것일까요? 다음 두 가지 이유에 기인해서 문제가 발생할 수 있습니다. 

  • 호스트 운영체제로 JVM의 직접적인 접근

JVM은 호스트 시스템의 전체 메모리에 대해 직접적으로 접근하며, 컨테이너의 제한된 메모리 양을 고려하지 않고 메모리를 요청하고 사용합니다. 따라서 도커는 이전에 설명한 것처럼 호스트 운영체제의 커널을 공유하고 있기 때문에 컨테이너와 같은 환경에서 실행될 때 컨테이너의 메모리 제한을 인식하지 못하게 되는 것입니다. 

  • JVM 메모리 관리 

JVM은 기본적으로 가용 메모리의 일정 비율을 사용하려고 하는 특징이 있습니다. 왜냐하면 JVM은 가바지 컬렉션(GC)을 통해서 메모리를 관리하는데, 충분한 메모리 여유 공간이 있으면 GC가 더 적게 발생하고 그로 인해 애플리케이션의 성능이 개선될 수 있습니다. GC는 메모리가 부족할 때 더 빈번하게 발생하기 때문에 애플리케이션 성능에 부정적인 영향을 줄 수 있기 때문에 더 많은 가용 메모리를 사용하려고 합니다.

 

자바 애플리케이션은 JVM 위에서 동작한다는 특수성 때문에 도커 컨테이너에서 자바 애플리케이션의 메모리 사용을 제한하지 않으면 전체 시스템에 영향을 줄 수 있는 가능성이 있습니다.  


마이크로서비스와 자바 애플리케이션, 그리고 도커 

이제 마이크로서비스 환경에서 자바와 도커를 사용한 환경을 구축하는 방법에 대해서 정리하려고 합니다. 

 

먼저 각 마이크로서비스를 독립된 도커 컨테이너 환경에서 실행하고 배포해야 합니다. 

FROM openjdk:12.0.2

EXPOSE 8080

ADD ./build/libs/*.jar app.jar

ENTRYPOINT ["java","-jar","/app.jar"]

 

각각의 마이크로서비스 프로젝트에서 Dockerfile을 작성합니다. 이때 주목할 점은 base 이미지의 자바 버전은 12이라는 점입니다. 자바 9 버전 이하는 컨테이너에 설정된 메모리가 아닌 도커 호스트의 메모리 기준으로 사용가능한 메모리를 계산합니다. 즉, JVM이 컨테이너의 최대 메모리 설정 및 CPU 설정을 준수하지 않을 수 있기 때문에 버전을 9 이상으로 설정하는 것을 명심합니다. 

 

다음은 애플리케이션의 설정 정보를 수정합니다. 

spring.profiles: docker

server.port: 8080

 

스프링 프로파일을 활용해 docker 환경임을 명시해 주고 포트를 설정합니다. 

spring.profiles: docker

server.port: 8080

app:
  product-service:
    host: product
    port: 8080
  recommendation-service:
    host: recommendation
    port: 8080
  review-service:
    host: review
    port: 8080

 

두 번째 application.yml파일에서 주목할 점은 각 핵심 마이크로서비스에 요청을 보낼 product-composite-service 마이크로서비스의 설정 정보입니다. 로컬 호스트에서는 각 마이크로서비스의 포트번호가 달라야 했지만, 도커 환경에서는 각각의 컨테이너에서 독립된 호스트와 포트, IP를 갖기 때문에 동일한 port를 사용할 수 있습니다. 

 

다음은 도커 컴포즈를 활용해서 각각의 마이크로서비스 설정정보를 하나로 통합해 컨테이너 환경을 구성할 준비를 합니다. 

version: '2.1'

services:
  product:
    build: microservices/product-service
    mem_limit: 350m
    environment:
      - SPRING_PROFILES_ACTIVE=docker

  recommendation:
    build: microservices/recommendation-service
    mem_limit: 350m
    environment:
      - SPRING_PROFILES_ACTIVE=docker

  review:
    build: microservices/review-service
    mem_limit: 350m
    environment:
      - SPRING_PROFILES_ACTIVE=docker

  product-composite:
    build: microservices/product-composite-service
    mem_limit: 350m
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=docker

 

주목할 점은 다음과 같습니다. 

  • 각 마이크로서비스가 실행된 도커 컨테이너 메모리 제한을 설정합니다. 도커 컴포즈를 사용할 경우는 서비스의 하위 설정으로  mem_limit를 명시적으로 지정합니다. 현재는 350 MB로 제한했습니다. 
  • product-composite-service 마이크로서비스만 외부에 노출한 포트를 지정합니다. 여기서는 8080 포트를 도커의 8080 포트와 연결해서 외부의 접근을 허용하고 있습니다. 다른 핵심 마이크로서비스로의 접근은 차단되어 있고 오직 product-compsite 서비스로만 접근을 할 수 있습니다. 

현재는 자바 9 버전 이상의 베이스 이미지를 사용했기에 JVM 메모리 옵션설정을 따로 하지 않았지만 명시적으로 제한을 두고자 하는 경우에는 다음과 같은 설정을 추가할 수 있습니다. 

 

version: '3.8'

services:
  product:
    build: microservices/product-service
    mem_limit: 350m
    environment:
      - JAVA_OPTS=-Xms256m -Xmx256m \
                  -XX:+UnlockExperimentalVMOptions \
                  -XX:+UseCGroupMemoryLimitForHeap \
                  -XX:+HeapDumpOnOutOfMemoryError \
                  -XX:HeapDumpPath=/heapdump
    volumes:
      - ./heapdump:/heapdump
  • JAVA_OPTS 환경 변수에 JVM 메모리 옵션과 컨테이너 인식 설정, 메모리 경고 설정을 모두 포함시킵니다. 
    • Xms256 m -Xmx256 m: JVM 힙 메모리 최소 및 최대 값을 각각 256MB로 설정합니다.
    • -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap: JVM이 컨테이너의 메모리 제한을 인식하도록 설정합니다.
    • -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/heapdump: 메모리 부족 시 힙 덤프를 생성하고 /heapdump 경로에 저장하도록 설정합니다.
  • volumes을 지정해서 힙 덤프 파일을 호스트 시스템에 저장하기 위한 볼륨 마운트를 설정합니다. 

마지막으로 다음 명령어를 실행해서 도커 컨테이너를 시작합니다. 

docker compose up -d

이번 장에서 중요한 점은 다음과 같습니다. 

  • 가상 머신과 도커를 비교했을 때의 도커가 갖는 장점과 단점
  • 자바 애플리케이션을 도커 환경에서 실행했을 때의 문제와 해결방법
반응형