NHKラジオ(らじるらじる)をGoogleCloudPlatformのサービスCloudRunで録音する

[IoTLTアドベントカレンダー](https://qiita.com/advent-calendar/2022/iotlt)最終日を担当するニアムギです。
普段はねこIoTLTで活動しています。年2回ほど、ねこ参加型のオンラインLTを開催しているのでぜひ登壇or視聴お願いしますー

今回は[NHKラジオ(らじるらじる)](https://www.nhk.or.jp/radio/)をGoogleCloudPlatform(以下、GCP)を使って録音する方法について記事にしました。

# きっかけ
子どもの習い事で「気象通報を聞いて天気図を書きましょう」という課題が出ました。
![tenkizu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237144/284a1084-fa7f-d69c-87f6-217f629d8547.png)
ラジオで日本近辺の気象情報を聴きながら専用用紙に各地点の風向き・風力・天気・気圧・気温を記載するといったものです。
※低学年にはかなり無理のある課題のため大人が手伝う前提です。

## 問題点
課題をやるとしても・・・
* ラジオをリアルタイムに聞いて転記できるとは思えない
* そもそも16時にラジオを聞けない(忘れる)
* 気象通報は[聞き逃し番組](https://www.nhk.or.jp/radio/ondemand/)の対象外のため、あとから聞けるサービスがない

ということで自分で録音する必要が出ました。
仕様はこんな感じです
* 1日1回、16時に定期実行する
* 20分間録音する
* 録音データをストレージに保存する

# 録音する環境の選定
どのようにすれば録音できるのか検討しました。

## ラズパイとPythonで録音できる
調べてみると、ラズパイ+Pythonを使って録音している人たちがいました。
仕組みはシンプルで
「あるURLを叩くとリアルタイムのラジオが流れるので、それを録音する」
というものでした。ラズパイは定期実行(Cron)のために使用しています。

以下のようにffmpegを使えば再生中のらじるらじるを録音できます。
``` python
#!/usr/bin/env python
# %%
import ffmpeg
url = 'https://radio-stream.nhk.jp/hls/live/2023501/nhkradiruakr2/master.m3u8'
len = 10
saveFile = 'test_weather.mp3'
stream = ffmpeg.input(url, t=len)
stream = ffmpeg.output(stream, saveFile, format='mp3')
ffmpeg.run(stream)
```
※URLは2022/12/25現在のものとなります。([参考](https://www.nhk.or.jp/radio/config/config_web.xml))
|Type|URL|
|-|-|
|ラジオ第1|https://radio-stream.nhk.jp/hls/live/2023229/nhkradiruakr1/master.m3u8|
|ラジオ第2|https://radio-stream.nhk.jp/hls/live/2023501/nhkradiruakr2/master.m3u8|
|NHK-FM|https://radio-stream.nhk.jp/hls/live/2023507/nhkradiruakfm/master.m3u8|
## でもラズパイではなくあえてGCPを使う
家庭の都合上、ラズパイの使用は不可のため(置いているだけで白い目で見られる)、普段使っているGCPで出来ないか模索しました。**一番のネックは20分の録音です。**
### Pythonを動かす環境の候補
調査した結果は以下の通りです。(ほかの選択肢もあるかもしれません)  
コストと労力を考えてCloudRunを選択しました。
|サービス|どんなもの？|タイムアウト時間|コメント|
|-|-|-|-|
|GoogleCloudEngine|インスタンスを常時動かす|気にしなくてよい|録音するだけなのにやり過ぎ&費用も掛かり過ぎ|
|GoogleCloudFunctions(第1世代)|リクエストを受けたら動く|9分|録音しきれない|
|GoogleCloudFunctions(第2世代)(イベントドリブン)|リクエストを受けたら動く|9分|録音しきれない|
|GoogleCloudFunctions(第2世代)(HTTP関数)|リクエストを受けたら動く|60分|調べてみたけど使い方わからず…|
|GoogleCloudRun|リクエストを受けたら動く|60分|これならいける!!!|

## 組み合わせたサービス
定期実行して録音したファイルを保存するまでの流れをまとめると以下の通りです。
|やりたいこと|サービス|
|-|-|
|1日1回、16時に定期実行する|CloudSchedulerでCron実行する|
|20分間録音する|CloudRunでPythonを動かす|
|録音データをストレージに保存する|GoogleCloudStorageに保存する|

1. Cron実行(16時開始)
2. Cloud Runにリクエストを投げる
3. Cloud Runで録音 + ストレージに保存
![system.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237144/c4109c7a-2321-5afe-ac64-7c04270eac01.png)

## そもそもCloudRunとは？？

[CloudRun のトップページより引用](https://cloud.google.com/run?hl=ja)
**フルマネージドのサーバーレス プラットフォームでお好きな言語（Go、Python、Java、Node.js、.NET）を使用して、スケーラブルなコンテナ型アプリを構築してデプロイできます。**

[CloudRunとは より引用](https://cloud.google.com/run/docs/overview/what-is-cloud-run?hl=ja)
![Cloud-run-service.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237144/81b7f595-a459-a389-e21d-660558b097f7.png)

ざっくり言うと
**「クラウド上にコンテナを置いて、簡単なアプリが作れるサービス」**
です。
GPIOのないラズパイがクラウドにあるイメージかなと思います。

# CloudRunにアプリをデプロイする
方向性が決まったので[コンテナをビルドする](https://cloud.google.com/run/docs/building/containers?hl=ja)を参考に、らじるらじるを録音するコンテナをCloudRunへデプロイします。手順は以下の通りです。
1. Dockerコンテナを準備
2. ローカルでコンテナをビルド(ビルド出来るか試す)
3. CloudBuildでコンテナをビルド
4. CloudRunへデプロイ
5. CloudSchedulerで定期実行を設定する

## Dockerコンテナの準備
以下のファイルを準備します
Dockerfile : Pythonを動かすDocker 
requirements.txt : Pythonで使うライブラリ 
main.py : リクエストを受けてラジオを録音するプログラム 

### Dockerfile
Pythonを動かすシンプルな設定です。

``` docker
ROM python:3.9-slim

# Allow statements and log messages to immediately appear in the Knative logs
ENV PYTHONUNBUFFERED True

# Copy local code to the container image.
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY . ./

RUN apt-get update && apt-get install -y \
  ffmpeg

# Install production dependencies.
RUN pip install --no-cache-dir -r requirements.txt

CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 main:app
```

### requirements.txt

```
Flask==2.1.0
gunicorn==20.1.0
ffmpeg-python==0.2.0
google-cloud-storage==2.1.0
google-cloud-secret-manager==2.12.6
```

### main.py 
ログファイルへ出力しているため、つらつらと長くなっています。
またProjectIDなど固有の情報については外部から設定できるようにしています。今回こだわった点です。※後述参照
録音はrecord関数、Storageへのアップロードは _blob.upload_from_filename(TEMP_SAVEPATH + _fileName) のあたりです。

``` python
import os
import sys
from datetime import datetime, timezone, timedelta
import time
import ffmpeg
from flask import Flask, render_template
from google.cloud import storage, secretmanager

# Environment Value
PROJECT_ID = os.environ.get("PROJECT_ID")
SECRET_ID = os.environ.get("SECRET_ID")
VERSION = os.environ.get("SECRET_ID_VERSION")
URL = os.environ.get("URL")
RECORD_SECOND = int(os.environ.get("RECORD_SECOND"))
SAVE_FILE_TITLE = os.environ.get("SAVE_FILE_TITLE")
SAVE_FOLDER_BUCKET = os.environ.get("SAVE_FOLDER_BUCKET")

# Global Value
TEMP_SAVEPATH = './'
LOG_FILE = 'recordRadiru.txt'
storage_client = storage.Client()
bucket = None
retMsg = list()
app = Flask(__name__)


def setBucket():
    'Connect GoogleCloudStorage Bucket'
    client = secretmanager.SecretManagerServiceClient()
    path = client.secret_version_path(PROJECT_ID, SECRET_ID, VERSION)
    response = client.access_secret_version(name=path)
    bucketName = response.payload.data.decode("UTF-8")
    global bucket
    bucket = storage_client.bucket(bucketName)


def getNowTime():
    'NowTime(JST) YYYYMMDD_hhmmss'
    JST = timezone(timedelta(hours=+9), 'JST')
    _date = datetime.fromtimestamp(time.time(), JST)
    return _date.strftime("%Y%m%d_%H%M%S")


def copyLog():
    global bucket
    blobLogFile = bucket.blob(LOG_FILE)
    with open(TEMP_SAVEPATH + LOG_FILE, 'a') as logfile:
        with blobLogFile.open("r") as f:
            logfile.write(f.read())
        logfile.write("\n")


def infoMsg(func, msg):
    with open(TEMP_SAVEPATH + LOG_FILE, 'a') as logfile:
        _msg = getNowTime() + '\tINFO\t[' + func + '] ' + msg
        logfile.write(_msg + '\n')
    global retMsg
    retMsg.append(_msg)


def errMsg(func, msg):
    with open(TEMP_SAVEPATH + LOG_FILE, 'a') as logfile:
        _msg = getNowTime() + '\tERROR\t[' + func + '] ' + msg
        logfile.write(_msg + '\n')
    global retMsg
    retMsg.append(_msg)


def record(url, len, saveFile):
    _fn = sys._getframe().f_code.co_name
    try:
        infoMsg(_fn, 'Recoding...')
        stream = ffmpeg.input(url, t=len)
        stream = ffmpeg.output(stream, saveFile, format='mp3')
        ffmpeg.run(stream)

    except Exception as e:
        tb = sys.exc_info()[2]
        errMsg(_fn, '{0}'.format(e.with_traceback(tb)))
    infoMsg(_fn, 'end')


def main(isDebug):
    # Set Bucket
    setBucket()
    # Copy Old Log
    copyLog()

    _fn = 'main.py'
    infoMsg(_fn, 'start')
    _url = URL
    _len = RECORD_SECOND
    _saveFileTitle = SAVE_FILE_TITLE
    _saveBucketFolder = SAVE_FOLDER_BUCKET + "/"
    _saveBucketFolder = _saveBucketFolder.replace("//", "/")

    _fileName = getNowTime() + '_'
    _fileName += _saveFileTitle
    _fileName += '.mp3'

    if isDebug:
        _len = 10

    infoMsg(_fn, 'URL : %s' % _url)
    infoMsg(_fn, 'Length : %s' % str(_len))
    infoMsg(_fn, 'SaveFile : %s' % _fileName)
    record(_url, _len, TEMP_SAVEPATH + _fileName)

    # upload
    _blob = bucket.blob(_saveBucketFolder + _fileName)
    _blob.upload_from_filename(TEMP_SAVEPATH + _fileName)
    infoMsg(_fn, 'Save RecordData in Storage : %s' %
            _saveBucketFolder + _fileName)
    _blob = bucket.blob(LOG_FILE)
    _blob.upload_from_filename(TEMP_SAVEPATH + LOG_FILE)
    infoMsg(_fn, 'Save LogFile in Storage : %s' % LOG_FILE)

    infoMsg(_fn, 'end')
    return retMsg


@app.route("/test")
def test():
    isDebug = True
    ret = main(isDebug)
    values = {"message": ret, "isDebug": isDebug}
    return render_template('index.html', data=values)


@app.route("/", methods=["POST"])
def index():
    isDebug = False
    ret = main(isDebug)
    values = {"message": ret, "isDebug": isDebug}
    return render_template('index.html', data=values)


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
```
#### StorageのBucketNameを隠す

StorageのBucketNameが誤って外部に漏れないようにするため、[SecretManager](https://cloud.google.com/secret-manager?hl=ja)というサービスを利用しています。
![SecretManager.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237144/fb2419c4-e7ef-5cc5-1c90-d92dfa3183f6.png)

そのためソースは以下のように少し分かりづらい書き口となっています。
詳しくは[シークレットの作成とアクセス](https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets?hl=ja#secretmanager-create-secret-console)を参照ください
``` python
def setBucket():
    'Connect GoogleCloudStorage Bucket'
    client = secretmanager.SecretManagerServiceClient()
    path = client.secret_version_path(PROJECT_ID, SECRET_ID, VERSION)
    response = client.access_secret_version(name=path)
    bucketName = response.payload.data.decode("UTF-8")
    global bucket
    bucket = storage_client.bucket(bucketName)
```



## ローカルでコンテナをビルド(ビルド出来るか試す)

``` bash
export DOCKER_TAG=recradio:latest
docker build --tag ${DOCKER_TAG} .
```

## CloudBuildでコンテナをビルド

```
export GOOGLE_CLOUD_PROJECT=【ProjectIDをセットする】
export DOCKER_TAG=recradio:latest
export IMAGE_URL=gcr.io/${GOOGLE_CLOUD_PROJECT}/${DOCKER_TAG}
gcloud builds submit --tag ${IMAGE_URL}
```
![Container.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237144/e1886f7b-8bc8-2b99-891d-e71fca647055.png)


## CloudRunへデプロイ
汎用性を持たせるため、固有の定数はデプロイ時に出来る限り設定しています。

``` bash
export GOOGLE_CLOUD_PROJECT=【ProjectIDをセットする】
export DOCKER_TAG=recradio:latest
export JOB_NAME=recradio
export IMAGE_URL=gcr.io/${GOOGLE_CLOUD_PROJECT}/${DOCKER_TAG}
export REGION=asia-northeast1
export SECRET_MANAGER_ID=rediruBucket
export SECRET_MANAGER_ID_VERSION=latest

gcloud run deploy ${JOB_NAME} \
    --image ${IMAGE_URL} \
    --region ${REGION} \
    --timeout=30m \
    --set-env-vars "PROJECT_ID=${GOOGLE_CLOUD_PROJECT}" \
    --set-env-vars "SECRET_ID=${SECRET_MANAGER_ID}" \
    --set-env-vars "SECRET_ID_VERSION=${SECRET_MANAGER_ID_VERSION}" \
    --set-env-vars "URL=https://radio-stream.nhk.jp/hls/live/2023501/nhkradiruakr2/master.m3u8" \
    --set-env-vars "RECORD_SECOND=1500" \
    --set-env-vars "SAVE_FILE_TITLE=weather" \
    --set-env-vars "SAVE_FOLDER_BUCKET=record/"
```

CloudRunにコンテナがビルドされ、アクセス用のURLが生成されます
![CloudRun_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237144/5c921a66-75c6-ffa7-b4b3-361053a19748.png)


## CloudSchedulerで定期実行を設定する
CloudRunへ定期的にアクセスするスケジュールを設定します

頻度を"00 16 * * *" (毎日16時)に設定
![Scheduler_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237144/cdd4758d-f662-9c4f-5b6b-39d25ff6ba5c.png)
時間になったらCloudRunのコンテナのURLにPOSTするように設定
![Scheduler_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237144/58ce103a-16ae-bd50-832e-86e608f253e2.png)
最終的には以下のようにスケジュールが設定されます
![Scheduler_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237144/fc2db8a7-4ec4-dc2f-5843-8d4a26f662e4.png)

# 結果
定期的に録音できました！
※若干タイミングがずれるため、16時ピッタリから録音するのは無理でした。
![Storage_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/237144/1f810e1f-5b17-ee26-60d7-e32131044c8b.png)


# まとめ
* 「ローカルPCの時と同じように録音できる︖20分も動かせる︖」と思っていましたが、あっさりとデータを取得出来て驚きました。
* おそらくCloudRunは例えば画像認識やログの整形などちょっとした処理を実行するのに向いているのだと思います。
(1回のリクエストに20分も動かすのは上手い使い方ではないかと...)

何か参考になれば嬉しいです。ではでは。



# 参考URL
[NHKラジオ(らじるらじる)](https://www.nhk.or.jp/radio/)
[NHKネットラジオらじる★らじるをラズベリーパイで自動録音する](https://so2-2009.hatenadiary.org/entry/20211111/1636602441)
[CloudFunctions 関数のタイムアウト](https://cloud.google.com/functions/docs/configuring/timeout?hl=ja#:~:text=Cloud%20Functions%EF%BC%88%E7%AC%AC%201%20%E4%B8%96%E4%BB%A3,%E5%88%86%EF%BC%88540%20%E7%A7%92%EF%BC%89%E3%81%A7%E3%81%99%E3%80%82)
[CloudRun のトップページ](https://cloud.google.com/run?hl=ja)
[CloudRunとは](https://cloud.google.com/run/docs/overview/what-is-cloud-run?hl=ja)
[コンテナをビルドする](https://cloud.google.com/run/docs/building/containers?hl=ja)
[シークレットの作成とアクセス](https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets?hl=ja#secretmanager-create-secret-console)