Julie의 Tech 블로그

Docker, getting-started with Python 본문

Tech

Docker, getting-started with Python

Julie's tech 2021. 5. 15. 22:17
728x90

오늘은 Docker official docs에 있는 가이드대로 도커 이미지를 생성하고 빌드하는 과정을 해볼 것이다.

Python을 이용하여 간단한 웹 어플리케이션을 개발해볼 것이다.

참조 링크 : https://docs.docker.com/language/python/build-images/

 

Build your Python image

Learn how to build your first Docker image by writing a Dockerfile

docs.docker.com

필요한 환경은 아래와 같다.

- python 버전 3.8이상

- Docker

- IDE (ex. Visual Studio Code)

우선 프로젝트를 수행할 폴더를 하나 생성한다. Flask라는 프레임워크를 사용하여 웹을 간단하게 만들어볼 것이다.

freeze라는 명령어는 현재 개발환경에 설치된 라이브러리들과 그 버전을 명시해준다.

touch 명령어는 파일 크기가 0인 빈 파일을 생성해주는 명령어이다.

$ mkdir python-docker101
$ cd python-docker101
$ pip3 install flask
$ pip3 freeze > requirements.txt
$ touch app.py
$ pip freeze
arrow==1.0.3
binaryornot==0.4.4
certifi==2020.12.5
chardet==4.0.0
click==7.1.2
cookiecutter==1.7.2
idna==2.10
Jinja2==2.11.3
jinja2-time==0.2.0 ...

pip freeze 명령어를 통해 얻은 결과물을 보통 아래와 같이 사용할 수 있다.

여러 사람들이 동일한 개발환경을 공유할때 아래와 같이 심플한 명령어 두 줄로 동일한 환경을 구성해볼 수 있는 것이다.

$ pip freeze > requirements.txt
$ pip install -r requirements.txt # 목록에 있는 라이브러리, 버전과 동일하게 설치

IDE 혹은 vi를 이용하여 app.py를 아래와 같이 수정해준다.

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, Docker!'

프로젝트 폴더 내에서 아래와 같은 명령어를 통해 웹이 정상적으로 동작하는지 미리 확인한다.

$ python3 -m flask run

명령어 수행 후 http://localhost:5000에 접속하면 Hello, Docker!를 뱉는 웹을 볼 수 있다.


도커 파일 생성

도커 파일이란, 우리가 도커를 빌드할 때 이 파일을 순차적으로 읽어내려 수행할 명령어들을 담고 있다.

도커는 도커파일을 기반으로 이미지를 생성한다.

Dockerfile는 맨 처음 파서 지시어를 담는다. (Syntax parser derivative)

아래 맨 첫번째 문장은 Docker가 버전1 syntax의 가장 최근 릴리즈를 의미하는 것이다.

그 다음으로는 어플리케이션이 어떤 이미지를 베이스로 두고 있는지를 가르킨다.

도커는 이미지를 이용하여 다른 이미지를 생성할 수 있다.

따라서 Python 어플리케이션을 만들기 위해 제공되고 있는 Python

official 도커 이미지를 사용할 것이다.

# syntax=docker/dockerfile:1
FROM python:3.8-slim-buster

그 다음으로는 도커에게 그 다음에 순차적으로 진행할 명령어들이 위치할 Working Directory를 지정해준다.

이후 명령어에서는 우리가 full file path를 입력할 필요 없이, 상대경로로 지정하여 원활하게 코드를 작성할 수 있다.

WORKDIR /app

그 다음으로는 아까 생성해둔 requirements.txt파일을 도커에 옮겨 도커에도 내 환경과 동일한 환경을 구성할 수 있도록 해준다.

아래 명령어를 통해 /app 디렉토리 내에는 내 로컬에 있는 requirements.txt와 동일한 파일이 복사된다.

COPY requirements.txt requirements.txt

이제는 복사해둔 requirements.txt 대로 주요 라이브러리들을 설치해야한다.

그 후 현재 프로젝트 디렉토리 내에 있는 모든 소스코드 들을 복사하기 위해 COPY를 수행한다. (여기선 app.py)

RUN pip3 install -r requirements.txt

이제는 도커에게 컨테이너에 이미지가 생성된 뒤 어떤 명령어를 수행해야하는 지 담는다.

아래 명령어를 통해 앞서 살펴본대로 flask라이브러리를 통해 웹 어플리케이션을 실행한다.

CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0"]

이렇게 Dockerfile을 전부 생성하고 난뒤, 프로젝트 폴더 내에는 아래와 같이 파일들이 구성되어있어야 한다.

python-docker
|____ app.py
|____ requirements.txt
|____ Dockerfile

이제는 만들어둔 소스코드로 도커 이미지를 빌드할 차례이다. 아래와 같이 python-docker 이름으로 태그도 달아준다.

* 태그는 이미지 명과 같으며, 동일 이미지에 여러 태그를 붙일 수도 있다. 주로 버전을 명시하기도 한다.

$ docker build --tag python-docker .

빌드를 성공적으로 진행하고 난 뒤에는 아래와 같은 명령어로 이미지 목록을 살펴볼 수 있다.

$ docker images
REPOSITORY      TAG               IMAGE ID       CREATED         SIZE
python-docker   latest            8cae92a8fbd6   3 minutes ago   123MB
python          3.8-slim-buster   be5d294735c6   9 days ago      113MB

그 후 이미지를 실행한다.

$ docker run python-docker

이 명령어를 실행하고 난 뒤 터미널은 계속 무언가를 수행하는 것처럼 input 라인을 더 이상 받지 않을 것이다.

그 이유는 우리가 만든 애플리케이션이 REST서버이고, REST서버는 request가 들어오기 전까지 무한 루프를 돌고 있기 때문이다.

따라서 새 터미널을 생성하여 웹 서버가 정상적으로 돌고 있는지 확인하는데, curl명령어를 통해 아래와 같이 접근해보고자 하면 fail이 발생한다.

$ curl localhost:5000
curl: (7) Failed to connect to localhost port 5000: Connection refused

이 이유는 우리가 실행한 컨테이너는 네트워크를 포함하여 격리된 공간에서 수행되고 있기 때문이다.

로컬 포트와 컨테이너의 포트를 연결한 채로 이미지를 수행해야 로컬에서 localhost:5000번을 통해 접속할 수 있다.

아래와 같이 publish 옵션이 그 역할을 담당한다. 로컬의 5000번 포트와 컨테이너의 5000번 포트를 연결해주었다.

$ docker run --publish 5000:5000 python-docker

다시 새로운 터미널 창을 열어 curl을 수행해보면 아래와 같이 정상적으로 결과를 내뱉는 것을 확인할 수 있다.

$ curl localhost:5000
Hello, Docker!

우리는 웹 서버를 만들었고, 항상 컨테이너에 접속해있는 상태가 될 필요는 없다.

아까는 이미지를 run했을 때 터미널에서 지속적으로 수행하고 있었는데, 우리가 백그라운드로 두고 실행하고 싶다면 아래와 같이 가능하다.

$ docker run -d -p 5000:5000 python-docker
ce02b3179f0f10085db9edfccd731101868f58631bdf918ca490ff6fd223a93b

-d 혹은 -detach라는 옵션을 통해 가능한데, 명령어를 수행하면 컨테이너 ID를 내뱉는다.

이 컨테이너 ID를 기반으로 다시 해당 컨테이너에 붙고 싶을 때 attach {컨테이너ID}를 통해 가능하다.

위와 같이 백그라운드로 컨테이너가 돌더라도 crul명령어를 통해 정상적으로 확인이 가능하다.

attach할 수도 있다. 말 그대로 연결한다는 것인데, 정확히는 실행되고 있는 컨테이너에 표준 입출력(stdin, stdout)이 가능하도록 해주는 것이다.

detach한 컨테이너에 attach할 수는 없다.


현재 도커에서 실행상태에 있는 컨테이너들의 목록을 보고 싶을 경우 아래와 같이 가능하다.

컨테이너가 실행된다는 것은 프로세스가 실행된다는 의미이기도 하여, 로컬에서 프로레스 목록을 보는 명령어와 유사하다.

$ docker ps

컨테이너를 멈추고, 다시 시작할 수도 있다.

stop, start, restart라는 명령어가 있고, 앞서 살펴본 run은 create와 start를 아우르는 개념이기도 하다.

컨테이너를 삭제할 때는 rm 명령어로 수행이 가능하다.

위와 같이 백그라운드로 도는 컨테이너들은 쉽게 확인하기가 어렵기 때문에 ps를 통해 확인한 뒤, 필요없을 경우 stop 혹은 rm을 해줄 필요가 있다.


도커 어플리케이션에 Database를 운영하고 싶을 경우, 영구적인 데이터는 volume을 통해 관리가 가능하다.

우리는 mysql과 mysql configuration을 담는 volume을 생성할 것이다.

$ docker volume create mysql
$ docker volume create mysql_config

이 때 생성한 볼륨과 우리가 실행할 어플리케이션 간 통신이 가능한 네트워크를 생성해주어야한다.

$ docker network create mysqlnet

이제 우리가 생성해둔 볼륨과 네트워크를 새로 생성할 container에 붙여준다.

-v라는 옵션이 volume을 지정해주는 것이다. -e는 환경변수로 MYSQL에서 사용할 Root password를 넘겨준다.

아래 명령어를 통해 도커는 자동으로 Hub에서 mysql 최신 이미지를 찾아 컨테이너를 생성할 것이다.

$ docker run --rm -d -v mysql:/var/lib/mysql \
  -v mysql_config:/etc/mysql -p 3306:3306 \
  --network mysqlnet \
  --name mysqldb \
  -e MYSQL_ROOT_PASSWORD=p@ssw0rd1 \
  mysql

앞서 살펴본 background 로 돌린 컨테이너와는 다르게, 우리는 직접 컨테이너에서 실행된 mysql에 접속하고자 한다.

exec 명령어는 컨테이너에 명령어를 내리는 것이고, 우리는 mysql에 접속하는 명령어를 내린다. <mysql -uroot -p>

-ti 옵션은 각각 tty, iterative 인데, foreground로 내 로컬에서 직접 터미널을 통해 명령어를 내릴 수 있는 환경을 만들어준다.

$ docker exec -ti mysqldb mysql -u root -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 8
Server version: 8.0.23 MySQL Community Server - GPL

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

이렇게 생성한 mysql DB를 아까 만든 웹 어플리케이션에 붙여 사용할 수도 있다.

app.py를 아래와 같이 수정해보자.

import mysql.connector
import json
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
  return 'Hello, Docker!'

@app.route('/widgets') // widget이라는 하위 경로 추가
def get_widgets() :
  mydb = mysql.connector.connect(
    host="mysqldb",
    user="root",
    password="p@ssw0rd1",
    database="inventory"
  )
  cursor = mydb.cursor()


  cursor.execute("SELECT * FROM widgets")

  row_headers=[x[0] for x in cursor.description] #this will extract row headers

  results = cursor.fetchall()
  json_data=[]
  for result in results:
    json_data.append(dict(zip(row_headers,result)))

  cursor.close()

  return json.dumps(json_data)

@app.route('/initdb') // initdb라는 하위 경로 추가
def db_init():
  mydb = mysql.connector.connect(
    host="mysqldb",
    user="root",
    password="p@ssw0rd1"
  )
  cursor = mydb.cursor()

  cursor.execute("DROP DATABASE IF EXISTS inventory")
  cursor.execute("CREATE DATABASE inventory")
  cursor.close()

  mydb = mysql.connector.connect(
    host="mysqldb",
    user="root",
    password="p@ssw0rd1",
    database="inventory"
  )
  cursor = mydb.cursor()

  cursor.execute("DROP TABLE IF EXISTS widgets")
  cursor.execute("CREATE TABLE widgets (name VARCHAR(255), description VARCHAR(255))")
  cursor.close()

  return 'init database'

if __name__ == "__main__":
  app.run(host ='0.0.0.0')

이제 웹 어플리케이션은 mysql과 연결할 수 있는 모듈이 필요하다. requirements.txt를 업데이트하자.

$ pip3 install mysql-connector-python
$ pip3 freeze > requirements.txt

이제는 이미지를 빌드하고, run 해볼 것이다.

$ docker build --tag python-docker .
$ docker run \
  --rm -d \
  --network mysqlnet \
  --name rest-server \
  -p 5000:5000 \
  python-docker
$ curl http://localhost:5000/initdb
$ curl http://localhost:5000/widgets

위 두 crul 명령어를 수행한 결과는 빈 json파일일 것이다. (DB안에 데이터가 없기 때문)


앞서 도커파일에서 CMD라는 명령어를 살펴보았다.

컨테이너가 수행하게 될 명령어라고 소개했는데, 도커파일에는 동일한 기능을 하는 Entrypoint라는 명령어도 있다. 둘 간의 차이는 무엇일까?

Entrypoint와 CMD의 차이는 명령어 수행에 대한 default 지정 여부이다.

즉 Entrypoint는 entrypoint로 지정한 명령어를 반드시 수행하지만, CMD는 인자로 다른 명령어를 주게 되면, 그 명령어를 수행한다.

간단하게 예제로 알아보면 그렇다. 아래와 같이 ls -al 명령어를 수행하는 도커 이미지를 생성해보자.

# Dockerfile
FROM ubuntu
CMD ["ls", "-al"] 
$ docker build -t test .
$ docker run --name test test
total 56
drwxr-xr-x   1 root root 4096 May 15 12:50 .
drwxr-xr-x   1 root root 4096 May 15 12:50 ..
-rwxr-xr-x   1 root root    0 May 15 12:50 .dockerenv
lrwxrwxrwx   1 root root    7 Jan 19 01:01 bin -> usr/bin
drwxr-xr-x   2 root root 4096 Apr 15  2020 boot
drwxr-xr-x   5 root root  340 May 15 12:50 dev
drwxr-xr-x   1 root root 4096 May 15 12:50 etc
drwxr-xr-x   2 root root 4096 Apr 15  2020 home
...

하지만 아래와 같이 인자로 새로운 명령어를 주면, 그 명령어를 수행한다.

$ docker run --name test2 test ps
PID TTY          TIME CMD
    1 ?        00:00:00 ps

$ docker inspect test2 // inspect 명령어를 통해 CMD가 수정된 것을 알 수 있다.
.... "Cmd": [ 
            "ps" 
          ], ...

이와 반면 ENTRYPOINT로 지정한 명령어는 반드시 수행하게 된다.

ENTRYPOINT > CMD 순으로 명령어를 수행하는데, ENTRYPOINT를 먼저 수행하고, 인자로 들어온 명령어를 수행한다.

컨테이너가 실행될 때 변경되지 않아야하는 명령어는 ENTRYPOINT로 지정하는 것이 좋다.

예를 들어 웹서버 혹은 DB 역할을 하는 어플리케이션들은 각각 nginx나 mysql와 같은 실행이 필요하다.

이 경우에는 ENTRYPOINT로 지정하는 것이 좋다.

참고로 RUN은 보통 패키지 설치 등에 사용된다. RUN을 수행하고 난 결과는 새로운 레이어에 저장되고, 이 레이어는 베이스 이미지에 추가된다.


* 참고

dive라는 패키지를 설치하여 도커 이미지를 시각화할 수 있다.

도커 크기를 줄이기 위해서 Dockerfile을 최적화하는 데에 사용하며, 각 레이어를 시각화하여 구성을 살펴볼 수 있다.

반응형