[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (11)”
[REAL Python – Flask] – “인스타그램 클론코딩 – Instagram Clone (11)”
Docker 의 Ubuntu image VS Ubuntu in VM
예전의 저의 블로그 글에서는 “굳이 OS 전체가 필요한가?” 실행에 필요한 것들만 딱딱 모아서 이미지화 하면 되지 않을까? 의 해결책으로 Docker를 제안했습니다. 그런데 여러 가지 Dockerfile 예제를 보다 보면 이런 의문점이 생길 겁니다. 생각의 흐름을 잘 따라가 봅시다.
FROM ubuntu
위의 문장은 “ubuntu image 를 기반으로 내 image를 빌드하겠다” 라는 Dockerfile 문법 중 하나입니다. 그런데, 아시다시피 ubuntu 는 영국 기업 캐노니컬이 개발, 배포하는 컴퓨터 운영 체제입니다.(https://ko.wikipedia.org/wiki/%EC%9A%B0%EB%B6%84%ED%88%AC)
그런데 분명히 저는 “운영 체제 전체가 필요하지 않으니, 필요한 것만 딱딱 모아서 이미지화하자!” 를 도커 사용의 정당성으로서 부여했습니다. 그럼 당연히 생각의 흐름은 이렇게 지나갑니다. “가상머신에 깔리는 운영 체제로서의 ubuntu 와, Dockerfile 이미지로서의 ubuntu 는 무엇이 다른가?(VM – Container의 차이는 무엇인가?)”
이를 이해하기 위해서는 현재 굉장히 많은 리눅스 배포판이 존재한다는 것을 알고 있어야 합니다.
위의 그림은 많은 리눅스 배포판들의 역사를 정리해 둔 것입니다. PNG 파일로 변환했더니 자그마치 4mb라는 어마무시한 파일 용량이 말해주듯, 그리고 위의 사진 길이가 말해주듯 수많은 배포판들이 존재하네요. 잘 살펴보면, 우리에게 익숙한 우분투도 찾아볼 수 있습니다.
그리고 위의 수많은 배포판들은 모두 리눅스 커널을 중심으로 만들어졌습니다(https://askubuntu.com/questions/172927/do-all-linux-distros-use-the-same-kernel). 그리고 운영체제에서 커널이란, 컴퓨터 운영 체제 의 중심 코어를 구성하는 프로그램입니다 . 시스템에서 발생하는 모든 것을 완벽하게 제어할 수 있습니다.(http://www.linfo.org/kernel.html)
좋아요. 이제 도커로 잠깐 넘어갑시다. 도커 공식 문서 (https://docs.docker.com/get-started/overview/#the-underlying-technology) 의 설명에 따르면 Docker 는 Go 언어로 작성되었고 , 리눅스 커널의 여러 가지 기능을 활용하여 사용자들에게 기능을 제공한다고 소개되어 있습니다.
그리고 위의 사진을 살펴보겠습니다. 도커는 기본적으로 호스트 OS 의 커널을 공유합니다.
위의 사진은 도커가 어떻게 작동하는지를 잘 표현한 그림입니다. 기본적으로 Host OS 의 커널을 공유하고, 그 위에 우리가 필요한 것들을 설치하고 있네요. 도커 공식 홈페이지에서도 도커는 Host OS 의 커널을 공유한다고 알려주고 있습니다.
위의 사진을 보고 아래의 사진을 다시 본다면 조금 더 이해가 쉬울 겁니다.
그렇기에 VM(가상 머신) 은 OS 전체를 격리하기 때문에 엄청난 용량의 운영 체제를 비롯한 모든 것들이 설치되게 됩니다. “Docker 는 VM보다 가볍다” 라고 말하는 이유도 그것 때문이죠!
이제는, 우리의 Flastagram 앱을, 진짜 도커화해 보겠습니다.
구축할 서버 구조를 알아보고 컨테이너화하기
예전에 말했다시피, 그리고 우리가 항상 flask run 혹은 python ./app.py 로 앱을 실행할 때 뜨는 경고 메시지가 있었습니다.
production 환경에서 이것을 서버로 사용하지 말라는 거였죠. 조금 더 좋은 웹 서버인 nginx를 가장 앞단에서 요청을 받도록 하고, uwsgi 로 우리의 flastagram 앱과 연결하겠습니다.
위의 사진은 우리가 구축할 구조를 나타낸 것입니다. 왼쪽부터 살펴보면 여러 클라이언트로부터 요청을 받는다는 건 알 수 있겠습니다. 웹 서버(웹 서버와 웹 애플리케이션 서버를 포괄하는) 에서는 , 맨 처음에 NGINX가 요청을 처리하고, uwsgi를 통해 flask 앱과 통신합니다.
그것들을 수행하기 위해서 위의 파일들을 만들어 줍시다. deploy에는 nginx와 uWSGI 설정들이, .dockerignore 와 Dockerfile 에는 Docker 관련 설정을 작성하겠습니다. 각각의 파일이 정확히 무엇을 의미하는지는 진행하며 알게 될 겁니다!
FROM python:3.10-alpine
COPY . /app
WORKDIR /app
RUN apk update && \
apk add \
nginx \
build-base \
linux-headers
RUN pip3 install --upgrade pip && \
pip install -r requirements.txt
RUN chmod +x /app/deploy/entrypoint.sh
ENTRYPOINT [ "/app/deploy/entrypoint.sh" ]
먼저 Dockerfile 에는 위와 같은 내용을 작성합니다. Dockerfile 은 이미지를 빌드하기 위해 사용자가 명령줄에서 호출할 수 있는 모든 명령을 포함하는 텍스트 문서입니다.(https://docs.docker.com/engine/reference/builder/)
우리의 이미지는 FROM 커맨드로서 시작합니다. 3.10-알파인 버전의 파이썬 이미지를 공개 저장소에서 가져올 겁니다. FROM 커맨드를 도커 공식 홈페이지에서는 아래와 같이 소개합니다.
아무튼 우리의 기본 이미지는 Python3.10-apline이 될 겁니다. 위의 지시문을 작성함으로서, 도커가 공개 저장소(docker hub) 에서 이미지를 가져오도록 하는 겁니다.
우리가 사용하는 python3.10 alpine 이미지의 내용은 https://github.com/docker-library/python/blob/7a54933c66171020473b9a4f06aec4e8ffb43a45/3.10/alpine3.17/Dockerfile 에서 볼 수 있습니다. 아래와 같이 이 이미지에서는 alpine 이미지를 기반으로 작성되었네요.
참고로 alpine(알파인) 은 보안, 단순성, 자원 효율성을 위해 설계된 musl, 비지박스 기반의 “리눅스 배포판” 입니다. (https://ko.wikipedia.org/wiki/%EC%95%8C%ED%8C%8C%EC%9D%B8_%EB%A6%AC%EB%88%85%EC%8A%A4)
파이썬 이미지를 가져온다면 Docker는 위의 Dockerfile 을 빌드한 이미지를 가져옵니다. 잠시 그 구현을 살펴보면,
wget 으로 파이썬 설치 파일을 가져오는 것을 잠깐 엿볼 수 있네요. https://www.python.org/ftp/python/3.10.9/Python-3.10.9.tar.xz 를 브라우저에 입력하면 실제로 설치 파일이 다운로드 될 겁니다. (exe 버전은 https://www.python.org/ftp/python/3.10.9/python-3.10.9.exe 와 같고, 이 링크를 브라우저에 입력한다면 파이썬 3.10.9 버전의 파이썬 설치 파일 exe 가 다운로드될 겁니다.)
그 이후를 또 조금 살펴보면 PIP 또한 설치하는 것도 확인할 수 있네요!
그 이후에 나오는 두 개의 지시어는 각각 다음의 의미를 가집니다.
먼저 COPY 는 <src> 에 있는 파일이나 디렉토리를 복사한 후, <dest> 위치에 복사합니다.
그러므로 위의 지시어는 아래처럼,
Dockerfile 과 같은 레벨에 있는 모든 파일들을 도커 이미지의 /app 으로 복사하겠다는 것을 의미합니다.
그런데 생각해보면 굳이 전체 파일들을 모조리 복사할 필요는 없습니다. 어플리케이션 구동에 굳이 필요없는 파일들도 존재하죠. 예를 들면 flastagram.db 파일은 전혀 필요하지 않습니다.
이를 위해서 .dockerignore 파일을 만들고 아래의 내용을 넣습니다. COPY 할 때, 아래의 것들은 모두 제외하고 복사할 겁니다.
README.md
Dockerfile
__pycache__/
flastagram.db
venv
post
profile
그 이후 나오는 WORKDIR 지시어는 작업 디렉토리를 설정합니다.(https://docs.docker.com/engine/reference/builder/#workdir) RUN, COPY와 같은 명령어들이 수행될 위치를 지정하는 커맨드입니다. 우리는 모든 파일들을 /app에 복사했으므로 작업 위치도 /app 으로 설정하겠습니다.
좋아요. 지금까지 Python을 굴릴 환경까지는 되었습니다. 그리고 우리 애플리케이션 파일들을 모두 복사했으므로 flastagram 을 돌리기만 하면 됩니다. 당연히 그냥은 아니고, nginx 와 함께요.
이후 나오는 RUN 지시어는 뒤에 나오는 명령어 (apk update .. add.. 등)를 수행하고, 그것의 결과를 커밋합니다. (https://docs.docker.com/engine/reference/builder/#run)
위의 명령어에서 우리는 nginx, build-base, linux-headers 를 설치했죠. (add이지만, 만약 base image 가 ubuntu 였다면 apt-get 명령어를 썼을 겁니다. 우리가 사용한 alpine 리눅스의 패키지 관리자는 apk입니다. Alpine Package Keeper)
아무튼 우리는 nginx를 설치했습니다. 이것은 웹 서버이고, uwsgi를 구동하기 위해서 build-base, linux-headers 도 설치했습니다.
그리고 당연히 위의 커맨드는 이해가 되시겠죠? flastagram 앱에 필요한 모든 필요한 파이썬 패키지들을 다운받습니다.
그런데 배포 환경에서는 uwsgi 가 필요하죠? requirements/prod.txt 에 위의 uwsgi 를 추가합니다.
참고로, 저의 requirements 전체 목록은,
Flask==2.2.2
Flask-JWT-Extended==4.4.4
flask-marshmallow==0.14.0
Flask-Migrate==3.1.0
Flask-RESTful==0.3.9
Flask-SQLAlchemy==2.5.1
flask-cors
marshmallow-sqlalchemy==0.28.1
PyJWT==2.4.0
python-dotenv==0.21.0
SQLAlchemy==1.4.41
-r common.txt
-r common.txt
psycopg2-binary
uwsgi
가 되겠습니다 :) (삽질을 줄이기 위해..)
그 이후에 나오는 지시어는 우리가 작성할 entrypoint.sh 파일에 실행 권한을 부여하기 위해 사용합니다.
마지막 줄의 ENTRYPOINT 지시어는 컨테이너가 시작되었을 때의 명령어를 작성할 수 있습니다. .sh 파일을 가리키고 있는데, 아시다시피 .sh 파일은 셸 스크립트 파일이죠? 이제 /deploy/entrypoint.sh 에 다음의 명령어들을 작성합니다.
#!/bin/sh
set -e
# Start Nginx
nginx -c /app/deploy/nginx.conf
# Start uWSGI
uwsgi --ini deploy/uwsgi.ini
웹 서버를 구동하고 uWSGI 도 구동합니다. 별거 없죠? 그런데 이제부터 중요한 내용이 나옵니다. /deploy/uwsgi.ini 에 아래의 코드들을 작성합니다.
[uwsgi]
# Flask Application
wsgi-file = app.py
callable = app
# socket path
socket = /tmp/flastagram.sock
# processes & threads
master = true
processes = 4
threads = 2
# socket permissions
chmod-socket = 660
vacum = true
die-on-term = true
좋아요. 태초에 우리가 uwsgi 를 사용하고자 했던 이유는 Nginx와 같은 웹 서버에 그것을 붙이기 위해서였습니다. 그렇기에 wsgi-file 을 우리의 flastagram 앱이 담겨있는 app.py 로, callable 을 app (app.py 에 app=create_app() 이 있었죠) 으로 지정했습니다.
그리고 socket path 부분에서 우리는 유닉스 도메인 소켓을 통하여 통신할 것이므로 소켓 파일 경로를 지정해 주었습니다.
다음으로 보이는 # processes & threads 부분은 서버의 프로세스와 스레드를 설정합니다.
좋아요. 이번에는 웹 서버 설정을 하겠습니다. deploy/nginx.conf 에 아래의 코드를 작성합니다.
user root;
events{}
http{
server {
listen 80;
location / {
include /etc/nginx/uwsgi_params;
uwsgi_pass unix:/tmp/flastagram.sock;
}
location /statics/ {
alias /app/static/images/;
}
}
}
우리는 정적 파일들을 플라스크가 아니라 Nginx가 서빙하도록 할 겁니다. 그렇기 때문에 location 블럭을 추가했습니다. 우리가 80번 포트에 /statics/ 요청을 날리면, Nginx는 /app/static/images 안에 있는 사진들을 보여주게끔 동작할 겁니다.
PostgreSQL fly.io 애플리케이션에 ip 주소 부여하기
기본적으로 fly.io 의 앱은 인터넷에 노출되지 않습니다. (https://fly.io/docs/postgres/connecting/connecting-external/) 이를 외부에서 연결하려면, 데이터베이스 애플리케이션에 ip 주소를 부여해야 합니다. fly ips –app flastagram-db 를 터미널에 입력하면, 아래처럼 아무런 ip 주소도 할당되지 않은 것을 알 수 있습니다.
문서가 알려주는 대로 ip주소를 할당하겠습니다. 먼저 할 것은 frontend/ backend/ 와 같은 레벨에 database 폴더를 하나 만드는 겁니다.
그리고 아래의 명령어를 입력합니다.
참고로, flastagram-db 의 경우 fly.io 에서의 대시보드의 앱 이름을 작성하면 됩니다.
명령어가 수행되고 나서 잠시 후 다시 ip 목록을 확인해 보면,
위와 같이 IPv4 주소가 정상적으로 할당된 것을 볼 수 있습니다.
이제 터미널을 이용해 /database 로 작업 위치를 바꾼 후, fly config save –app flastagranm-db 를 입력합시다.
그러면 위와 같이 fly.toml 파일이 하나 생성된 것을 확인할 수 있습니다. 여기서 해야 할 것은 5432 포트를 열어주는 것입니다.
이후 위와 같이 포트번호를 수정하고,
fly image show –app flastagram-db 를 입력하면 디테일에 (태그) 가 있을 겁니다.
fly deploy –app (앱이름) –image flyio/postgres:(태그) 를 입력하여 ip 주소 할당을 완료합니다.
대시보드에 들어가 보면 IPv4 주소가 할당되어 있는 것을 확인할 수 있을 겁니다!
주소가 할당되었으므로, DATABASE_URL_FOR_PRODUCTION 환경 변수 값을 위와 같이 고쳐줍시다.
도커 이미지 빌드하기
이제 우리의 환경을 “찰칵” 찍을 시간입니다. Dockerfile 이 있는 곳에서 docker build -t flastagram . --progress=plain
명령어를 입력합시다.
그러면 우리의 docker 는 Dockerfile 에 구성된 대로 이미지를 빌드하기 시작할 겁니다. --progress=plain
옵션을 주었기 때문에 빌드가 일어나는 과정을 터미널에서 모두 확인할 수 있는데요,
예를 들면 우리는 Dockerfile 의 맨 처음에 FROM Python:3.10-alpine 을 작성했기 때문에 공개 저장소에서 해당 이미지 정보들을 불러오는 걸 확인할 수 있죠?
필요한 파이썬 패키지들 설치도 모두 잘 진행되었습니다.
docker run –name flastagram_container -d -p 80:80 flastagram 을 터미널에 입력하여 이미지를 실행해 봅시다. 그럼 도커 데스크탑의 “컨테이너” 목록에 “flastagram_container” 가 뜰 겁니다.
그리고, 주소창에 127.0.0.1 을 입력하면,, 정상적으로 동작할 겁니다.
/posts 를 입력해도 정상 동작하네요.
이전에는 포트번호를 5000번처럼 주었지만 이번에는 그렇게 하지 않은 이유는 우리의 서버 (웹서버 + 웹 애플리케이션 서버) 가 80번 포트를 듣고 있기 때문입니다.
그리고 이는 rfc2616(https://www.rfc-editor.org/rfc/rfc2616.txt)에도 정의되어 있습니다. “If the port is empty or not given, port 80 is assumed.”
naver.com:443 을 브라우저 주소창에 입력하면 naver.com 에 접속할 수 있을 겁니다. https는 443 포트를 사용하기 때문입니다. 이는 또한 rfc7540 에 적혀 있는 내용이기도 합니다. (https://www.rfc-editor.org/rfc/rfc7540)
그리고 실제로도 80번 포트를 LISTEN 하고 있는 것을 볼 수 있네요.
정상 동작하는 것을 확인했다면, stop 버튼을 눌러 컨테이너를 멈춰줍시다.
이제 진짜 fly.io 에 배포하면 되겠습니다. Dockerfile이 있는 곳으로 이동해서(backend 디렉토리), fly launch 명령어를 입력합니다.
그러면 자동으로 fly 가 Dockerfile 을 찾고 앱 이름을 생성하라고 합니다. 저는 flastagram-api-server 로 설정하겠습니다.
그러면 배포 지역을 선택하라는 메시지가 나오는데, 저는 홍콩을 선택했습니다.
데이터베이스는 이미 선택하였으므로 N, N 을 선택합니다.
지금 바로 배포할 거냐는 메시지가 나오는데, 몇 가지 설정이 남았으므로 N을 선택합니다.
위의 과정을 모두 마친다면 fly.toml 이라는 파일이 생겼을 겁니다.
Dockerfile 에서 80번 포트를 개방했으므로 internal_port 를 80으로 바꾸어 줍니다.
toml 파일을 저장한 후, flyctl deploy 를 입력하여 배포를 진행합니다.
보아하니 배포를 진행할 때에 로컬에 있는 Dockerfile 에서 임의로 다시 이미지를 빌드하네요.
위처럼 배포가 성공적으로 끝났다는 메시지가 나온다면 성공입니다.
그러면 위처럼 백엔드 서버가 열려 있는 걸 확인할 수 있고,
요 링크를 클릭해서 접속한다면,
잘 동작하는 걸 확인할 수 있을 겁니다.
좋네요!
Github Action 을 이용한 CI-CD 구축하기
좋아요. 위의 과정을 성공적으로 경험하셨다면 느끼셨을 겁니다. 생각보다 할 게 많다는 걸요. 만약 제가 백엔드 서버에 “댓글 좋아요 API” 기능을 추가하고 싶다고 가정합니다. 그러면, 저는 아래의 과정을 거쳐야 합니다.
- 로컬에서 API 기능 구현을 완료한다.
- 로컬에서의 변경내역을 커밋한 다음, 깃허브 원격 저장소에 푸시한다.
- flyctl deploy 명령어를 로컬에서 입력해 배포를 완료한다.
하지만 보셨다시피, 위의 과정 중 2번과 3번은 끊임없이 반복되는 과정이고 꼭 필요한 과정입니다. 이걸 줄이면, 개발자 입장에서는 자연스레 구현에만 집중할 수 있겠네요.
좋아요. 이왕 하는 거 제대로 가 봅시다. 우리가 구현할 CI/CD 파이프라인은 아래와 같습니다.
- 개발자가 github의 develop 브랜치에 소스코드를 push 한다.
- 이후 develop 브랜치에서 개발이 완료되면 main 브랜치로 병합한다.
- Github Action 은 main 브랜치에 병합하는 순간 배포를 완료한다.
물론 이는 브랜치가 딱 한 개만 있을 때의 경우이며 팀이나 단체별로 배포 전략은 다양해질 수 있겠죠?
그런데 제목에서도 그렇고, 위의 리스트의 3번에서도 그렇고 처음 보는 단어가 나옵니다. “Github Action” 이 뭔지 모르셨다면.. 일단 짧은 답으로는 “CI / CD 자동화 플랫폼” 입니다.
한번 실제로 그것을 구현하며 알아볼까요?
먼저 터미널에서 flyctl auth token을 입력하여 토큰을 발급받습니다.
그리고 발급받은 토큰을 어딘가에 저장해주세요.
이후 저장소의 settings 탭에 들어갑니다.
Secrets -> Actions 를 선택합니다.
New repository secret 버튼을 누릅니다.
Name 에는 FLYIO_SECRET_KEY 를, 아래에는 우리가 발급받았던 키를 넣어주고 Add secret를 누릅니다.
그리고 아래와 같은 폴더 구조를 만들어주세요.
deploy.yml 에는 아래와 같은 내용을 입력합니다.
name: Deploy
on:
pull_request:
branches:
- main
types:
- closed
env:
FLY_API_TOKEN: ${{ secrets.FLYIO_SECRET_KEY }}
jobs:
deploy:
name: Deploy API Server
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: cd backend && flyctl deploy --remote-only
코드는 꽤 직관적입니다. main 브랜치에, PR이 닫히면, 아래의 jobs 를 통해서 작업이 수행됩니다. if : 로 시작되는 부분에서는 PR이 머지되었는지를 확실히 합니다. 이후 우리가 했던대로 flyctl deploy 를 진행하네요.
그러면, develop 브랜치에 아래와 같이 커밋을 진행하겠습니다.
그리고 뭘 좀 테스트해 봅시다. 이제야 발견한 백엔드 로그인 로직을 약간 수정하고, README.md 도 추가해 보겠습니다.
backend.api.resources.user 의 UserLogin 에 아래의 코드를 추가합니다.
그리고 제 프로젝트를 소개할 수 있는 README 를 작성해서 Develop 브랜치에 푸시하겠습니다.
그러면 develop 브랜치에는 위의 커밋 내용이 올라가게 됩니다.
이제 이를 main 브랜치에 병합하겠습니다.
그리고 Actions 탭에 가면 우리가 작성한 yml 파일대로 배포를 진행할 겁니다.
조금 기다리면, 배포가 완료되었다는 성공 화면이 뜰 겁니다.
성공! 그리고 fly.io 대시보드에 가 보면,
새 버전을 배포했다는 메시지를 볼 수 있네요. :) 이제 “develop” 브랜치에서 “main” 브랜치로 병합하기만 하면, 자동으로 배포되도록 했습니다. 물론 이는 아주 간단한 예제이므로 각자의 상황에 맞게 커스텀할 수 있겠습니다! :)