Docker Compose로 개발 환경 단순화하기

2022년 12월 1일 수정

Docker를 이용해 개발 환경을 세팅하고 개발을 하면서 필요한 기능이 더 생기기 시작했다. 덕분에 도커 명령을 일일히 입력하는 것이 점점 더 귀찮아지고 있다.

이 귀찮은 점을 쉽게 해결할 수 있는 방법이 있을까? 물론 있긴 하니까 이 글을 쓰는 것이다. 아는 사람은 다 하는 멀티 도커 컨테이너 관리 도구인 Docker Compose를 활용해보자.

프로젝트 상태

이전에는 MongoDB 정도만 컨테이너를 추가하고 개발 했었는데, 점점 성능에 대한 이슈가 생기면서 Redis를 도입하게 되었다. 즉 redis 컨테이너가 개발 의존성으로 포함되게 되었다.

점점 도커 커맨드도 길어지다보니 아예 셸 스크립트를 만들어서 관리를 하게 되었다. 대충 이런 식으로 필요한 컨테이너를 시작시키는 스크립트를 작성했다.

#!/bin/bash
docker create network my-network
docker run --rm -d \
    --net my-network \
    --name mongo \
    -v ~/tmp/mongo:/data/db \
    -e MONGO_DATA_DIR=/data/db \
    mongo
docker run --rm -d \
    --net my-network \
    --name redis \
    redis

보다시피 네트워크를 하나 생성하고 여기에 맞는 mongo와 redis 컨테이너를 구동시키는 스크립트다.

MongoDB에 대한 설정이 이전보다 많이 늘었다. -v 옵션으로 데이터를 호스트 스토리지에 보관하고 -e 옵션으로 데이터 보관 장소를 가리키는 환경 변수를 설정하는 코드가 추가되었다. 컨테이너를 새로 띄울 때마다 테스트 데이터를 매번 생성하는 게 귀찮아져서 결국 내 컴퓨터에 DB 데이터를 보관하기 위해 이런 긴 명령이 만들어졌다.

거기다 앞서 이야기 한대로 redis가 추가되었다. 특별한 설정은 없지만 네트워크를 통해 접속에 무리가 없도록 설정하고 있다.

개발 환경만 로드하는 스크립트가 이 정도로 커졌다. 물론 이것은 그저 개발 환경을 시작하는 커맨드일 뿐이다.

거기다 실제 개발중인 앱 컨테이너는 별도로 실행하고 있다.

docker run --rm -it --net my-network -p 8888:8888 myapp /bin/bash

앱 컨테이너는 특성 상 자주 껐다 켤 수도 있고 이미지 빌드를 새로 할 때도 있고 다양한 요구(?)가 있어서 어쩔 수 없이 별도로 실행시키고 있는 것이다.

자 그래서 결론이 뭐냐면… 귀찮다는 것이다. 실행시키는 커맨드가 너무 길어서 귀찮다. 물론 스크립트를 작성해서 돌리면 많이 편해지긴 하지만 그래도 별도로 명령어를 입력해야 하는 경우가 종종 있다.

그래서 도커 컴포즈를 도입하여 이 귀차니즘을 해결해 보기로 했다.

구원자, 도커 컴포즈

Docker Compose는 이런 명령어 세트와 환경 변수 등의 여러 도커를 실행시키는 방법에 대한 것을 스크립트화 시켜서 자동화 하는 목적의 도구다.

따라서 별도의 스크립트를 코딩해야 한다. 프로젝트 디렉터리에 docker-compose.yml 이라는 파일을 만들어서 아래처럼 코딩해보자.

version: '3'

services:
  mongo:
    image: mongo
    volumes:
      - /Users/someuser/tmp/mongo:/data/db
    environment:
      - MONGO_DATA_DIR=/data/db
  redis:
    image: redis
  myapp:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - '8888:8888'

버전 3 규격에 맞는 Docker Compose 스펙을 만들었다. YAML로 작성된 코드이니 이 문법을 먼저 알고 있다고 가정한다.

여기서 핵심 내용은 services 라 표시한 부분이다. 도커 컴포즈에서는 각 컨테이너를 서비스라고 부른다. 여기에서는 3개의 서비스가 돌아가는데 이 중 2개가 의존성으로 사용하는 mongoredis 이고 나머지 하나는 우리의 앱 프로젝트인 myapp 이다.

각 서비스 아래에는 해당 컨테이너의 정보 및 실행 옵션을 나열한다. 각 하위 옵션에서 image 는 말 그대로 이미지 이름이다. volumes-v 옵션을 의미하고, environment-e 옵션을 풀어놓은 것이다.

myapp 의 경우는 build 라는 특수한 정보가 있는데 별도의 커스텀 이미지를 빌드할 수 있도록 설정하기 위한 정보다. 그리고 dockerfile 필드를 통해 원하는 스펙 파일 경로를 입력할 수도 있다.

그런데 빠진 옵션이 몇 가지 있다. -d--rm, --net 같은 몇 가지 옵션이 빠져있는데 이것들은 사실상 없어도 되기 때문이다. 특히 -d--rm 은 하나의 커맨드로 쉽게 관리가 되기 때문에 굳이 뺐다.

--net 같이 커스텀 네트워크를 생성해서 여기에 연결하는 옵션도 빠졌있다. 이건 도커 컴포즈가 알아서 해주기 때문에 뺀 것이다.

이제 할 일은 이미지를 빌드해 보는 것이다.

docker-compose build

이렇게 하면 알아서 build 정보가 기입되어 있는 myapp 이미지를 빌드한다. 나머지 mongoredis 는 도커 허브에 올라온 이미지를 그대로 사용하므로 알아서 빌드하지 않는다.

이제 아래 명령어 하나면 모든 서비스가 기동된다.

docker-compose up

순서는 딱히 알 수 없지만 모든 컨테이너가 알아서 실행된다. 참고로 데몬 모드로 돌리고 싶다면 -d 옵션을 추가해 주기만 하면 된다.

웹 브라우저를 이용해 하던대로 localhost:8888 로 접속해서 잘 보이는지 확인해보자. 물론 잘 보여야 정상이다.

이제 단순하게 위 커맨드 한 줄로 모든 서비스를 올릴 수 있게 되었다. 끄고 싶다면 ^C 를 누르고 잠시 기다리면 모든 컨테이너가 중지된다.

그리고 아래 한 줄이면 모든 컨테이너를 메모리에서 삭제할 수 있다.

docker-compose down

--rm 커맨드가 필요 없다는 이유가 이것 때문이다.

이제 한 줄의 커맨드로 저 많던 스크립트와 앱 구동 코드를 해결할 수 있게 되었다.

하지만 다 편한 건 아니었다

개발 과정에서 도커 컴포즈를 이용하려니 몇 가지 문제가 있다.

우선 인터랙티브 터미널이 바로 떨어지지 않는다는 점이다. 개발 중에는 앱을 자주 껐다 켜야되고 pip로 패키지를 설치하는 등 다양한 CLI 작업이 필요하다. 하지만 을 얻을 수가 없으면 이런 작업이 힘들다. 물론 restart 명령을 이용해 앱 컨테이너만 재시작하는 건 쉬운 일이지만 이것 만으로 만족하긴 힘들다.

물론 이 중 패키지 설치는 아래와 같이 별도로 접속해서 해결할 수도 있다.

docker-compose exec myapp /bin/bash

이렇게 하면 인터랙티브 셸을 얻을 수 있다.

하지만 이걸 이용하더라도 약간의 문제가 있다. 만약 컨테이너는 놔두고 앱만 재시작하려면 어떻게 해야할까?

리눅스나 유닉스를 사용할 줄 안다면 아래처럼 하는 방법을 알고 있을 것이다.

$ ps -euf
$ kill -9 PID

ps 명령으로 프로세스 아이디(PID)를 알아낸 후 kill 로 해당 프로세스를 강제로 종료시킬 수 있다.

그러면 다시 앱을 시작하면 될까?

불행히도 위의 kill 명령으로 종료하자 마자 myapp 컨테이너가 종료되어 버릴 것이다. 당연하다. 해당 컨테이너가 돌아가는 핵심적인 포그라운드(foreground) 프로세스를 죽였으니 컨테이너도 따라서 죽는 것이다.

이 외의 다른 고급 방법(?)을 생각해 볼 수도 있겠지만, 고작 앱만 재시작하고 싶은데 배보다 배꼽이 더 큰 작업이 생길 지도 모른다.

개발에 유리하도록 더 편한 방법이 없을까?

좀비(?) 컨테이너를 만들어보자

물론 방법이 없는 것은 아니다. 도커를 통해서 앱을 바로 실행시키는 것이 아니라 컨테이너가 종료되지 않게끔 하는 특수한 프로세스를 구동시키고, 나머지는 직접 컨테이너에 접속해서 앱을 구동시키거나 원하는 작업을 하는 방식이다.

가장 간단하게 생각해 볼 수 있는 방법을 아무 혹은 Python 인터프리터처럼 사용자 입력을 무작정 기다리는 프로세스를 띄워놓으면 될 것 같다. 그래서 위 docker-compose.yml 파일의 myapp 서비스 스펙을 아래처럼 수정했다.

  myapp:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - '8888:8888'
    command: ["/bin/bash"]

docker run 명령의 마지막 인자로 실행시킬 명령을 입력하는 것과 비슷하게 도커 컴포즈도 command 라는 정보를 이용해 실행시킬 커맨드를 별도로 설정할 수 있다. 물론 myapp 이미지에 ENTRYPOINT 가 없어야 커스텀 명령이 통한다는 점은 동일하다.

그런데 이렇게 하고 docker-compose up 명령으로 돌려보면… 불행히도 아래와 비슷한 메시지가 뜨면서 myapp 서비스(컨테이너)는 바로 종료되어 버린다.

myapp exited with code 0

이유는 도커 컨테이너의 특징에 있다. 도커 컨테이너는 기본적으로 터미널 인터랙티브 입력이 없다는 상황을 가정하고 동작하기 때문에 사용자 입력을 기다리는 프로세스는 바로 종료되어 버린다. 따라서 셸이 실행 되더라도 바로 종료되어 버려서 컨테이너가 죽지 않게 붙잡아주질 못한다.

다행히도 이 경우 방법이 없는 것은 아니다. 아래처럼 추가로 tty 옵션을 true 설정해주면 된다.

  myapp:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - '8888:8888'
    command: ["/bin/bash"]
    tty: true

이렇게하면 도커 옵션으로 -it 을 주고 실행시킨 것과 비슷하게 터미널에서 입력을 받을 수 있는 상태로 컨테이너를 구동시킨다. 따라서 bash가 떠서 무작정 사용자 입력을 대기하게 된다.

위 상태로 빌드할 필요 없이 다시 서비스를 구동해보자.

docker-compose up -d

위 명령으로 컨테이너는 모두 뜨지만 myapp 이미지의 커맨드가 실행되지 않았기 때문에 myapp 컨테이너에 접속해서 수동으로 앱을 실행시켜보자.

$ docker-compose exec myapp /bin/bash
$ cd /myapp
$ python main.py

잘 동작한다. 이제 앱과 앱이 돌아가는 컨테이너를 마음대로 할 수 있는 셸이 생겼다. 자유다.

본론은 여기서 끝이지만 추가로 다른 방법도 살펴보자. 대충 아래와 같은 식으로도 앱을 구동시키지 않고도 컨테이너가 죽지 않게 붙잡아 둘 수 있다.

  myapp:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - '8888:8888'
    command: ["tail", "-f", "/foo/bar/any/file"]
    tty: true

tail-f 옵션을 붙이면 특정 파일의 추적(follow) 모드로 동작한다. 그래서 해당 파일의 업데이트가 발생하는 것을 영원히 대기한다. 따라서 역시 컨테이너가 죽지 않게 붙잡아 줄 수 있다.

이 외에도 여러 방법이 있을 수 있다.

결론

도커 컴포즈를 이용하면 굳이 셸 스크립트를 쓰지 않고도 많은 컨테이너를 단순한 몇 가지 명령으로 쉽게 올리거나 내리거나 접속하거나 할 수 있다. 따라서 환경에 따라 잘 사용하면 개발을 편하게 할 수 있다.

다만 이게 전부 편하냐 하면 글쎄다. 한 번에 많은 컨테이너를 다수 켜고 꺼야하는 상황이 자주 있다면 도커 컴포즈가 당연히 편하다. 하지만 하나의 컨테이너를 다양한 방식으로 끄고 켜야하는 상황이 잦다면 오히려 더 불편할지도 모른다.

어차피 도구는 쓰는 사람 마음이고 쓰는 사람에 따라 효율성이 달라진다.