Docker로 개발환경을 만들어보자

2022년 12월 1일 수정

여기 정말 까다로운 사람이 있다. 다양한 프로젝트에서 다양한 언어와 다양한 패키지와 다양한 프레임워크를 사용한다. 하지만 개발용으로 사용하는 맥이 여러 버전의 패키지로 더럽혀(?)지는 것을 매우 싫어한다.

여기까지만 보면 일단 아는 사람은 다 아는 좋은 도구가 있다. 바로 Docker다. 격리된 컨테이너로 마이크로 서비스 단위로 환경을 구축하고 개발 중인 코드도 역시 이런 격리 프로세스로 실제 환경과 동일하게 띄워서 동일하게 구동시켜 볼 수 있다.

도커(Docker)란 무엇인가

사실 Docker를 전혀 모르는 사람에게 이걸 소개하는 것은 매우 어렵다. 프로세스 격리 같은 이야기 백날 해봤자 전혀 모른다.

차라리 에뮬레이션으로 리눅스 같은 OS를 띄우고 여기에 원하는 명령이나 코드를 넣고 빠르고 가볍게 실행시킬 수 있는 환경이라고 틀린 설명이라도 하자. 이걸로 용도가 이해되면 아 사실은 에뮬레이션 같은 게 아니라 프로세스 격리임ㅋ 이렇게라도 설명할 수 있을 지도 모른다.

자 그래서 뭐라구? 다시 이야기하지만, 도커로 원하는 서비스를 띄우거나 내 코드를 리눅스 머신에서 돌리는 것 처럼 돌릴 수 있다. 그것도 초고속으로, 거기다 내 컴퓨터에 거기다 패키지를 직접 설치해서 관리할 필요도 없이 말이다.

도커 설치하기

설치하기라는 항목을 잡긴 잡았는데 뭘 적어야할까. 일단 맥 유저인가? 그렇다면 구글에서 🌏Docker Desktop을 검색해서 인스톨러를 다운받아서 설치하면 된다. 그러면 준비는 끝난다.

저게 귀찮다면, 그리고 Homebrew가 설치되어 있다면 아래 명령으로도 설치할 수 있다.

brew cask install docker

다른 OS를 쓴다면? 미안하지만 각자 알아서 해야 할 것이다. 나는 내가 해보지 않은 것을 글로 쓰는 사기를 치고 싶지는 않다.

다짜고짜 Python 돌려보기

혹시 현재 시스템에 파이썬(Python)이 설치되어 있는가? 없거나 혹은 시스템 기본 파이썬만 설치되어 있다면 아주 좋은 기회다. 아래 커맨드를 입력해보자.

docker run --rm -it python python

위의 커맨드는 실행 시점에서 가장 최신의 파이썬 인터프리터를 실행시킬 것이다. 우리는 별도로 파이썬을 설치한 적도 없음에도 말이다.

그런데 혹시 위 커맨드를 처음 실행했을 때 뭔가 다운로드 받는 것을 봤었다면 무슨 일이 일어났는지를 느꼈을지도 모르겠다. 아 파이썬을 다운로드 받았구나 라고 생각했다면 대충 절반은 맞췄다.

정확하게 말해서 이때 다운로드 받은 것은 이미지(image)라고 부르는 패키지가 포함된 데이터 덩어리다. 이 이미지는 레이어 단위로 여러 용도로 나눠진 기능들을 묶어놓은 데이터 덩어리이다.

이미지가 레이어 덩어리라는 것의 증거를 보려면 아래 커맨드를 눌러보자.

docker images

시스템에 다운로드(pull)된 이미지 목록이 나타나는데 방금 실행시킨 python 외의 이미지들이 잔뜩 보일 것이다. 이는 다르게 말해서 python 이미지는 다른 이미지를 기반으로 작성된 이미지라는 것을 의미한다.

어쨌든 앞에 실행된 파이썬 인터프리터는 ^D 를 눌러서 종료시키고, 이어서 아래 커맨드도 실행시켜보자.

docker run --rm -it python /bin/bash

놀랍게도 리눅스 이 뜬다. 이 상태에서 python 을 입력하면 파이썬이 실행된다. 와우 마음대로 가지고 놀 수 있는 리눅스 서버가 생긴 것 같다!

$ uname -a
Linux 9651f2a75f38 4.19.76-linuxkit

정말 리눅스다. 의심하지 않아도 된다.

이제 도커라는 너석의 정체가 이해가 될까? 아 리눅스 에뮬레이터구나! 라고? 음… 뭐… 당장은 이렇게라도 이해하자.

참고로 이렇게 이미지를 실행시키면 뜨는 것을 컨테이너(container)라고 부른다. 앞으로 이 용어가 나오면 도커 이미지를 실행시킨 것으로 이해하자.

코딩 시작해보기

도커로 새로운 리눅스 머신(?)을 얻은 것처럼 되었으니 이제 Python 코드를 실행시킬 준비는 되었다. 하지만 이대로는 익숙한 에디터로 코드를 작성할 수가 없다. 셸 만으로 어떻게 코딩을 하지? 이건 굉장히 생산성을 저하시킨다. 뭐 여기서 apt-get 을 이용해 Vim을 설치해서 코딩한다면야 그렇게 해도 되겠지만 말이다.

어쨌든 이 생산성 문제를 해결해보자.

앞에서 띄운 컨테이너를 종료하고, 프로젝트 디렉터리를 하나 준비하자. 대충 ~/develop/myapp 이라는 디렉터리를 준비했다고 치자.

그리고 아래와 같은 커맨드를 입력해서 다시 컨테이너를 띄워보자.

docker run --rm -it -v ~/develop/myapp:/myapp python /bin/bash

추가된 -v 옵션은 볼륨을 연결시키는 옵션이다. 즉 내 컴퓨터의 ~/develop/myapp 이라는 디렉터리를 컨테이너 내부에서 /myapp 이라는 디렉터리로 접근할 수 있게 해준다.

위 커맨드를 실행시켜도 똑같이 리눅스 이 뜬다. 여기서 /myapp 이라는 디렉터리로 이동해서 살펴보자.

$ cd /myapp
$ ls

당연히 이 디렉터리는 비어있어서 ls 의 결과가 아무 것도 나오지 않지만 어쨌든 이 디렉터리가 생성되어 있는 것은 알 수 있다.

이제 내 컴퓨터로 돌아와서, 아까 생성한 프로젝트 디렉터리에 아무 에디터로 main.py 파일을 만들어서 아래처럼 대충 hello world를 찍게 만들어보자.

print("hello world!")

이제 다시 도커로 띄운 셸에 돌아와서 확인해보자.

$ ls
main.py

내 컴퓨터의 내용과 동일한 내용이 표시됨을 알 수 있다. 당연하겠지만 컨테이너에서 실행도 잘 될 것이다.

$ python main.py
hello world!

결과적으로 손에 익은 에디터를 이용해 개발할 수 있는 환경이 구성되었다. 소스코드 자체는 내 컴퓨터에 저장되기 때문에 마음대로 버전 컨트롤도 할 수 있다. 정말 마음대로 개발하면 된다는 말이다.

네트워킹의 시작

내가 하려던 프로젝트가 Flask 앱이었다는 것으로 가정하자. 그렇다면 우선 Flask 패키지를 설치해야 한다. 파이썬 컨테이너에서 대충 설치해주자.

pip install flask

어차피 이 컨테이너는 내 앱만을 위한 독립된 머신이니 별도의 virtualenv 환경을 만들 필요도 없다.

이제 Flask 앱을 본격적으로 개발하기 위해 main.py 파일을 아래처럼 뜯어고쳤다. 물론 내 컴퓨터에서 내가 선호하는 에디터로 편집했다.

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'hello docker world!'

app.run(host='0.0.0.0', port=8888)

완성된 앱을 시험해보자. 도커로 띄운 컨테이너의 셸에서 실행시켜보자.

$ python main.py
 * Serving Flask app "main" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://0.0.0.0:8888/ (Press CTRL+C to quit)

자 이제 웹 브라우저에서 localhost:8888 로 접속해보자. 그러면 화면에 hello docker world!가 표시되…지 않고 에러가 뜬다. 서버에 접속할 수 없다는 식의 오류 말이다.

사실 코드에는 문제가 없다. 그저 도커의 네트워킹은 좀 특별해서 그냥 외부에서 바로 연결할 수는 없다는 점을 몰랐을 뿐이다.

이 문제를 해결하기 위해 컨테이너를 종료시키고 아래 커맨드로 다시 컨테이너를 띄워보자.

docker run --rm -it -v ~/develop/myapp:/myapp -p 8888:8888 python /bin/bash

아까랑 다르게 -p 8888:8888 옵션으로 포트 정보를 준 것이 보일 것이다. 이 옵션의 의미는 호스트의 8888 포트랑 컨테이너의 8888 포트를 연결한다는 의미다. 이렇게 하면 호스트에서 컨테이너 내부의 8888 포트로 접속할 수 있게 된다.

새로 띄운 컨테이너에서 다시 앱을 띄워보자.

$ cd /myapp
$ python main.py
Traceback (most recent call last):
  File "main.py", line 1, in <module>
    from flask import Flask
ModuleNotFoundError: No module named 'flask'

이런… 이전에 설치해 둔 Flask 패키지가 날아갔다. 이유는 단순하다. 지금까지 설명하진 않았지만, docker 명령으로 컨테이너를 띄울 때 사용한 --rm 옵션은 컨테이너가 종료되면 컨테이너의 스토리지를 몽땅 삭제시키기 위한 옵션이다. 그래서 컨테이너에 설치한 패키지도 계속 보존되지 않는다.

거기다 어차피 run 명령은 새로운 컨테이너를 띄운다. 그래서 이전에 띄운 걸 남겨놓던 말던 아마도 이렇게 될 것이다.

그렇다면 --rm 옵션을 삭제하고 하면 되지 않냐고 할 수도 있고, 혹은 attach 명령을 쓰면 되지 않냐 할 수 있는데 일단은 그냥 넘어가자. 나는 종료되면 컨테이너도 깔끔하게 사라지는 것을 선호하기 때문이다. 이 글을 읽는 여러분은 물론 마음껏 시험해 봐도 된다. 더 많은 도커 명령을 알고 싶다면 Docker CLI 명령 정리를 읽어보자.

어쨌든 다시 돌아와서, 문제를 해결하자. 쉽다. 그냥 컨테이너에 Flask를 다시 설치하면 된다.

$ pip install flask
$ python main.py

서버가 뜨면 다시 웹브라우저로 localhost:8888 로 접속해보자. 아마 이제는 웹브라우저에 hello docker world! 라는 메시지가 뜨고 컨테이너의 셸에도 리퀘스트 로그가 뜰 것이다.

결과적으로 컨테이너에서 Flask 웹 서비스 프로젝트를 시작할 수 있게 되었다.

이미지 만들기

컴퓨터를 켜고 작업을 시작하려고 컨테이너를 띄웠는데 매번 Flask를 새로 설치하고 서버를 띄우는 일은 아무래도 귀찮다. 좀 더 편하게 할 수 있는 방법은 없을까?

우리는 컨테이너의 시작 환경을 갖추는데 뭐가 필요한지 알고 있다. 따라서 이 환경을 이용해 이미지를 만들면 좀 더 편한 개발이 가능하다. 바로 시작 환경을 이미지로 만드는 것이다.

이미지를 만들기 전에 한 가지 준비를 해야 할 것이 있다. 우리가 설치한 패키지 의존성의 명세를 작성하는 거다. 방법은 매우 간단하다. 아까 잘 돌아가던 도커 컨테이너에서 앱을 종료시키고 아래 커맨드를 입력해주자.

pip freeze > requirements.txt

파이썬 프로젝트의 전형적인 의존성 관리 방법인 패키지 명세를 정리하는 방법이다. 이러면 requirements.txt 파일에 pip로 설치한 패키지 및 의존성이 걸린 패키지의 이름과 버전이 그대로 기록된다. virtualenv로 프로젝트 환경을 별도로 구성해 봤던 분들이라면 아주 익숙한 커맨드일 것이다.

이걸로 준비는 끝났으니 컨테이너를 종료시켜도 된다.

이제 본격적으로 이미지를 만들어보자. 이미지를 만들기 위해선 Dockerfile 이라는 특수한 DSL로 작성된 명세 파일이 필요하다. 프로젝트 디렉터리에 Dockerfile 이라는 파일을 만들고 아래와 같은 식으로 내용을 작성해보자.

FROM python

RUN mkdir /myapp
WORKDIR /myapp
ADD . /myapp

RUN pip install -r requirements.txt
ENTRYPOINT ["python", "main.py"]

이 코드의 내용을 개략적으로 설명하자면 이렇다:

  • FROM python 은 이 이미지를 최신의 python 이미지 기반으로 작성한다는 의미다.
  • RUN 으로 시작되는 커맨드는 커맨드를 실행한다고 보면 된다.
  • ADD 명령은 호스트 컴퓨터의 내용을 컨테이너 내부에 복사할 때 사용한다.
  • WORKDIR 명령은 cd 와 비슷하게 생각하자.
  • ENTRYPOINT 는 이미지의 로딩이 완료되면 실행될 명령이다.

결과적으로 위 스크립트는 python 이미지를 내려받아 컨테이너를 띄우고 여기에 /myapp 디렉터리를 만들고 현재 로컬 디렉터리의 내용을 몽땅 여기에 복사해 넣은 다음 pip 로 필요한 패키지를 설치한 뒤 마지막으로 main.py 를 실행시키도록 하는 이미지를 만들라는 의미다.

이제 프로젝트 디렉터리에서 아래 커맨드를 입력해보자.

docker build -t myapp .

참고로 -t 뒤의 이름은 이미지의 이름이다. 마음대로 지어도 상관은 없다. 다만 다른 이름을 쓰게 되면 아래의 예제 명령어의 이미지 이름도 바꿔야 한다는 것을 잘 알아두자.

마지막의 . 은 필수다. 빌드 컨텍스트 디렉터리를 지정하는 것인데 여기에 . 을 입력했다는 말은 Dockerfile에서 인식하는 현재 디렉터리가 이 명령을 실행시킨 디렉터리의 내용이라는 기준으로 알려주는 것이다.

이미지 빌드가 시작되면 화면에 뭔가가 마구 지나가면서 뭔가 이상하면서도 익숙한 일들이 벌어진다. 그리고 끝나면 이제 우리만의 이미지를 얻게 된다. 다만 별도의 파일은 생기지 않는다. 이미지는 도커가 관리하는 이미지 영역에 기록되기 때문이다.

이제 아래 커맨드를 눌러서 이미지가 만들어졌나 확인해보자.

docker images | grep myapp

이제 만들어진 이미지를 컨테이너로 띄워보자.

docker run --rm -p 8888:8888 myapp

아까와는 다르게 이제는 python 이미지가 아닌 방금 우리가 만든 이미지 이름인 myapp 을 실행시켰다. 컨테이너가 실행되면 바로 웹브라우저를 열어서 localhost:8888 로 접속해보자. 아마도 잘 보일 것이다.

결과적으로 굳이 앞에서 pip로 Flask를 설치하고 앱을 실행시켜야 했던 일들이 자동화 되었다. 아니 그냥 자동화 수준이 아니라 배포 환경이 완성되었다. 이대로 컨테이너 클라우드에 제출하거나 Kubernetes 클러스터에 Pod를 올려버려도 될 정도다.

이게 바로 도커 이미지를 만드는 방식이다. 기초가 될 이미지를 선택해서 여기에 원하는 환경을 갖추도록 코드를 작성해서 이걸로 자신만의 이미지를 만드는 것이다. 마치 서버를 하나 설치해서 세팅한다는 기분으로 이미지를 만들 수 있다.

잃어버린 개발 환경 되찾기

이 이미지로 계속 개발을 시작해보자. 다시 볼륨을 연결해야 원하는 에디터로 작업을 계속 할 수 있을 것이다. -v 옵션을 붙여서 볼륨을 연결하고 셸이 실행되도록 해보자.

docker run --rm -it -p 8888:8888 -v ~/develop/myapp:/myapp myapp /bin/bash

이제 이 명령이 실행되면 잘 되…지 않는다. 불행히도 셸이 뜨지 않는다. 이 앞에서 실행시킨 커맨드와 동일하게 그대로 앱이 실행되어 버릴 것이다.

이유는 단순한데, 이미지 스펙 즉 Dockerfile 에서 정의한 ENTRYPOINT 라는 부분 때문이다. 이 엔트리포인트는 이미지의 최종 실행 명령을 의미한다. 그래서 우리가 원하는 커스텀 명령이 무시되고 엔트리포인트의 내용이 실행되는 것이다.

이럴 때를 위해 ENTRYPOINT 대신 CMD 를 쓸 수 있다. CMD 는 역시 실행될 명령을 의미하지만 사용자가 다른 명령을 내리면 이 명령 대신 사용자 명령을 실행시킨다. 따라서 위 Dockerfile 을 열어서 ENTRYPOINTCMD 로 바꿔보면 원하는 대로 실행시킬 수 있을 것이다.

# ENTRYPOINT ["python", "main.py"]
CMD ["python", "main.py"]

참고로 Dockerfile에서 # 은 주석을 의미한다.

이제 다시 이미지를 빌드해보자.

docker build -t myapp .

빌드가 완료되면 좀 전에 입력했던 커맨드로 다시 컨테이너를 띄워보자.

docker run --rm -it -p 8888:8888 -v ~/develop/myapp:/myapp myapp /bin/bash

이제 다시 친숙한 셸이 뜨고 수동으로 main.py 를 실행시킬 준비가 되니 마음대로 주무를 일만 남았다.

기타 도커 커맨드 사용법을 알고 싶다면 Docker CLI 명령 정리 글도 참고하자.

마무리

이제 우리의 개발 환경의 기본 형상이 완성되었다. 앞으로는 원하는 대로 앱을 실행시키고 하던대로 에디터로 코드를 편집하고 다시 컨테이너의 셸에서 재기동을 하거나 그 외의 원하는 작업을 하면 된다. 당연히 추가 패키지가 생기면 pip 로 설치하면서 작업하다가 어느 정도 진척이 되면 freeze 시켜서 다시 이미지로 빌드하면 된다.

그리고 CMDENTRYPOINT 로 바꾸면 그 자체로 배포 준비가 끝나는 셈이다. 왜냐하면 도커 이미지는 그 자체로 실행 환경과 동일하기 때문이다.

모든 패키지는 격리된 컨테이너 내부에 설치가 된다. 내 컴퓨터에 지저분하게 여러 버전의 패키지를 설치하지 않고도 개발을 진행할 수 있게 되었다. 실행되는 컨테이너는 모두 내 앱을 위한 전용 공간이기 때문에 패키지 버전이 충돌할 일도 없다. 그래서 virtualenv 환경도 필요가 없다. pyenv로 별도의 파이썬 버전을 관리할 필요도 없다.

바로 이것이 도커로 개발환경을 구축하는 이유다. 완벽하게 실행 환경을 그대로 개발 환경으로 이용할 수 있다. 개발 환경 이외의 서버들만 컨테이너로 띄우는 것도 가능한 환경이겠지만 그러면 개발 중인 앱의 환경은 결국 개발자 로컬 환경에 귀속될 수 밖에 없다는 점을 생각해보자.

물론 여기까지의 내용은 도커의 가장 기본적인 기능만 이용한 것이다. Docker 멀티 컨테이너 개발 환경 글에서 좀 더 도커다운 개발 환경을 계속 구축해보자.