Flask + Gunicorn with Docker

가끔 유지보수 하고 있던 node.js 기반의 텔레그램 봇에서 파이썬을 도입하게 되었다. 그 이유는, 봇의 특정 기능 중 요약문을 제공하는 기능을 필요로 했었고, Komoran + TextRank 알고리즘 기반으로 구성하는 것이 그나마 빠르게 진행할 수 있었기 때문이었다.

다만, 다른 곳에서 해당 기능을 사용하므로 api로 배포할 필요가 있었고, 전체적인 구조는 다음과 같다.

  • 텔레그램 봇 —> Textrank API —> TextRank Analyzer

이 중, API를 사용하기 위해 Flask-RESTFul 를 사용하고, 배포는 Python WSGI 웹 서버인 Gunicorn 를 사용했다.

이 글에서는 TextRank Analyzer 를 API로 expose 하고, Docker로 배포하는 과정을 정리한다.

API 연결

먼저, TextRank 알고리즘 수행을 위해 외부에서 받아 와야 할 정보들에 대해 정리가 필요하다.

  • 요약할 문장 (필수값)
  • 최대 요약할 문장 갯수 (기본값 3)
  • 최대 요약할 키워드 갯수 (기본값 3)
  • PageRankDamping Factor 값 → 사용자가 클릭을 중단할 확률. 기본값 0.85
  • 최상위에 랭크된 문장과 그 다음 문장 간의 최소 코사인 거리. 기본값 0.3
  • 허용 단어 빈도 (최소 n번 사용된 문장을 선택하나 m번 이상 사용된 문장은 사용하지 않음). 기본값 3, 20
  • 최소 문장 단어 수 (최소 n개 단어를 사용한 문장을 사용하나 m개 이상 단어가 사용되면 사용하지 않음). 기본값 10, 80

이제, 이 값들을 body로 받아 보관해야 하는데, Flask-RESTFul 는 reqparse 라는 기능을 제공한다.

이는 argument parsing 에 많이 쓰이는 argparse 와 비슷한 사용법을 가지면서도 사용자의 post body 를 해석하는 기능을 제공한다.

사용법은 `parser.add_argument(name, type) 이고, required 여부나 default 도 지정할 수 있다.

이를 반영한 API 클래스는 다음과 같을 것이다.

class TextRank(Resource):
    def post(self):
        try:
            parser = reqparse.RequestParser()
            parser.add_argument('body', type=str, required=True) 
            parser.add_argument('sentenceCount', type=int, default=3, required=False) 
            parser.add_argument('keywordCount', type=int, default=5, required=False)  
            parser.add_argument('beta', type=float, default=0.85, required=False) 
            parser.add_argument('diversity', type=float, default=0.3, required=False)  
            parser.add_argument('minWordCount', type=int, default=3, required=False)  
            parser.add_argument('maxWordCount', type=int, default=20, required=False)  
            parser.add_argument('minSentencePenalty', type=int, default=10, required=False) 
            parser.add_argument('maxSentencePenalty', type=int, default=80, required=False) 
            args = parser.parse_args()

            ...

            return {
                'code': '1',
                'message': 'Success',
                'sentence': sentence,
                'keyword': keywords
            }

        except Exception as e:
            return {'code': -1, "message": 'Failed ' + str(e)}

만들어진 TextRank 클래스를 Flask에 연결하게 되면, 지정한 routing 에 따라 API가 호출된다.

app = Flask(__name__)
api = Api(app)
api.add_resource(TextRank, '/summarize')

if __name__ == '__main__':
    app.run(port=8000)

이와 같이 작성하고 Run을 하여 127.0.0.1:8000/summarize 로 API 요청을 보내면 결과가 나오게 된다.

Gunicorn 배포

python3 server.py 로 서버를 실행시킬 수 있지만, 이는 development 기준으로 배포용도에는 맞지 않다.

실제로, 위 명령어로 배포하게 되면 아래와 같은 메세지가 나오게 된다.

* Serving Flask app "server" (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://127.0.0.1:8000/> (Press CTRL+C to quit)

요점은 production WSGI server 를 사용하라는 것이다. 여기서 WSGI 는 PEP-3333 에 정의된 파이썬 웹 서버 게이트웨이로, 파이썬 스크립트가 웹 서버와 통신하기 위해 작성된 인터페이스라고 설명할 수 있다.

여기서는 상기하였듯이 Gunicorn 를 사용했고, 사용법은 다음과 같다.

gunicorn server:app

여기서 server:app 는 각각 Flask를 담고 있는 파이썬 스크립트와 Flask 로컬 변수를 의미한다.

명령어를 입력하면 아래와 같은 로그가 나오게 된다.

% gunicorn server:app
[2020-02-15 13:15:14 +0900] [38332] [INFO] Starting gunicorn 20.0.4
[2020-02-15 13:15:14 +0900] [38332] [INFO] Listening at: <http://127.0.0.1:8000> (38332)
[2020-02-15 13:15:14 +0900] [38332] [INFO] Using worker: sync
[2020-02-15 13:15:14 +0900] [38336] [INFO] Booting worker with pid: 38336

기본적으로 127.0.0.1 (loopback) 주소에만 대응하고 있는데, 이를 도커 등으로 활용하려면 0.0.0.0:8000 등으로 port만 지정할 필요가 있다.

이는 -b 0.0.0.0:8000 으로 지정할 수 있고, 최종 명령어는 다음과 같다.

gunicorn -b 0.0.0.0:8000 server:app

이를 실행하면 다음과 같이 나온다.

% gunicorn -b 0.0.0.0:8000 server:app
[2020-02-15 13:16:04 +0900] [38387] [INFO] Starting gunicorn 20.0.4
[2020-02-15 13:16:04 +0900] [38387] [INFO] Listening at: <http://0.0.0.0:8000> (38387)
[2020-02-15 13:16:04 +0900] [38387] [INFO] Using worker: sync
[2020-02-15 13:16:04 +0900] [38391] [INFO] Booting worker with pid: 38391

Docker 배포

위 gunicorn 을 사용해 Dockerfile로 만들면 다음과 같을 것이다.

FROM ubuntu
WORKDIR /usr/src/app
RUN rm -rf /var/lib/apt/list/* && apt-get update && apt-get install python3 python3-pip -y
ADD . .
RUN pip3 install -r requirements.txt
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "server:app"]

간단히, python 과 python3-pip 를 설치하고 모든 파일을 복사하여 requirements.txt 에 적힌 항목을 전부 설치한다. 마지막으로, 포트 8000를 expose 하여 gunicorn를 실행한다.

FFmpeg Loudnorm 필터를 이용한 볼륨 평준화 기능

도입

지난 글에서 소개한 MusicBot(https://pyxispub.uzuki.live/?p=1648)의 기능 중에, 볼륨 평준화 기능이 있다.

해당 기능은 volumedetect 필터를 사용해서 max_volume를 추출해서 해당 max_volume에 -1를 곱한 수치를 volume=%sdB 로 설정하는 방식으로 구현되어 있다.

VolumeDetect 필터를 사용하면 다음과 같은 정보를 가지고 올 수 있다.

n_samples: 4845242
mean_volume: -25.8 dB
max_volume: -8.6 dB
histogram_9db: 245
histogram_10db: 4214
histogram_11db: 12522

하지만 volume 필터는 음원에 있는 샘플의 볼륨을 지정한 값 만큼 변환하는 작업을 하기 때문에, 음원의 왜곡이 거의 없으나 클리핑(0dBFS를 넘는 오디오 샘플을 재생하려 할 때 의도되로 재생되지 않는 현상) 이 일어남에 따라 실제 사용하기에는 치명적인 문제점이 존재한다.

따라서 FFmpeg의 Loudnorm(https://ffmpeg.org/ffmpeg-filters.html#loudnorm) 필터를 사용하여 이를 보정하기로 했고, Python으로 구현한 결과를 간단히 정리하려 한다.

Loudnorm 필터의 장점과 단점

앞서 volumedetect 필터는 임계점을 넘어 클리핑이 발생한다고 언급했는데, Loudnorm 필터는 이 임계점을 넘지 않게 하는 True Peak Limiter 를 내장하고 있기 때문에 클리핑이 발생할 수 없게 된다.

또한, 인지 음량(perceived loudness, 사람이 소리를 들을 때 주파수에 따라 인지하는 음량에 차이가 있는 것을 설명)을 고려하기 때문에, 단순히 dBFS 를 기준으로 보정을 가할 때 보다 사람의 귀에 들리는 소리의 크기에 맞춰서 정확하게 측정할 수 있다.

다만 인코딩할 때 시간이 걸리고, 부분부분 적절하게 조절하는 것이 아니고 전체적으로 목표 수치에 맞춰서 조절하는 것이므로 이에 수반하는 다양한 현상 (소리 크기가 왕복하거나, 잡음이 크게 들린다던가, 배경 소리가 크게 들린다던가 등)이 나타나게 되는데, 이는 오디오 자체를 변형시키는 것이기 때문에 왜곡은 당연한 것이라고 봐야 한다.

적용 방법

먼저, single pass 인코딩을 구동하여 인풋된 볼륨 정보를 찾는다. 여기서는 파일을 저장할 필요가 없으므로 -f null 를 통하여 아웃풋을 저장하지 않도록 한다.

ffmpeg -i {input_File} -af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=summary -f null /dev/null

이 결과로, 아래와 같은 텍스트를 얻을 수 있다.

[Parsed_loudnorm_0 @ 00a4e800] 
Input Integrated:   -11.8 LUFS
Input True Peak:     +0.5 dBTP
Input LRA:             7.8 LU
Input Threshold:     -21.9 LUFS

Output Integrated:   -16.0 LUFS
Output True Peak:     -1.5 dBTP
Output LRA:           7.0 LU
Output Threshold:   -26.0 LUFS

Normalization Type:   Dynamic
Target Offset:       -0.0 LU

여기에서 주목해야 되는 값은 Input Integrated, Input True Peak, Input LRA, Input Threshold. Target Offset 이다. 각각 평균 볼륨, 최대 임계점, 볼륨 범위, 임계값, gain offset 를 나타낸다.

이 정보를 반영해서 다시 한번 인코딩을 하면 더 정확하게 나오는데, 이 방법이 dual pass이다.

ffmpeg -i {input_FILE} -af loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-11.8:measured_TP=0.5:measured_LRA=7.8:measured_thresh=-21.9:offset=0:linear=true::print_format=summary...

Input Integrated 값을 measured_I에, Input True Peak를 measured_TP, Input LRA를 measured_LRA, Input Threshold를 measured_thresh, Target Offset를 offset에 대응시켜서 명령어를 구동하면 된다.

실행한 결과는 다음과 같다.

Input Integrated:    -11.8 LUFS
Input True Peak:     +0.4 dBTP
Input LRA:             7.9 LU
Input Threshold:     -21.8 LUFS

Output Integrated:   -16.0 LUFS
Output True Peak:     -3.8 dBTP
Output LRA:           7.9 LU
Output Threshold:   -26.0 LUFS

Normalization Type:   Linear
Target Offset:       -0.0 LU

음원에 따라 다르겠지만, 사용한 음원의 경우 single pass와 double pass의 목표값(Output Integrated) 는 같게 나왔다. 의도와는 살짝 다르지만 loudnorm 필터는 완벽하게 인코딩을 하는 것이 아닌, 최대한 의도에 맞게 인코딩한 결과를 보장한다고 볼 수 있다.

음원 비교

사용한 음원은 Unminus 의 Lit(https://soundcloud.com/wowamusik/lit-120-bpm-latino-party-wwwwowame?in=wowamusik/sets/free-do-whatever-you-want) 음원이고, 보정한 결과는 다음과 같다.

마무리

loudnorm 필터를 실제 음악 봇에 적용하고 올리고 테스트를 몇 번 거친 결과, 시간은 다소 오래 걸릴지 몰라도 계속 volume를 부분적으로 적용하던 불편함은 없어졌다고 한다.

마지막으로, 구현에 사용한 코드는 다음과 같다.

async def get_loudnorm_argument(self, input_file):
    log.debug('Calculating loudnorm argument of {0}'.format(input_file))
    cmd = '"' + self.get(
        'ffmpeg') + '" -i "' + input_file + '" -af "loudnorm=I=-16:TP=-1.5:LRA=11:print_format=summary" -f null /dev/null'
    output = await self.run_command(cmd)
    output = output.decode("utf-8")

    input_integrated_matches = re.findall("Input Integrated:[ ]*([\-+\d\.]+) LUFS", output)
    input_tree_peak_matches = re.findall("Input True Peak:[ ]*([\-+\d\.]+) dBTP", output)
    input_lra_matches = re.findall("Input LRA:[ ]*([\-+\d\.]+) LU", output)
    input_threshold_matches = re.findall("Input Threshold:[ ]*([\-+\d\.]+) LUFS", output)
    offset_matches = re.findall("Target Offset:[ ]*([\-+\d\.]+) LU", output)

    log.debug(output)

    if input_integrated_matches:
        integrated = float(input_integrated_matches[0])
    else:
        integrated = float(0)

    if input_tree_peak_matches:
        tp = float(input_tree_peak_matches[0])
    else:
        tp = float(0)

    if input_lra_matches:
        lra = float(input_lra_matches[0])
    else:
        lra = float(0)

    if input_threshold_matches:
        threshold = float(input_threshold_matches[0])
    else:
        threshold = float(0)

    if offset_matches:
        offset = float(offset_matches[0])
    else:
        offset = float(0)

    argument = 'loudnorm=I=-16:TP=-1.5:LRA=11:measured_I={0}:measured_TP={1}:measured_LRA={2}:measured_thresh={3}:offset={4}:linear=true:print_format=json' \
            .format(integrated, tp, lra, threshold, offset)

    operate = integrated != float(0) or tp != float(0) or lra != float(0) \
              or threshold != float(0) or offset != float(0)

    log.debug('Calculate complete! argument = {0}, operate = {1}'.format(argument, operate))
    return argument, operate

해당 음악 봇에서는 discords.py로 음원을 전송할 때 ffmpeg로 바로 실행하므로, 여기서는 loudnorm 필터 안에 들어갈 것만 판단하고 보내주는 역할을 한다.