Dev

Sharing Containers Across Multiple Docker Compose

prostars 2023. 5. 29. 14:40

배경

실무에서 개발 중인 여러 프로젝트의 로컬 개발 환경에서 통합 테스트를 수행하기 위해 Docker Compose(이하 컴포즈)를 사용하고 있다. A라는 서비스를 실행하려면 MySQL, Zookeeper, Memcached가 필요하고, B라는 서비스를 실행하려면 MySQL, Zookeeper, Redis가 필요하다. 이렇게 서비스마다 필요한 외부 서비스(이하 컨테이너)들이 겹치는 것과 그렇지 않은 것이 있다.

중복되는 컨테이너들은 컴포즈의 컨테이너 네이밍 기능과 포트 매핑 기능으로 호스트에 노출하는 컨테이너의 이름과 포트를 서로 달리하여 각 프로젝트가 모두 자신만의 컨테이너를 사용하도록 구성되어 있다. 모두 A, B, C, D 이렇게 4개의 서비스가 이런 방식으로 구성되어 있고 서로 독립된 환경으로 그동안 잘 사용했다.

 

새로운 요구 사항

다른 S라는 서비스의 리모트 통합 테스트를 로컬 환경으로 끌어내리는 과정에서 새로운 요구 사항이 생겼다.

이 S 서비스는 위 4개의 하위 서비스에 트래픽을 넣는 상위 서비스로 하위 서비스를 통합 테스트용 테스트 더블로 구성하지 않는다면, 하위 서비스를 모두 로컬에서 실행해야 할 필요가 있다. 이 말은 하위 서비스들이 의존하는 컨테이너들도 모두 로컬에서 실행될 필요가 있다는 말이다. 즉, 위 구성으로 S 서비스의 로컬 통합 테스트를 위한 환경을 구성하면 14개의 컨테이너가 필요하며, 테스트에 컨테이너가 많이 필요한 것 외에도 문제가 있다.

하위 서비스 각자에 대한 통합 테스트에는 독립적인 스토리지 사용이 문제가 없지만, 상위 서비스의 통합 테스트에서는 하위 서비스들이 같은 스토리지를 봐야 하는 요구 사항이 등장한다. S 서비스가 A, B 서비스로 어떤 요청한 보냈을 때 A, B 서비스가 서로 같은 스토리지를 사용하고 있어야 정상 동작을 하는 케이스가 있다. 즉, A 서비스가 쓴 정보를 B 서비스가 읽을 수 있어야 한다.

요구 사항을 정리하면, 각 하위 서비스는 일부 컨테이너를 같이 사용해야 한다. 그렇다고 컴포즈 관리 밖에 두고 공유 컨테이너들을 개별로 관리하고 싶지는 않다.

 

문제점

컴포즈 사용 환경을 유지하면서 위의 요구 사항을 해결하고 싶지만, 컴포즈는 현재 실행하려는 컴포즈 파일에 설정된 컨테이너 이름과 같은 이름의 컨테이너가 이미 존재하면 해당 컴포즈 실행을 실패 처리한다. 그렇다고, 컴포즈의 컨테이너 그룹화 기능을 버리고 컨테이너를 개별 관리하기에는 불편함이 클 것이다.

 

해결 방향과 주의 사항

당면한 문제는 컴포즈의 'Have multiple isolated environments on a single host' 기능을 사용하여 처리할 수 있다.

하지만, 이렇게 일부 컨테이너를 여러 컴포즈에서 공유하는 구성을 한다면 컨테이너들의 생명 주기가 달라지는 문제가 있다. 프로덕션 환경에서 사용할 만한 방법은 아니다. 이런 방식은 로컬 개발 환경이나 CI 구성 정도에서 사용한다는 전제하에 고려해 볼 만하다. 컨테이너의 생명 주기가 달라지는 문제는 아래에서 다시 언급한다.

 

이 글을 읽는 데 필요한 배경지식으로 도커와 도커 컴포즈의 기본 사항을 이해하고 있다고 가정한다.

 

예제 구성

A, B 이렇게 2개의 서비스가 사용하는 컴포즈 구성 2개를 간단히 예제로 만들어서 위에서 설명한 상황을 구현한다. (예제는 여기에 있다.)
각 컴포즈 파일은 별도의 디렉토리에 둔다. 여기서는 A 서비스의 디렉토리는 'ServiceA'로 하고 A 서비스의 컴포즈 파일은 'docker-compose-A.yml'로 하고, B 서비스의 디렉토리는 'ServiceB'로 하고 B 서비스의 컴포즈 파일은 'docker-compose-B.yml'로 하겠다. 
그리고, 예제는 컨테이너들은 서로에게 독립적이고 호스트만이 컨테이너에 접근하는 구성이라고 가정한다.

 

포트 충돌

A 서비스의 docker-compose-A.yml 파일은 아래와 같다.

version: '3'
services:
  mysql:
    image: mysql:latest
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
    ports:
      - "3306:3306"
  
  zookeeper:
    image: zookeeper:latest
    ports:
      - "2181:2181"

  memcached:
    image: memcached:latest
    ports:
      - "11211:11211"

A 서비스의 컴포즈를 실행시키면 아래와 같다.

참고로 위의 스크린샷에서 명시적으로 지정하지 않았던 컴포즈 프로젝트명이 자동으로 컴포즈 파일이 위치한 디렉토리명으로 설정되어 있을 것을 확인할 수 있다. 네이밍 규칙에 대한 세부 사항은 공식 문서에서 확인할 수 있다.

 

B 서비스의 docker-compose-B.yml 파일은 아래와 같다.

version: '3'
services:
  mysql:
    image: mysql:latest
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
    ports:
      - "3306:3306"
  
  zookeeper:
    image: zookeeper:latest
    ports:
      - "2181:2181"

  redis:
    image: redis:latest
    ports:
      - "6379:6379"

이제 B 서비스의 컴포즈를 실행한다.

실행 결과 A 서비스와 중복되지 않는 redis 컨테이너는 실행에 성공했지만, mysql 컨테이너는 A 서비스의 mysql 컨테이너가 선점한 3306 포트와 충돌하여 실행에 실패한다. 에러 메시지에는 포함되지 않았지만, zookeeper 컨테이너도 포트 충돌로 실행하지 못한다. 실행 후 상태를 확인해 보면, B 서비스의 컴포즈가 실행 중이지만, 실행에 성공한 1개의 컨테이너만 실행 중인 것을 볼 수 있다. 어쨌든 중복되는 컨테이너는 실행되지 않고 스킵되었으니 원하는 결과와 비슷하다고 생각할 수도 있지만, 컴포즈의 실행 결과가 에러이기 때문에 이 상태로는 다른 프로세스와 연동하는 파이프라인을 구성하기 곤란하다.

참고로 위의 스크린샷에서 명시적으로 지정하지 않았던 컨테이너명은 {프로젝트명}-{서비스명}-{인스턴스번호} 형식으로 자동 설정된 것을 확인할 수 있다. 네이밍 규칙에 대한 세부 사항은 공식 문서에서 확인할 수 있다.

 

포트 충돌 해결

B 서비스의 컴포즈 파일에서 컨테이너들의 포트를 변경해서 포트 충돌 문제를 해결하자. B 서비스의 docker-compose-B.yml 파일에서 mysql, zookeeper의 ports 부분에서 호스트와 매핑되는 포트 번호를 아래와 같이 1씩 증가시킨 값으로 변경한다.

...
  mysql:
    ...
    ports:
      - "3307:3306"
  
  zookeeper:
    ...
    ports:
      - "2182:2181"
...

이제 B 서비스의 컴포즈를 실행한다.

위와 같이 B 서비스의 컴포즈가 정상적으로 실행되었고, servicea, serviceb 2개의 컴포즈 프로젝트가 실행 중이고, servicea, serviceb 2개의 컴포즈 프로젝트에 설정된 모든 컨테이너가 실행되어 mysql 2개, zookeeper 2개, memcached, redis 각 1개 총 6개의 컨테이너가 실행 중이다.

중복된 컨테이너의 존재를 각 서비스 테스트 환경의 독립성과의 트레이드오프로 생각한다면 그것도 좋다. 다만, 여기서는 위에서 언급한 것처럼 다른 요구 사항이 더 있었다.

그 요구 사항인 A, B 서비스를 모두 사용하는 상위 S 서비스의 로컬 통합 테스트를 위해서 A, B 서비스가 같은 스토리지를 사용해야 한다.

A, B 서비스의 컴포즈 파일에 같은 컨테이너를 사용한다고 컨테이너의 이름을 명시적으로 설정해 보자.
우선 현재 실행 중인 컴포즈를 모두 종료한다.

 

동일한 컨테이너명 설정

A 서비스의 docker-compose-A.yml 파일에 'container_name' 속성을 아래와 같이 추가한다.

version: '3'
services:
  mysql:
    container_name: local_mysql
    ...
  
  zookeeper:
    container_name: local_zookeeper
    ...

  memcached:
    container_name: local_memcached
    ...

A 서비스의 컴포즈를 다시 실행한다.

설정한 컨테이너명으로 컨테이너가 실행 중이다.

B 서비스의 docker-compose-B.yml 파일에 'container_name' 속성을 아래와 같이 추가한다.

version: '3'
services:
  mysql:
    container_name: local_mysql
    ...
  
  zookeeper:
    container_name: local_zookeeper
    ...

  redis:
    container_name: local_redis
    ...

B 서비스의 컴포즈를 다시 실행한다.

A 서비스의 컴포즈에서 실행한 컨테이너와 같은 이름의 컨테이너를 생성하지 못하고 B 서비스의 컴포즈 실행이 중단된다. 포트가 충돌했을 때와는 달리 충돌이 없는 local_redis 컨테이너도 실행되지 않는다. 하지만, local_redis 컨테이너 생성에는 성공했기 때문에 예제를 계속 진행하기 위해서 정리하고 지나가자.

 

동일한 프로젝트명 설정

위에서 포트 충돌이 없도록 수정해서 A, B 서비스의 컴포즈를 모두 실행했을 때 확인했듯이 2개의 컴포즈는 서로 다른 프로젝트명을 가지고 구성되어 있었다. 컴포즈는 프로젝트명을 지정하여 각 프로젝트명으로 독립된 환경을 구성할 수 있도록 지원한다. 각 프로젝트명으로 구분하는 것이지 컴포즈 파일로 구분하는 것이 아니다.

한 가지 테스트를 해보자. 현재 실행 중인 local_mysql 컨테이너를 삭제하고, 위에서 실행했던 A 서비스의 컴포즈를 다시 실행한다.

이미 실행 중인 A 서비스의 컴포즈 프로젝트를 다시 실행하면 이미 실행 중인 컨테이너들은 스킵하고 실행 중이지 않았던 local_mysql 컨테이너만 실행시킨다. 그러면, A, B 서비스의 컴포즈를 같은 프로젝트명으로 설정하면 방금 확인한 것과 같은 동작을 할 것이다. 확인해 보자.

다시 A 서비스의 컴포즈를 종료한다.

A 서비스의 docker-compose-A.yml 파일에 'name' 속성을 아래와 같이 추가한다.

version: '3'
name: myproject
...

A 서비스의 컴포즈를 다시 실행한다.

'myproject'라는 프로젝트명으로 컴포즈가 실행된 것을 확인할 수 있다.

B 서비스의 docker-compose-B.yml 파일에 'name' 속성을 아래와 같이 추가하고, 위에서 수정했던 포트 번호를 원복한다.

version: '3'
name: myproject
...
  mysql:
    ...
    ports:
      - "3306:3306"
  
  zookeeper:
    ...
    ports:
      - "2181:2181"
...

B 서비스의 컴포즈를 다시 실행한다.

일단, 원하는 결과를 얻었다. B 서비스의 컴포즈에서 local_redis 컨테이너만 추가로 생성하고, A 서비스의 컴포즈에서 실행시킨 local_mysql, local_zookeeper 컨테이너는 이미 실행 중이므로 스킵되었다. 2개의 컨테이너를 A, B 서비스에서 공유하므로 필요한 모든 컨테이너의 개수가 6개에서 4개로 줄었다.

경고가 하나 발생했는데 이 경고는 같은 프로젝트를 실행했지만, 이전 실행과 컨테이너 구성이 달라졌기 때문에 local_memcached 컨테이너가 B 서비스의 컴포즈의 관리 대상이 아니라고 발생한다. 구성이 달라졌다고 하는 이유는 A, B 서비스의 컴포즈 파일이 같은 프로젝트명을 사용하지만, 각 파일의 설정은 다르기 때문이다. A 서비스의 컴포즈 파일에는 설정이 있기 때문에 A 서비스의 컴포즈를 종료할 때 local_memcached 컨테이너가 종료된다. 이 경고 외에도 컴포즈를 종료할 때도 아래와 같은 에러를 볼 수 있다.

이 에러는 'myproject'라는 컴포즈 프로젝트에 할당된 네트워크를 아직 A 서비스의 local_memcached 컨테이너가 사용하고 있기 때문에 삭제할 수 없어서 발생한다. 아직 실행 중인 쪽에서 네트워크를 물고 있기 때문에 A 서비스의 컴포즈를 먼저 종료했어도 동일하게 에러가 발생한다.

경고도 발생하고 에러도 발생하지만, A, B 서비스의 컴포즈 실행 순서는 자유롭기 때문에 A, B 서비스 실행 시 각자의 컴포즈를 실행시키는 전처리가 구성되어 있다면 문제없이 사용할 수 있다. 예를 들어, Gradle의 dependsOn 설정에 composeUp을 연동한다든가 하는 식이다. 하지만, 종료 시점에는 문제가 될 수 있다. A 서비스가 실행 중인데 B 서비스가 자신의 컴포즈를 종료하면 공유 컨테이너도 종료되기 때문에 A 서비스가 영향을 받는다. 이 문제는 아래에서 해결할 수 있다.

 

공유 컴포즈 파일 분리

종료 문제의 해결과 컴포즈를 실행할 때의 경고와 종료할 때의 에러 메시지가 없는 것을 원한다면, 더 진행해 보자. 먼저 남아있는 myproject 컴포즈 프로젝트를 종료한다.

A, B 서비스가 서로 같이 사용할 컨테이너만 모아서 컴포즈 프로젝트를 새로 구성한다.

'TwoComposeFiles'라고 디렉토리를 하나 새로 만들고 그 하위 디렉토리로 'ServiceA', 'ServiceB'를 만든다. (명시적으로 컴포즈 파일의 'name' 속성에 프로젝트명을 설정한다면 디렉토리를 생성하지 않아도 되지만, 실제 프로젝트의 개별 서비스가 별도의 디렉토리에 있는 것처럼 예제 컴포즈 파일을 별도의 디렉토리에 배치하는 것으로 했다.)

TwoComposeFiles 디렉토리에 docker-compose-shared.yml 파일을 아래와 같은 내용으로 생성한다.

version: '3'
name: myproject_shared
services:
  mysql:
    image: mysql:latest
    container_name: local_mysql
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
    ports:
      - "3306:3306"
  
  zookeeper:
    image: zookeeper:latest
    container_name: local_zookeeper
    ports:
      - "2181:2181"

TwoComposeFiles/ServiceA 디렉토리에 docker-compose-A.yml 파일을 아래와 같은 내용으로 생성한다.

version: '3'
services:
  memcached:
    image: memcached:latest
    container_name: local_memcached
    ports:
      - "11211:11211"

TwoComposeFiles/ServiceB 디렉토리에 docker-compose-B.yml 파일을 아래와 같은 내용으로 생성한다.

version: '3'
services:
  redis:
    image: redis:latest
    container_name: local_redis
    ports:
      - "6379:6379"

공유 컴포즈와 A 서비스의 컴포즈를 실행한다.

이어서, 공유 컴포즈와 B 서비스의 컴포즈를 실행한다.

경고 메시지 없이 깔끔하게 모두 실행되었고, 실행 후 상태를 확인해 보니 3개의 컴포즈 프로젝트와 해당 프로젝트들이 관리하는 4개의 컨테이너가 모두 실행 중이다.

이제 공유 컴포즈와 A 서비스를 종료한다.

에러 메시지 없이 종료되었다. 이어서, 공유 컴포즈와 B 서비스의 컴포즈를 종료한다.

이번에도 원하는 결과를 얻었고, 이전과 달리 경고와 에러 없이 깔끔하게 종료된다.

 

주의 사항

이렇게 공유할 컨테이너를 별도의 docker-compose-shared.yml 파일과 같이 분리했을 때의 문제는 이 파일을 어디서 관리할 것인가 하는 것인데 이건 프로젝트 구성마다 다를 것이다. 그래도, 이렇게 별도의 컴포즈 프로젝트로 공유할 컨테이너를 관리하면 컴포즈 종료 시점에 다른 서비스의 컴포즈 프로젝트가 실행 중인지 확인하면서 공유 컴포즈의 종료를 관리할 수 있다. 물론, 서로의 존재를 알아야 한다는 의존성 문제가 생긴다. 그래서, 도입부에 언급했듯이 이 방법은 프로덕션 환경에서 사용하면 안 된다. 다시 말하지만, 로컬 개발 환경이나 CI 구성 정도에서만 사용을 고려하는 것이 좋다.

반응형