imhamburger 님의 블로그

데이터엔지니어 부트캠프 - FastAPI로 파일 업로드 기능 구현하고 파일을 업로드할 때마다 DB에 저장시키기 2탄 with Docker (12주차) 본문

데이터엔지니어 부트캠프

데이터엔지니어 부트캠프 - FastAPI로 파일 업로드 기능 구현하고 파일을 업로드할 때마다 DB에 저장시키기 2탄 with Docker (12주차)

imhamburger 2024. 9. 28. 00:04

지난글에서 사진을 업로드하는 기능을 만들고 업로드된 사진이 DB에 적재되는 것까지 구현하였다. 이번에 할 Task는 다음과 같다.

 

1. "ml-worker"라는 모듈 이어서 만들기

2. "ml-worker" 모듈을 자동적으로 실행해줄 cron 세팅하기

3. Docker 이미지로 만들어 실행해보기 (잘 되는지)

4. 숫자이미지를 넣으면 예측해주는 기능을 진짜 딥러닝 모델 입히기

5. 다시 Docker 이미지로 만들어 로컬이 아닌 AWS 에서 실행해보기

6. AWS에서 도커를 실행하였을 때, 라인알람 문제 해결하기

 

 

1. "ml-worker"라는 모듈 이어서 만들기

 

나는 for문을 이용해 NULL이었던 prediction_result / prediction_model / prediction_time 을 채워주었었다. (이전글)

그런데 for문을 이용하지 않아도 되었다는 사실....!!!

 

수정 전

# STEP 2
    # RANDOM 으로 0 ~ 9 중 하나 값을 prediction_result 컬럼에 업데이트
    # 동시에 prediction_model, prediction_time 도 업데이트

            for i in result:
                number = randrange(10)
                num_id = i["num"] #key값
                sql = f"""
                        UPDATE image_processing
                        SET prediction_result = {number},
                            prediction_model = {number},
                            prediction_time = '{jigeum.seoul.now()}'
                        WHERE num = {num_id}
                        """
                cursor.execute(sql)

            conn.commit()

 

수정 후

def prediction(file_path, num):
    sql = """UPDATE image_processing
    SET prediction_result=%s,
        prediction_model='n01',
        prediction_time=%s
    WHERE num=%s
    """
    presult = random.randint(0, 9)
    dml(sql, presult, jigeum.seoul.now(), num)

    return presult

 

for문을 돌릴필요없이 sql 에 %s 로 value를 채워주는 방법이 있었다. 이렇게하면 굳이 for문을 돌리지 않아도되니 코드 효율이 더 좋다.

 

 

이제 다시돌아와서 worker.py을 만든 이유는....!

worker.py 파일을 가지고 함수 run을 실행하는 "ml-worker"라는 모듈을 만들고,

"ml-worker"가 실행되면 NULL이었던 컬럼에 랜덤 숫자가 들어가며, "ml-worker"를 사용할 때마다 라인알람을 받기위함이다.

 

위에서 코드를 수정해주었으니 지난번에 작성한 코드에서 조금 변경되었다.

 

worker.py

import jigeum.seoul
import requests
import os
from mnist.db import select, dml
import random

def get_job_img_task():
    sql = """
    SELECT num, file_name, file_path
    FROM image_processing
    WHERE prediction_result IS NULL
    ORDER BY num -- 가장 오래된 요청
    LIMIT 1 -- 하나씩
    """
    r = select(sql, 1)

    if len(r) > 0:
        return r[0]
    else:
        return None

def prediction(file_path, num):
    sql = """UPDATE image_processing
    SET prediction_result=%s,
        prediction_model='n01',
        prediction_time=%s
    WHERE num=%s
    """
    presult = random.randint(0, 9)
    dml(sql, presult, jigeum.seoul.now(), num)

    return presult

def run():
    """image_processing 테이블을 읽어서 가장 오래된 요청 하나씩을 처리"""

    # STEP 1
    # image_processing 테이블의 prediction_result IS NULL 인 ROW 1 개 조회 - num 갖여오기
    job = get_job_img_task()

    if job is None:
      print(f"{jigeum.seoul.now()} - job is None")
      return

    num = job['num']
    file_name = job['file_name']
    file_path = job['file_path']

    # STEP 2
    # RANDOM 으로 0 ~ 9 중 하나 값을 prediction_result 컬럼에 업데이트
    # 동시에 prediction_model, prediction_time 도 업데이트
    presult = prediction(file_path, num)

    print(jigeum.seoul.now())

    # STEP 3
    # LINE 으로 처리 결과 전송
    send_line_noti(file_name, presult)

def send_line_noti(file_name, presult):
    KEY = os.environ.get('API_TOKEN')
    url = "https://notify-api.line.me/api/notify"
    data = {"message": "성공적으로 저장했습니다!"}
    headers = {"Authorization": "Bearer " + KEY}
    response = requests.post(url, data=data, headers=headers)
    print(response.text)
    print("SEND LINE NOTI")

 

 

근데 여기서 문제는....

NULL값을 채우는게 내가 일일이 해줘야 하기때문에 번거롭다.

어차피 이 기능은 Docker 로 띄울거라 차라리 스케줄을 정해놓고 주기적으로 채워주는게 더 효율적이다.

 

따라서 선택지는 2개이다.

ariflow를 쓸건지? cron을 쓸건지?

 

결론적으로는 cron을 사용하였다. airflow는 cron에 비해 무거운 파일이기때문에 내가 사용할 서버스펙이 안될 것 같아서이다..!

 

 

 

2. "ml-worker" 모듈을 자동적으로 실행해줄 cron 세팅하기

 

cron을 이용하여 ml-worker를 매분마다 실행할 것인데 log도 함께 기록할 것이다.

따라서, 로그를 저장하기 위해 ml-work-cronjob이라는 파일을 별도로 만들었다.

 

 

ml-work-cronjob

* * * * * /usr/local/bin/ml-worker >> /var/log/worker.log 2>&1
  • * * * * *:  매분마다 실행
  • /usr/local/bin/ml-worker: ml-worker를 실행
  • >> /var/log/worker.log: ml-worker 명령의 표준 출력(stdout) 결과를 /var/log/worker.log 파일에 추가(append)
  • '>>' 의 의미는 덮어쓰기
  • 2>&1: 표준 오류(stderr)를 표준 출력(stdout)과 동일한 파일에 기록하라는 의미
2>&1 ??
1: 표준 출력(stdout)의 파일 디스크립터를 의미. 기본적으로 명령어 실행 시 정상 출력
2: 표준 오류(stderr)의 파일 디스크립터. 명령 실행 중 에러가 발생하면 이 경로로 에러 메시지가 출력
>&: 리다이렉션을 나타내는 기호. 이것은 2번(표준 오류)을 1번(표준 출력)으로 리다이렉트하라는 의미

즉, 2>&1는 표준 오류(2)를 표준 출력(1)과 동일한 곳으로 보내라는 의미
이것을 포함한 명령은 실행 중에 발생한 에러 메시지를 표준 출력과 함께 동일한 파일이나 콘솔로 출력

 

그럼 이제 cron은 어떻게 실행할까?

실행파일인 sh 를 별도로 만들어 실행해줄 것이다!

(단순히 명령어를 입력하여 실행할 수 도 있지만 나는 ml-worker를 도커 이미지로 실행시킬 것이기 때문에.... 별도로 파일을 만들었다.)

 

 

run.sh

#!/bin/bash

service cron start;uvicorn main:app --host 0.0.0.0 --port 8080 --reload

나는 FastAPI로 기능을 만들었기 때문에 FastAPI 실행 명령어도 추가해주었다.

포트번호를 8080으로 준 이유는 db와 마찬가지로 해당 기능도 도커 이미지로 만들어 실행시킬 것이기 때문이다.

(참고로 도커 내부 포트번호는 8080)

 

이제 도커이미지를 만들기위해 세팅을 해주자!

 

 

 

3. Docker 이미지로 만들어 실행해보기

 

Dockerfile

FROM python:3.11

WORKDIR /code

RUN apt update
RUN apt install -y cron
COPY ml-work-cronjob /etc/cron.d/ml-work-cronjob
RUN crontab /etc/cron.d/ml-work-cronjob
RUN apt install -y vim

COPY src/mnist/main.py /code/
COPY run.sh /code/run.sh

RUN pip install --no-cache-dir --upgrade git+<깃허브 주소>@<깃허브 태그>

CMD ["sh", "run.sh"]

 

여기서 주의해야할 점은 run.sh과 ml-work-cronjob도 함께 도커 이미지 내 복사해주어야 한다!!

 

이제 세팅을 다 해주었으니, 도커 이미지를 build하고 run해보자.

나는 도커 이미지를 build한 후, 아래와 같이 run 하였다.

docker run -p 8001:8080 \
--name mnist \
-e DB=<도커로 띄운 DB IP주소> \
-e DB_PORT=<도커로 띄운 DB 포트번호> \
-e API_TOKEN=<내 라인토큰> \
<도커이미지명>:<태그>

 

-e 옵션은 내 코드 내 환경변수를 설정할 수 있다.

 

도커를 띄운 후, API에 들어가 이미지를 넣고 실행을 시키니...! 아래와 같은 에러가 떴다.

 

에러메세지

 

내 DB 서버랑 연결을 할 수 없다는 에러메세지였는데, 처음엔 IP주소가 잘못된건가 싶어

Docker inspect <컨테이너명>

위 명령어를 통해 IP주소를 다시 확인했는데 잘못된건 없었다.

 

해결방법을 찾아보니, 서버 연결이 간혹 안될 때가 있어서 도커를 실행할 때 --link 옵션을 추가해 명령어를 주니 해결이 되었다.

 

해결방법

docker run -p 8001:8080 \
--name mnist \
--link <도커로 띄운 DB 이름> #link 옵션 추가
-e DB=<도커로 띄운 DB 이름> \
-e DB_PORT=<도커로 띄운 DB 내부 포트번호> \
-e API_TOKEN=<내 라인토큰> \
<도커이미지명>:<태그>

 

다시 API화면에서 사진을 업로드를 하면 아래와 같이 db에 저장된다.

 

그리고 도커 내부로 들어가 ml-worker 명령어를 주면?

NULL값에 랜덤숫자가 잘 들어간다!!

라인알람도 잘 온다!!

 

 

다음 STEP으로는...

랜덤값으로 주었던 predict를 이번엔 진짜 딥러닝 모델을 입혀서 숫자 이미지를 넣으면 그 숫자가 무엇인지 predict에 출력해주는 코드로 수정해보자.

 

 

 

4. 숫자이미지를 넣으면 예측해주는 기능을 진짜 딥러닝 모델 입히기

 

딥러닝 모델을 입히려면 먼저 모델이 있어야 한다. 

나는 model이라는 폴더를 만들어 model.py을 만들었다. 딥러닝 모델을 불러와 숫자를 예측해주는 리얼 기능이다.

딥러닝 모델은 "mnist240924.keras"을 사용하였다.

 

model.py

import numpy as np
from PIL import Image
from keras.models import load_model
import os

def get_model():
    # 모델 로드
    f= __file__
    dir_name = os.path.dirname(f)
    model_path = os.getenv('MODEL_PATH', os.path.join(dir_name, "mnist240924.keras"))
    model = load_model(f'{model_path}')

    return model

def get_model_name():
    f= __file__
    dir_name = os.path.dirname(f)
    model_path = os.getenv('MODEL_PATH', os.path.join(dir_name, "mnist240924.keras"))

    return model_path

# 사용자 이미지 불러오기 및 전처리
def preprocess_image(file_path):
    img = Image.open(file_path).convert('L')  # 흑백 이미지로 변환
    img = img.resize((28, 28))  # 크기 조정

    # 흑백 반전
    img = 255 - np.array(img)  # 흑백 반전
    #img = np.array(img)

    img = img.reshape(1, 28, 28, 1)  # 모델 입력 형태에 맞게 변형
    img = img / 255.0  # 정규화

    return img

# 예측
def predict_digit(file_path):
    model = get_model()
    img = preprocess_image(file_path)
    prediction = model.predict(img)
    digit = np.argmax(prediction)

    return digit

 

위 코드는 모델을 불러와서 사용자가 이미지를 넣으면 해당 이미지를 모델 입력 형태에 맞게 변형하고 정규화해 최종적으로 숫자를 예측해주는 기능이다.

 

주의해야할 점은 이미지의 배경색에 따라 코드가 달라진다.

img = 255 - np.array(img)  # 배경색이 흰색이며 글씨가 검은색일 때 사용
img = np.array(img) #배경이 검은색이고 글씨가 흰색일 때 사용

 

 

이 작업을 마쳤으면 worker.py도 수정해줘야 한다.

이미 어느정도 세팅을 해놓아서 prediction 함수만 수정해주면 된다.

 

worker.py 수정

def prediction(file_path, num):
    sql = """UPDATE image_processing
    SET prediction_result=%s,
        prediction_model=%s,
        prediction_time=%s
    WHERE num=%s
    """
    presult = predict_digit(file_path) #랜덤숫자에서 진짜 예측해주는 걸로 변경
    model = get_model_name()
    model_name = os.path.basename(model)
    print(model_name)
    dml(sql, presult, model_name, jigeum.seoul.now(), num)

    return presult

presult 부분을 원래는 랜덤숫자가 들어가는 것이었는데, 진짜 예측해주는 함수로 넣어주었다.

그리고 model_name도 "mnist240924.keras"로 넣어주었다!

 

그럼 Docker 이미지로 만들 준비는 마쳤다.

에러가 없다면 이미지를 업로드하고 1분 후 prediction_result에 예측한 숫자가 들어갈 것이다.

 

근데......!

 

예측한 숫자가 정확한건지...? 확인을 해야하는 어떻게 하지?! 라는 의문이 들 것이다.

그래서 DB에 들어가는 sql문에 "label"이라는 컬럼을 추가해주고, 진짜 정답을 거기에 넣어줄 것이다.

 

main.py

@app.post("/uploadfile/")
async def create_upload_file(file: UploadFile):
    #파일 저장
    img = await file.read()
    file_name = file.filename
    file_ext = file.content_type.split('/')[-1]
    file_label = os.path.splitext(file_name)[0]

    upload_dir = os.getenv('UPLOAD_DIR', "/Users/seon-u/code/mnist/img")
    if not os.path.exists(upload_dir):
        os.makedirs(upload_dir)

    import uuid
    file_full_path = os.path.join(upload_dir, f'{uuid.uuid4()}.{file_ext}')

    with open(file_full_path, 'wb') as f:
        f.write(img)

	#label 컬럼 추가
    sql = "INSERT INTO image_processing(`file_name`, `label`, `file_path`, `request_time`, `request_user`) VALUES(%s,%s,%s,%s,%s)"

    import jigeum.seoul
    from mnist.db import dml
    insert_row = dml(sql, file_name, file_label, file_full_path, jigeum.seoul.now(), 'n01')

    return {
            "filename": file.filename,
            "content_type": file.content_type,
            "file_full_path": file_full_path,
            "time": jigeum.seoul.now(),
            "insert_row_cont": insert_row
            }

 

복잡한 것은 아니고 sql에 INSERT 문에 `label`을 추가해주고 insert_row에도 어떤 값이 들어갈 건지 정해주면 된다.

나는 파일명을 무조건 이미지의 숫자로 지정을 한다는 가정하에 이미지 확장자를 뺀 진짜 "이름"?만 넣어주었다.

 file_label = os.path.splitext(file_name)[0]

 

 

 

5. 다시 Docker 이미지로 만들어 로컬이 아닌 AWS 에서 실행해보기

 

위에 작업을 다 마쳤다면 다시 Docker 이미지로 만들고 AWS에서 run 해보자!

 

AWS에서 도커 실행하기 (이전글 참고)

sudo docker run -d \
-p 8001:8080 \
--name mnist01 --link mnist-mariadb \
-e API_TOKEN=<토큰입력> \
-e DB=mnist-mariadb -e DB_PORT=3306 \
-v /home/ubuntu/images:/home/ubuntu/images \
-e UPLOAD_DIR=/home/ubuntu/images/n01 \
<도커이미지>:<태그>

 

  • UPLOAD_DIR=: 사용자가 이미지를 업로드할 때마다 도커 컨테이너 안 /home/ubuntu/images/n01 라는 경로에 이미지 저장
  • -v /home/ubuntu/images:/home/ubuntu/images: 호스트 시스템 AWS 경로/home/ubuntu/images 에 있는 이미지가 도커 컨테이너 내부 /home/ubuntu/images 와 연결된다.
  • 즉, 따라서 컨테이너 내부에서 /home/ubuntu/images/n01 경로에 파일을 업로드하거나 생성하면, 호스트 시스템의 /home/ubuntu/images/n01 경로에서도 그 파일을 확인할 수 있다.
  • 주의해야할 점은 호스트 시스템에 해당 경로가 있어야 한다.

 

하면 끝나는 줄 알았는데.....

 

오류가 있다. 1분마다 예측값도 잘 넣어지는데 라인알람은 오지 않는다....

라인토큰도 환경변수 옵션을 줘서 잘 넣어줬는데 왜지?!! 도커 컨테이너 내부에서도 환경변수가 잘 설정되어있다.

 

 

 

6. AWS에서 도커를 실행하였을 때, 라인알람 문제 해결하기

 

이유는 잘 모르겠지만, 로컬에서 도커를 실행하면 문제없는데 AWS에서 도커를 실행하니 해당 문제가 있었다.

해결방법을 찾아보니, 도커를 build할 때, run해주는 파일에 아래 코드를 추가하면 된다.

 

run.sh

#!/bin/bash

env >> /etc/environment; #여기 추가
service cron start;uvicorn main:app --host 0.0.0.0 --port 8080 --reload
  • env >> /etc/environment: env 에 값들을 /etc/environment에 추가
  • env 안에는 환경변수 설정한 값들이 들어가 있다. 해당 값들을 etc/environment에 추가해주는 것

 

이렇게 설정해주고 도커 이미지를 다시 빌드해 실행해주니 라인알람 문제가 해결되었다!!  

 

 

 

일주일을 보내면서.....

 

이번 일주일은 뭔가 배우긴 했지만, 머릿속에 깔끔하게 정리가 되지 않는 느낌이 들었다. 다양한 작업을 해본 것 같은데, 그 흐름이 자연스럽게 이어지지 않고, 어딘가 끊기는 기분이었다. 특히나 여러 가지 에러를 만났고, 그로 인해 한참을 삽질하며 시간을 보냈다. 그런 에러들이 때로는 예상하지 못한 아주 단순한 방법으로 해결되었을 때, 허탈하기도 했다. 그 순간엔 웃음이 나기도 했지만, 동시에 에러 해결에 있어서 익숙해지고 있다...!

이번 주에 겪었던 어려움 중 하나는 데이터 분석 툴인 판다스(Pandas)였다. 여전히 판다스를 다루는 것은 쉽지 않았다. 데이터를 정리하고 처리하는 과정에서 여러 번 벽에 부딪혔고, 그것들이 어떻게 해결되어야 하는지에 대한 고민이 많았다. 그렇지만 공모전을 진행하면서 예전에 비해 내가 판다스를 더 잘 이해하고 있다는 사실을 느꼈다. 조금씩 나아지고 있다는 것이 보이는 것만으로도 다행이라는 생각이 들었다. 코드를 작성하는 속도나 능력도 조금씩 개선되고 있는 것 같다. 과거에 비하면 파이썬으로 문제를 해결하는 것이 자연스러워졌고, 이는 꾸준히 공부한 덕분이라고 생각한다. 

 


앞으로 나의 방향


다음 프로젝트까지 이제 일주일이 남았고, 그동안 조금은 여유를 즐기고 싶은 마음이 있다. 하지만 아마 현실적으로 그러기 힘들지 않을까 싶다. 아직 부족한 부분들이 많고, 프로젝트를 준비하는 동안 계속해서 공부해야 할 것들이 쌓여가고 있기 때문이다. 아직 갈 길이 멀다는 생각도 든다. 단순히 코드를 작성하는 것에 그치지 않고, 왜 이런 방식으로 접근하는지, 더 효율적인 방법은 없는지에 대해 깊이 고민하는 연습도 필요할 것이다.

파이썬도 계속해서 공부해야겠다. 요즘 들어 조금씩 코드 작성 능력이 향상되고 있다는 느낌을 받지만, 아직도 배워야 할 내용들이 많다. 앞으로도 꾸준히 파이썬을 연습하고, 더 나은 코드 작성 방법과 문제 해결 능력을 기르기 위해 노력해야겠다. 특히 코딩을 할 때 논리적으로 사고하고, 에러에 직면했을 때 당황하지 않고 차근차근 해결해나가는 방법을 익혀야 한다.

이번 주는 배운 것들이 다양해서 그것들이 아직 제대로 정리되지 않은 상태다. 앞으로 남은 시간 동안 배우고 있는 것들을 체계적으로 정리하고, 프로젝트에 필요한 부분들을 차근차근 준비해야겠다.