.NET5 コンテナ + OpenCvSharpで作る画像処理Lambda

公開:2020/12/13
更新:2020/12/14
16 min読了の目安(約14600字TECH技術記事

C# その2 Advent Calendar 2020 の14日目の記事です。

re:Invent 2020にて発表があり、AWS Lambdaのパッケージフォーマットとしてコンテナイメージが新たにサポートされるようになりました。

この記事ではさっそくそれを確認すべく、Dockerコンテナを使ってOpenCV[1]が動作するLambdaを作ることにします。従来はやや手間でした。

C#のアドベントカレンダーですから、開発にはこれまた出たばかりの.NET 5を使ってみましょう。AWSが公式に.NET 5に対応するベースイメージを提供しています。(仕事が速い)

なお筆者はC#, OpenCV, Docker, AWSいずれも疎いので、本記事はあまり練られた話ではないことをお断りしておきます。

成果物

本記事で説明しているコードはこちらにあります。

以下長いですが、先に結論を述べますとこちらです。

  • AWS Toolkit for Visual Studioのテンプレに従いましょう
  • Dockerfileのベースイメージは shimat/al2-dotnet5-opencv4.5.0:20201212

環境

以下の前提で話を進めます。これらの導入については割愛します。

  • Windows 10 20H2
    • WSL2が有効
  • Docker Desktop 3.0.0 (50684)
    • WSL2 basedにしておく
  • Visual Studio 2019 16.8.3
  • 適当なAWS環境
    • %USERPROFILE%\.aws\credentials にてAWS認証情報が設定済み

C#でOpenCV: OpenCvSharp

C#でOpenCVを触るには?というところで手前味噌で恐縮ですが、私が12年ほど開発している OpenCvSharp を使います。

C++で書くときのOpenCVとそれほど差がなく書けるようにしています。コードの一例として、以下はCascadeClassifierによる顔検出処理を挙げてみました。

// C++ (ネイティブOpenCV)
cv::Mat src = cv::imread("foo.jpg");
cv::Mat gray;

cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY);
cv::equalizeHist(gray, gray);

cv::CascadeClassifier cascade("haarcascade_frontalface_alt.xml");
std::vector<cv::Rect> faceRects;
cascade.detectMultiScale(gray, faceRects);
// C#
using OpenCvSharp;

using var src = Cv2.ImRead("foo.jpg");
using var gray = new Mat();

Cv2.CvtColor(src, gray, ColorConversionCodes.BGR2GRAY);
Cv2.EqualizeHist(gray, gray);

using var cascade = new CascadeClassifier("haarcascade_frontalface_alt.xml");
var faceRects = cascade.DetectMultiScale(gray);

Windows向けにはNuGetを入れるだけで難しいこと抜きでOpenCVに触れるようにしていますが、今回はコンテナとしてOpenCVの動作環境を作ってあげることになります。後述します。

開発手順

最低限必要なのは以下2点です。説明していきます。

  • Function.cs でOpenCvSharpを使うように書き換え
  • OpenCvSharpが使えるようにDockerfileを書き換え

プロジェクト作成

Visual Studioを開き、AWS Lambda Project (.NET Core - C#) で新規プロジェクトを作成します。もちろんテスト付きにしても構いません。
プロジェクトのテンプレート選択

続いてどんなLambdaにするかの青写真を選択するのですが、AWSさんの仕事が速いことに、もう .NET 5 (Container Image) というズバリのテンプレートがあります。
Lambdaのテンプレート選択

OpenCvSharpのNuGetを追加

プロジェクトが作られたら、OpenCvSharp4 のNuGet参照をプロジェクトに追加します。本記事は 4.5.0.20201013 のバージョンを使用する前提です。

NuGet Package ExplorerでOpenCvSharp4を検索

Function.cs の実装

Function.csFunctionHandlerというメソッドが雛形としてもう作られていて、ここがLambdaでのエントリポイントにあたります。このへんはコンテナイメージ対応になる以前と変わりません。

雛形では文字列のToUpper ToLowerをして、C#9らしくRecordで返す実装になっています。これをOpenCvSharpを使った適当な実装に変えてみましょう。

リクエストとして画像データをBase64形式で受け取り、それをOpenCVのMatとしてデコードして、大津の二値化を行い、結果のMatをBase64文字列に戻して返します。

// Function.cs
using System;
using Amazon.Lambda.Core;
using OpenCvSharp;

// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]

namespace AWSLambda1
{
    public class Function
    {
        /// <summary>
        /// A simple function that takes a image data and returns a binarized image data.
        /// </summary>
        /// <param name="imageBase64"></param>
        /// <param name="context"></param>
        /// <returns></returns>
        public string FunctionHandler(string imageBase64, ILambdaContext context)
        {
            var imageBytes = Convert.FromBase64String(imageBase64);

            using var src = Mat.FromImageData(imageBytes, ImreadModes.Grayscale);
            using var dst = Threshold(src);

            var dstImageBytes = dst.ImEncode(".png");
            var dstImageBase64 = Convert.ToBase64String(dstImageBytes);
            return dstImageBase64;
        }

        private static Mat Threshold(Mat src)
        {
            Mat dst = new (); // 無駄にC# 9を使う
            Cv2.Threshold(src, dst, 0, 255, ThresholdTypes.Otsu);
            return dst;
        }
    }
}

Dockerfileの準備

OpenCvSharpの構成

コンテナをどう作るか考えるにあたり、OpenCvSharpの構成を確認します。OpenCvSharpはC#実装部分とC++実装部分に分かれます。C++部分は環境ごとにビルドが必要ということです。

  • OpenCvSharp.dll [C#]
  • OpenCvSharpExtern.dll(.so/.dylib) [C++]

OpenCVはC++で書かれており、C#から簡単には呼び出せません[2]。そこで、C++の関数をC形式でエクスポートするようなファクトリ関数を作っています。それがOpenCvSharpExtern.dllにあたります。

extern "C" __declspec(dllexport) cv::Mat* __cdecl core_Mat_new(
  int rows, int cols, int type)
{
    return new cv::Mat(rows, cols, type); 
}

extern "C"していればC#から容易に[DllImport]できます。

[DllImport("OpenCvSharpExtern", CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr core_Mat_new(int rows, int cols, int type);

以上から、Dockerコンテナでは以下を済ませた環境を作ります。

  • OpenCVをビルド
  • ビルドしたOpenCVをリンクしてlibOpenCvSharpExtern.soをビルドし、適当な場所 (LD_LIBRARY_PATHが通った場所など)に置く [3]

これで、C#から自在にDllImportを通してOpenCVを呼び出せることになります。詳細はリポジトリをご覧ください。

.NET 5 対応のコンテナ

上記を踏まえDockerfileの編集に取り掛かりますが、DockerfileもAWS Toolkitが雛形を用意してくれていまして、初期状態で以下のようになっていると思います。Pure C#なライブラリのみを使っている限りはほぼ変える必要は無いでしょう。

FROM public.ecr.aws/lambda/dotnet:5.0

WORKDIR /var/task

# This COPY command copies the .NET Lambda project's build artifacts from the host machine into the image. 
# The source of the COPY should match where the .NET Lambda project publishes its build artifacts. If the Lambda function is being built 
# with the AWS .NET Lambda Tooling, the `--docker-host-build-output-dir` switch controls where the .NET Lambda project
# will be built. The .NET Lambda project templates default to having `--docker-host-build-output-dir`
# set in the aws-lambda-tools-defaults.json file to "bin/Release/net5.0/linux-x64/publish".
#
# Alternatively Docker multi-stage build could be used to build the .NET Lambda project inside the image.
# For more information on this approach checkout the project's README.md file.
COPY "bin/Release/net5.0/linux-x64/publish"  .

今回は、ここにOpenCV(Sharp)の環境構築処理を追加することになります。...が、ここまで説明しておきながら手順は簡単で、public.ecr.aws/lambda/dotnet:5.0をベースにして私の方でイメージを作っておいたので、FROMのところを変えるだけです。

FROM shimat/al2-dotnet5-opencv4.5.0:20201212  # ここだけ変更

WORKDIR /var/task

# This COPY command copies the .NET Lambda project's build artifacts from the host machine into the image. 
# The source of the COPY should match where the .NET Lambda project publishes its build artifacts. If the Lambda function is being built 
# with the AWS .NET Lambda Tooling, the `--docker-host-build-output-dir` switch controls where the .NET Lambda project
# will be built. The .NET Lambda project templates default to having `--docker-host-build-output-dir`
# set in the aws-lambda-tools-defaults.json file to "bin/Release/net5.0/linux-x64/publish".
#
# Alternatively Docker multi-stage build could be used to build the .NET Lambda project inside the image.
# For more information on this approach checkout the project's README.md file.
COPY "bin/Release/net5.0/linux-x64/publish"  .

ベースイメージをどう作ったかにつきましては、以下をご参照ください。(やや無駄が多い気はしています)

Lambdaへデプロイ

Publish to AWS Lambda... のメニューから、実際にAWSへデプロイします。
Publish to AWS Lambda...

最低限、Lambdaの関数名 (Function Name) と、ECRに登録するリポジトリ名 (Image Repo)を入力します。Package Typeは image になっていることを確認します。次のダイアログでは最低限IAM Roleを設定します。なおAWSの認証設定に失敗しているとこのへんで怒られると思いますので、確認してください。
Publish to AWS Lambda Dialog

デプロイにあたっては、ローカルでdockerが動作する必要があり、失敗すると以下のようなエラーとなります。AWS Toolkitにお任せしない場合の通常のフローでは、自分でdocker buildしてECRにpushすることになりますので、その際のエラーが出ていると理解できます。

... docker build: error during connect: Post http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.30/build?buildargs=%7B%7D&cachefrom=%5B%5D&cgroupparent=&cpuperiod=0&cpuquota=0&cpusetcpus=&cpusetmems=&cpushares=0&dockerfile=Dockerfile&labels=%7B%7D&memory=0&memswap=0&networkmode=default&rm=1&shmsize=0&t=opencvsharp_sample%3Alatest&target=&ulimits=null:
open //./pipe/docker_engine: The system cannot find the file specified. In the default daemon configuration on Windows, the docker client must be run elevated to connect. This error may also indicate that the docker daemon is not running.

デプロイが完了すると以下のような画面が出ます(非コンテナイメージのときと同じ)。ここでSample Inputにリクエストを設定して Invoke ボタンを押せば、さっそく試してみることができます。右側にレスポンスが出ます。

Function Test Dialog

Base64なので味気ないですが、手持ちの画像をBase64エンコード・デコードして試してみてください。レスポンスはPNGになっています。

大津の二値化処理結果

デプロイ後の注意点

従来のZipのアップロードによるデプロイと違い、デプロイが完了しても実際にLambda関数が実行可能になるまで少し時間がかかるようです。上記ダイアログでもしばらくはInvokeボタンが灰色で押せないと思います。慌てず待ちましょう。

これについては以下のクラメソ様の記事が参考になります。

OpenCVを入れたイメージは結構大きくなってしまうのでコールドスタートが気になるところですが、この記事によれば心配は無用そうですね。

ローカルでのデバッグ

結論を急いで手っ取り早くデプロイまで話を進めてしまいましたが、一般にはローカルでデバッグ実行もしたいところです。2つ方法があるようです(両者は別々ではなく関係しているのかもしれませんが今のところよくわかりません)。

Visual Studioのデバッガを簡単に使えるので、前者のほうが良い気はしています。

その1. AWS .NET Mock Lambda Test Tool

AWS Toolkitからプロジェクトを作成した場合は AWS .NET Mock Lambda Test Tool というのが標準で組み込まれていて、何も考えずデバッグ実行を始めるだけで以下のような画面がブラウザで開きます。自由にリクエストを投げられます。
AWS .NET Mock Lambda Test Tool

ただしこちらを使う上で2点注意があります。

注意点1. ローカル実行用のOpenCV環境

OpenCVが動くためのコンテナ作成について前述しましたが、ローカル(Windows想定)にも別途環境を作る必要があります。

Windows用にはNuGetパッケージを用意していまして、これを追加でインストールするだけです。

ただしこれはLambdaへデプロイするときには要らないわけです。現状あまりきれいな解が思いついていないのですが、デバッグ実行時のみWindowsバインディングを有効にするには以下のように.csprojファイルを書き換えます。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
    <AWSProjectType>Lambda</AWSProjectType>

    <!-- This property makes the build directory similar to a publish directory and helps the AWS .NET Lambda Mock Test Tool find project dependencies. -->
    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Amazon.Lambda.Core" Version="1.2.0" />
    <PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.1.0" />
    <PackageReference Include="OpenCvSharp4" Version="4.5.0.20201013" />
  </ItemGroup>
  <!-- 以下追記 -->
  <ItemGroup Condition="'$(Configuration)' == 'Debug'">
    <PackageReference Include="OpenCvSharp4.runtime.win" Version="4.5.0.20201013" />
  </ItemGroup>
</Project>

コンテナに入れてしまってもサイズが大きくなること以外特に悪さはしないので、分岐せず素直に入れてしまっても一応問題はありません。

AWS .NET Mock Lambda Test ToolのREADMEには、NuGet packages that use native dependencies are not supported.と但し書きがあるのですが、上記手順により一応動いています。

注意点2. 大きなPayloadを投げられない?

試したところ、データサイズが大きなPayloadを投げると失敗してしまいます。正確には試していませんが、8KB前後(8192文字?)に壁があるような気がします。コマンドライン引数の最大長に引っかかったりしているのかもしれませんが、あまり調べられておらずわかりません。この小ささでは画像のリクエストに使うには絶望的です。

AWS .NET Mock Lambda Test ToolのREADMEにあるように、Payloadをファイル名で与えればこの問題は回避できます。コマンドラインならこのようにします。

%USERPROFILE%\\.dotnet\\tools\\dotnet-lambda-test-tool-5.0.exe --path C:/Projects/AWSLambda1/AWSLambda1 --no-ui --payload C:/payload.json

Visual Studioのデバッグ実行で使うには、launchSettings.json で設定できます。 commandLineArgs を設定してあげます。(参考)

{
  "profiles": {
    "Mock Lambda Test Tool": {
      "commandName": "Executable",
      "commandLineArgs": "--no-ui --payload payload.json",
      "workingDirectory": ".\\bin\\$(Configuration)\\net5.0",
      "executablePath": "%USERPROFILE%\\.dotnet\\tools\\dotnet-lambda-test-tool-5.0.exe"
    }
  }
}

payload.json には試したい画像のBase64文字列を書きます。両端に""を忘れずに。

"/9j/4AAQSkZJRgABAQEAYABgAAD/4QBmRXhpZgAATU0AKgAAAAgA... <中略> ... +MUUtDo1P/9k="

余談ながらもう1つ注意として、Lambda自体のPayloadサイズ上限にも注意してください。今のところ6MBです。Base64にしていると膨らむので、実際はさらに厳しくなります。もしこの制限に引っかかるようなら、S3バケットに予めファイルを置くような設計が考えられます。

その2. AWS Lambda Runtime Interface Emulator

ローカルでデバッグするもう1つの方法として、AWS Lambda Runtime Interface Emulator (RIE) を使う方法があります。AWS .NET Mock Lambda Test Toolは.NET専用ですが、こちらは言語を問わず共通で使用できます。

本記事ではベースイメージはこちらにあるものを使いましたが、これら含めAWSで用意してくれているベースイメージにはRIEが含まれています。それ以外のベースイメージにRIEを後から追加することもできます (参考)。

以下のようにdocker runすればWebサーバとしてエミュレータが立ち上がります。1行目のdocker buildは、もし本記事のように先にAWS ToolkitでLambdaにデプロイをしている場合は、既に暗黙に実施済みですので飛ばして良いです。

PS> docker build -t opencvsharp_sample:latest .      
PS> docker run -p 9000:8000 opencvsharp_sample:latest
time="2020-12-13T06:23:58.329" level=info msg="exec '/var/lang/bin/dotnet' (cwd=/var/task, handler=/var/runtime/Amazon.Lambda.RuntimeSupport.dll)"

リクエストはPowerShellならこんな感じです。curlの例はRIEのリポジトリにあるのでそちらを参照ください。

PS> Invoke-WebRequest -Method Post -InFile "C:\payload.json" -ContentType "application/json" "http://localhost:9000/2015-03-31/functions/function/invocations"

まとめ

  • AWS Lambdaで .NET 5 コンテナイメージが使えます。OpenCVも動きました。全般的にかなり良さそうで、実戦投入したい感じです。
  • AWS Toolkit for Visual Studioがしっかりサポートしていて、簡単に開発・デプロイできます。
  • 全体的にAWSの対応が速くて驚きです。
  • デバッグ実行については多少課題があります(テストから実行したほうが楽かもしれません)。
脚注
  1. コンピュータビジョン向けライブラリ。本記事では詳細は述べません。https://opencv.org/ ↩︎

  2. 昔はCだったので楽だったのですが・・・ ↩︎

  3. DllImportでのライブラリ名指定では、先頭のlibや末尾の.soは補って読み込んでくれます。 ↩︎