株式会社Berry
🦆

AWS Lambdaにblenderを載せてサーバーレスなレンダリングサーバーを作る

2024/09/20に公開

初めまして、株式会社Berryの齋藤です。
みなさまLambdaはやっておりますでしょうか。

Berryでも3Dデータの自動処理を行う上で数多くのLambda関数を作成、運用しています。
その中で3Dデータのプレビュー生成が必要になったため、blenderによるプレビュー生成を行うことにしました。

通常であればEC2を使い、レンダリングサーバーを立てることが一般的かと思いますが、費用面・運用面を考慮し、Lambdaによるサーバーレスなレンダリングサーバーを作成することにしました。

非常にニッチなユースケースですが、ざっと検索したところ日本語の情報が少なかったので、今回はblenderをLambda上で動かす方法を紹介したいと思います。

サンプルリポジトリ

https://github.com/berry-devs/blender-on-lambda-sample

前提条件

  • AWS CLIとAWSアカウントが設定済み
  • Dockerインストール済み
    (x64のCPUで検証しています。armの場合はダウンロードするblenderやlambdaデプロイ時の設定をいじる必要があるかもしれません)

構成

AWS Lambdaでは、コンテナイメージを使った関数を作成することができます。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/images-create.html

このコンテナイメージ、Lambdaのランタイムインターフェースクライアント(RIC)さえ入っていればAWS謹製のベースイメージ以外でも作成することができます。

つまり大体のことがLambda上でできるということです。

ということで使い慣れたubuntuベースのイメージにblenderを載せていきましょう。

処理の流れとしては下記の流れになります。

  1. Lambdaハンドラで受け取ったリクエストを展開し、blenderのスクリプトに渡す引数を取得する
  2. blenderで読み込ませるobjファイルをS3からダウンロードする
  3. blenderスクリプトをsubprocessで呼び出す
    • --background --python ${pythonスクリプト}という形で実行することができます。
  4. 出力ファイルをS3にアップロードする

そのためにやることは下記の通りです

  1. 処理の流れを実装する
  2. Lambda-RICとblenderが入ったdockerイメージをビルドする
  3. dockerイメージをAWSのdockerリポジトリ(ECR)にプッシュする
  4. プッシュしたイメージを使ってLambdaを作成する
  5. テストデータをS3バケットにアップロードしてテストする

準備

blenderのダウンロード

wget https://mirror.freedif.org/blender/release/Blender4.2/blender-4.2.1-linux-x64.tar.xz

lambdaハンドラで使用されるpythonコードを作成

  • srcLambda/app.py
import os
from uuid import uuid4
import shutil
from typing import Any

import boto3
from invoke_blender_rendering import invoke_blender_rendering


def download(bucket_name: str, key: str, file_path: str):
    s3 = boto3.client("s3")
    dst_dir = os.path.dirname(file_path)
    if not os.path.exists(dst_dir):
        os.makedirs(dst_dir)
    s3.download_file(bucket_name, key, file_path)


def upload(bucket_name: str, key: str, file_path: str):
    s3 = boto3.client("s3")
    s3.upload_file(file_path, bucket_name, key)


def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, str]:
    # イベントの内容を取得
    bucket_name = event.get('bucket_name')
    assert isinstance(bucket_name, str)

    obj_key = event.get('obj_key')
    assert isinstance(obj_key, str)

    mtl_key = event.get('mtl_key')
    assert isinstance(mtl_key, str)

    texture_key = event.get('texture_key')
    assert isinstance(texture_key, str)

    upload_dst_key = event.get('upload_dst_key')
    assert isinstance(upload_dst_key, str)

    img_size = event.get('img_size', 512)
    assert isinstance(img_size, int) or isinstance(img_size, float)
    img_size: int = int(img_size)

    # 作業ディレクトリを作成
    tmp_path = f'/tmp/{uuid4()}'
    src_path = f'{tmp_path}/src'
    dst_path = f'{tmp_path}/dst'

    os.makedirs(tmp_path)
    os.makedirs(src_path)
    os.makedirs(dst_path)

    # エラーが起きた場合もtmpディレクトリを削除したいのでtry-finallyで囲む
    try:
        local_obj_path = f'{src_path}/{os.path.basename(obj_key)}'
        local_mtl_path = f'{src_path}/{os.path.basename(mtl_key)}'
        local_texture_path = f'{src_path}/{os.path.basename(texture_key)}'
        local_dst_path = f'{dst_path}/{os.path.basename(upload_dst_key)}'

        # データをダウンロード
        download(bucket_name, obj_key, local_obj_path)
        download(bucket_name, mtl_key, local_mtl_path)
        download(bucket_name, texture_key, local_texture_path)

        # メインの処理
        invoke_blender_rendering(
            local_obj_path,
            local_dst_path,
            img_size
        )

        # アップロードとローカルファイルの削除
        upload(bucket_name, upload_dst_key, local_dst_path)

    finally:
        # 保存したファイルをそのままにするとリクエストが重なった時にリクエストが失敗するので削除する
        shutil.rmtree(tmp_path)

    return {
        'bucket_name': bucket_name,
        'result_image_key': upload_dst_key,
    }
  • srcLambda/invoke_blender_rendering.py
import subprocess
import os


def invoke_blender_rendering(
        obj_path: str,
        dst_path: str,
        img_size: int
):
    cmd = get_cmd(
        obj_path,
        dst_path,
        img_size
    )

    result = subprocess.run(cmd)
    if result.returncode != 0:
        print(result.stderr)
        raise RuntimeError(f"Failed to render: {result.returncode}")
    else:
        print("Rendered successfully")

def get_cmd(
    obj_path: str,
    dst_path: str,
    img_size: int
) -> list[str]:
    blender_path = os.getenv("BLENDER_PATH", "/usr/local/blender/blender")
    assert os.path.exists(blender_path), f"BLENDER_PATH does not exist: {blender_path}"

    script_path = os.getenv("BLENDER_SCRIPT_PATH", "/app/srcBlender/render.py")
    assert os.path.exists(script_path), f"BLENDER_SCRIPT_PATH does not exist: {script_path}"

    template_blend_path = os.getenv("BLENDER_TEMPLATE_PATH", "/app/srcBlender/template.blend")
    assert os.path.exists(template_blend_path), f"BLENDER_TEMPLATE_PATH does not exist: {template_blend_path}"

    return [
        blender_path,
        "--background",
        "--python", script_path,
        "--",
        "--obj_path", obj_path,
        "--dst_path", dst_path,
        "--template_blend_path", template_blend_path,
        "--outputSize", f"{img_size}"
    ]

blenderで使用するスクリプトの作成

シーン構築を全てスクリプトで行うのはきついので、blendファイルを読み込むようにしています。

  • srcBlender/render.py
import os
import bpy  # type: ignore
from mathutils import Vector  # type: ignore
from typing import NamedTuple


class RenderCfg(NamedTuple):
    obj_path: str
    dst_path: str
    template_blend_path: str
    outputSize: int


def import_geo_file(file_path: str):  # type: ignore
    ext = os.path.splitext(file_path)[1]
    assert ext in [".obj", ".OBJ"], f"Unsupported file type: {ext}"
    bpy.ops.wm.obj_import(filepath=file_path)  # type: ignore


def render_main(cfg: RenderCfg):
    bpy.ops.wm.open_mainfile(filepath=cfg.template_blend_path)  # type: ignore

    bpy.context.scene.render.resolution_x = cfg.outputSize  # type: ignore
    bpy.context.scene.render.resolution_y = cfg.outputSize  # type: ignore

    # シーンのカメラを設定
    camera = bpy.data.objects["Camera"]  # type: ignore
    bpy.context.scene.camera = camera  # type: ignore
    bpy.context.scene.frame_start = 1  # type: ignore
    bpy.context.scene.frame_end = 1  # type: ignore

    # レンダリングサンプリングに設定
    bpy.data.scenes["Scene"].eevee.taa_render_samples = 1  # type: ignore

    # レンダリング出力先を設定
    bpy.context.scene.render.filepath = cfg.dst_path  # type: ignore
    ext = os.path.splitext(cfg.dst_path)[1]

    # 出力をマルチレイヤーEXRに設定
    if ext.lower() == ".exr":
        bpy.context.scene.render.image_settings.file_format = 'OPEN_EXR_MULTILAYER'  # type: ignore
    elif ext.lower() == ".png":
        bpy.context.scene.render.image_settings.file_format = 'PNG'  # type: ignore
    elif ext.lower() == ".jpg":
        bpy.context.scene.render.image_settings.file_format = 'JPEG'  # type: ignore
    else:
        raise ValueError(f"Unsupported file extension: {ext}")

    import_geo_file(cfg.obj_path)

    # アニメーションをレンダリング
    bpy.ops.render.render(animation=False, write_still=True)  # type: ignore


if __name__ == '__main__':
    import sys
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument('--obj_path', type=str, required=True)
    parser.add_argument('--dst_path', type=str, required=True)
    parser.add_argument('--template_blend_path', type=str, required=True)
    parser.add_argument('--outputSize', type=int, required=True)
    args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:])

    cfg = RenderCfg(
        obj_path=args.obj_path,
        dst_path=args.dst_path,
        template_blend_path=args.template_blend_path,
        outputSize=args.outputSize
    )

    render_main(cfg)

docker fileの作成

FROM ubuntu:22.04 AS blender-installer
RUN apt-get update
RUN apt-get install -y xz-utils

RUN mkdir /tmp/blender
WORKDIR /tmp/blender

# blenderのバイナリはいい感じに持ってきておいてください。
COPY ./cache/blender-4.2.1-linux-x64.tar.xz /tmp/blender/
RUN tar -xvf "./blender-4.2.1-linux-x64.tar.xz"


FROM ubuntu:22.04 as runtime

RUN ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
RUN apt-get update && apt-get upgrade -y

ENV LAMBDA_TASK_ROOT="/app"
ENV FUNCTION_DIR="${LAMBDA_TASK_ROOT}"
RUN mkdir -p ${LAMBDA_TASK_ROOT}
WORKDIR ${LAMBDA_TASK_ROOT}

ENV TZ=Asia/Tokyo

# blender依存関係のインストール
RUN apt-get install glibc-source -y
RUN apt-get install -y libsm6 libxext6
RUN apt-get install -y libx11-dev libxxf86vm-dev libxcursor-dev libxi-dev libxrandr-dev libxinerama-dev libegl-dev
RUN apt-get install -y libwayland-dev wayland-protocols libxkbcommon-dev libdbus-1-dev linux-libc-dev

# blender本体のインストール
ENV BLENDER_DIR="/usr/local/blender"
ENV BLENDER_PATH="${BLENDER_DIR}/blender"
ENV BLENDER_PYTHON_PATH="${BLENDER_DIR}/4.2/python/bin/python3.11"
COPY --from=blender-installer /tmp/blender/blender-4.2.1-linux-x64 ${BLENDER_DIR}
ENV PATH="${BLENDER_PATH}:${PATH}"

# pythonのセットアップ
RUN apt-get install -y python3.11
RUN apt-get install -y --fix-missing python3-pip
RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.11 1
RUN python -m pip install --upgrade pip setuptools wheel
RUN pip install --target ${LAMBDA_TASK_ROOT} awslambdaric
RUN python -m pip install boto3
RUN python -m pip install aws-lambda-powertools

# 実行ファイルのコピー
COPY srcPython/utils ${LAMBDA_TASK_ROOT}/utils
COPY srcPython/app.py ${LAMBDA_TASK_ROOT}/app.py
COPY srcBlender/render.py ${LAMBDA_TASK_ROOT}/render.py
COPY srcBlender/template.blend ${LAMBDA_TASK_ROOT}/template.blend
ENV BLENDER_TEMPLATE_PATH="${LAMBDA_TASK_ROOT}/template.blend"
ENV BLENDER_SCRIPT_PATH="${LAMBDA_TASK_ROOT}/render.py"

# 実行コマンドの設定
COPY srcPython/entry_script.sh ${LAMBDA_TASK_ROOT}/entry_script.sh
RUN chmod +x ${LAMBDA_TASK_ROOT}/entry_script.sh
ENTRYPOINT [ "./entry_script.sh" ]
CMD [ "app.lambda_handler" ]

entry_script.shも必要になってきます。サンプルリポジトリを参照してください。

デプロイ

イメージのビルドとプッシュ

# もしリポジトリがない場合は作成してください
REPO_NAME=blender-on-lambda-test
aws ecr describe-repositories --repository-names ${REPO_NAME} || aws ecr create-repository --repository-name ${REPO_NAME}
REGION=$(aws configure get region)
REPO_NAME=blender-on-lambda-test
ACCOUNTID=$(aws sts get-caller-identity --query Account --output text)
ECR_URL=${ACCOUNTID}.dkr.ecr.${REGION}.amazonaws.com
TAG="test"

docker build -t ${REPO_NAME} . || exit 1

# イメージプッシュ
docker tag ${REPO_NAME} "${ECR_URL}/${REPO_NAME}:${TAG}"
aws ecr get-login-password --region "${REGION}" | docker login --username AWS --password-stdin "${ECR_URL}"
docker push "${ECR_URL}/${REPO_NAME}:${TAG}"

Lambda関数の作成

  • レンダリングにそこそこスペックが必要なのでリソースは盛ってます。
  • lambdaのCPUリソースはメモリによって決まるためメモリは最大値に設定しています。
aws lambda create-function \
    --function-name blender-on-lambda-test \
    --package-type Image \
    --code ImageUri=${ECR_URL}/${REPO_NAME}:${TAG} \
    --role <your_role_arn> \
    --timeout 180 \
    --memory-size 10240 \
    --region ${REGION}

サンプルデータのアップロード

サンプルデータはこちらよりお借りした物を編集して使用しています。(CC0 Public Domain)

エリマキシギという鴫のモデルです。

AWS CLIでS3バケットにアップロードします。

aws s3 cp ./sampleData/ s3://<your_bucket_name>/ --recursive

Lambda関数の実行

aws lambda invoke \
    --function-name blender-on-lambda-test \
    --payload file://testEvent.json \
    --cli-binary-format raw-in-base64-out \
    output.json

結果のダウンロード

aws s3 cp s3://<your_bucket_name>/test_result.png .

できました。

雑にライティングしているのでだいぶ暗めですね...
時間は1フレーム512x512のレンダリングで80秒ほどかかっています。

まとめ

今回はblenderをLambda上で動かす方法を紹介しました。
コールドスタート時間は通常よりもかかりますし、レンダリング時間もかかり、Lambdaの中心となるユースケースからはだいぶ外れてしまう気がしますが、
サーバーレスの強みを活かしてスケールアウトすることができるので、大量のレンダリングを行う際には有用かと思います。

応募待ってます!

Berryでは他にもサーバーレスアーキテクチャを悪用活用した3Dデータ処理を数多く実装しています。様々な技術に気軽にトライできる環境は大変刺激的です。

エンジニア大募集中です。一緒に、このアーキテクチャを更に使い倒したい、発展させたい方、大歓迎です。

医療業界での経験や3Dの知見は問いません。Berryの考え方や製品に少しでも興味が持てた方はお気軽に応募下さい!

https://www.wantedly.com/projects/1594426

株式会社Berry
株式会社Berry

Discussion