ASP.NET Core と Azure WebJobs で業務ロジックを共通化したい(.NET Core なアセンブリをWebJobsで走らせる)

Web アプリ作っていて、バッチ処理したいときの実装って、ホントいろんな方法あります。cron なり Windows の Tasks で何かを定期的に走らせる由緒正しきミニマム実装もあれば、JP1 / Systemwalker / Tivoli / OpenView なエンプラどまんなかもありますし、今なら Serverless な FaaS でイベント・ドリブン的にやる手もあるでしょう。Outlook に予定を入れておいて、ヒューマン・イベント・ドリブンでやる事もありますが、それは言わない約束で。

今回は、既に Azure App Service Web Apps にデプロイする ASP.NET Core アプリがある場合が対象なのですが、この構成の場合でも、考慮する事項は多々あります。

本記事は、 http://qiita.com/advent-calendar/2016/asp-net の14日目です。

設定やサンプルコードは中頃から。

TL;DR バッチ処理の運用上のあれこれ

よくある失敗は、バッチ処理が失敗してる事に気がつかない、という初級の初級なのでありますが、そのレベルはちょっとおいておきます。頻繁にコードを変更する開発プロセスを運営してる場合いろいろ悩む事があります。

処理の中身が変わるので業務ロジックは出来る限りまとめたい

後で早々に辛くなるケースは、Webの実装コードとバッチの実装コードが物理的に別の部分で実装されていて、改修の度に双方改修する、二重管理です。この二重管理が功を奏するケースは(たまにありますが)希だと思います。

デプロイする際に手間をかけたくない

あえて書くこともないですが、面倒なのはいやですよね。.NET で構築された業務処理を NuGet にまとめて Internal に公開し、デプロイ後に現地でパッケージ復元して、という技も多分可能ではありますが、業務系の場合もうちどベタにやりたい事はやりたいものですし、パッケージ化して配置して準備までしつつも、業務側との依存性をマネジメントしながら動かすのは、ちょっと涙でます。

設計上の課題 タイムアウト、スレッド占有、もろもろ

業務ロジックを共有するためのもっとも楽ちんな方法は、業務ロジックを動かす部分を単一にすればいい訳で、時代は API 連携時代ですし、ってなノリで以下のような構成でも、まぁ動かすことは出来ます。

fig.0

赤字で書いたのが短所。良くあるわけですが、私は好きではないです。ウェブの処理は、同期に書いてるなら極力ショートショートに、非同期に書いていたとはいえ、だからといって長く処理させるのは、目的が違うのでぱっとしないです。

今回が対象としてる ASP.NET Core を Web App で動かす場合、外の URL の 80/443 に投げた場合、IIS から ASP.NET Core の Internal WebHost とのつなぎ部分で30秒でタイムアウトしてしまうので処理がどうなったのか大変微妙い状態になるので、それを伸ばすって話になります。うーむ。

スケジューラは通常、実行可能ファイルを叩けるわけですから、以下も成立します。

fig.1

オンプレでJP1使ってたりする場合に、よくあるケース。バッチ処理実行サーバーにキックするEXEを配置して、その中から依存アセンブリやCOMを呼び出してループ処理するようなケースですね。

利点としては、稼働してる環境そのものの面倒は見やすく、負荷の影響調整の柔軟性が高いところです。一方で、規模がちょっと増えただけでも、物理的にどこにアセンブリが配置されていて、どこで動いてて、どのバージョンに依存して然るべきか、というのを把握しないとダメで、デプロイとかとっても面倒になります。

今回の記事は、後者の設計で動かすんですが、面倒なモジュール管理を、Azure にデプロイする処理の工程に入れてしまって、赤字のダメポイントを一気に解消しましょう、そういう感じです。

Azure Web Apps の WebJobs での考慮

Azure Web Apps の WebJobs ですが、これですね。

国内で有名な MVP な方々の記事もあるので、検索し参照されたし。

以前は、別の Azure のサービスになっている スケジューラー サービスが自動的に作成されて、定期的に何かを叩くような事をやってくれてました。また SDK を入れて専用のアプリを作るみたいな事もやってました。

今は選択肢が増えていて、もっと楽ちんです。実行可能形式ファイルと CRON 式でスケジュールを記載したファイルを、指定したフォルダに配置しておけば、Azure Web Apps が指定した感じで叩いてくれます。

サービスプランを Standard にしてプロセスを Always On 状態(常時接続)にしておけば、十分なバッチ処理基盤として使えますので、とてもいい感じです。

で、Azure WebJobs の先のリンクだと、ポータルから登録出来るぜ!的な事が書かれてるのですが、これだと楽にならんのですよね。実行アセンブリ一式をZIP化して配置するのが年に1回なら許容出来ますが、月1回ならもう嫌になります。何度手間なんだ!って感じです。そこで、

fig.2

/App_Data/jobs/** に指定した方法でファイルを配置すれば、Azure 側で自動的にファイルの中をみてくれて実行可能ファイルを探しだし、指定した感じで cron の指定を読み込んで勝手に走ってくれて、しかもファイルの更新を見てるので設定が変わった事を反映してくれるっていうとっても便利な機能」を、最大限に活用したと思います。

この便利な機能を使う際のもろもろ考慮

最初、どうせ同じバージョンのアセンブリを見るなら、以下のような設計で動くんでないの?と思って、試行錯誤したのですが、自分で言うなですが筋悪でした。

fig.3

ジョブの初回稼働時に ASP.NET Core が展開してるファイル一式を拾ってくるとか、逆に自分を押し込むとかいろいろ考えるのですが、依存アセンブリの概念がぐちゃぐちゃになって、依存アセンブリが正しく読めないです。(←やってみて死んだ)

また、WebJobs が実行時に参照するモジュールは、内部で Temp 領域( D:\loca\Temp\jobs\{triggered|continuous}\{jobname}\{random} みたいな場所) に複製してからスケジューラーが走らせている都合と、WindowsScriptHostが実行可能ファイルを拡張子のタイプにあわせてぐにっと走らせてる関係からか、カレントディレクトリを調整して無理に走らせる、ってのも、うまく行きませんでした。

Temp 領域にファイル一式をコピーして走らせるってのは真っ当な話ですな。ジョブが稼動中にデプロイされるケースが必ず起こりえるわけで。よく考えられてます。

WebJobs を使うなら、必要なものは一式をまとめて指定フォルダに配置しろ、ってのが教訓です。王道無し。

でも、だからといって、配置するためのアセンブリを、ソースコード・リポジトリとともに Git 管理するんすかね、というと、いやぁそれはソースコード管理用と一緒にってのは流石にちょっと、って感じです。

fig.4

だってねぇ。ソースコードと外部アセンブリをどのレベルで参照して管理してるかにもよるのですが、例えば、図の上の業務ロジックは、ソースツリー上にソースコードとして存在してるのに、WebJobsの配置用の業務ロジックアセンブリは、コミットされていて、ビルドの度に更新が入るとか、いやですよね。管理境界がばらばらで、事故りそうです。

今回の構成(やっと本題)

で、模索の結果、以下の構造で行けるんでない?という感じになりました。

  • ASP.NET Core プロジェクトのデプロイ作業を全ての更新作業の単一の起点操作とする。
  • 当該プロジェクトのデプロイ時(Publish操作時)に、バッチ処理を行う .NET Core な実行モジュール群を、必要な場所に dotnet publish する。
  • 実行間隔を指定する設定ファイルだけ、ASP.NET Core のプロジェクト側で管理する

こんなノリ。イメージとしては以下。

fig.5

とここまで模索した時点で、以下の URL を見つけるわけです。

はっはっは。まぁそのあとも苦労したんすけども。実際のコードはこちら。

実践を見る前にに知ってると得する事

以下あたりを予め知っておくと、先の手順の意図の理解に、足しになるかもしれません。

  • dotnet publish コマンドで、動くもの一式を指定フォルダに発行してくれます。これ重要で、依存関係すべて調製して動く一式作ってくれますので、これでバッチ側を準備します。(ASP.NET Core側でもツールがやってくれてるわけですが)
  • exe になってない .NET Core assembly は、dotnet {dllへのパス} で実行する事が(Main()を叩く事が)できます。バッチ的にキックするので実行可能ファイルファイルにしなきゃ、と先に考えてしまうと、先の記事と http://www.hanselman.com/blog/SelfcontainedNETCoreApplications.aspx この記事を見て、ぐにぐにと頑張る事になります。ところがこれ、構成次第では動かないケースがありまして(踏んだ)、一言で書けば、EF 使ってる依存アセンブリが sni.dll を見つけ損ねて動きませんでした。コードにあるように、NETCore.App の type は platform のままで実行環境依存とし、 また runtimes は記載せず、exe を作らずに publish させ、WebJob には、別の実行可能ファイル経由で dotnet {dll} を叩いてもらったほうが、スムースに事は運びます。
  • WebJobs は、ジョブの単位となるフォルダにある実行可能ファイルを探して自動で登録し、叩いてくれます。先のリンクにある run.cmd は、同じ役割を果たすのであれば、 run.bat でも go.ps1 でも romance.exe でもいいのですが、複数の実行可能形式が含まれてると、なにやら動きよろしくないですし、動いたとしても管理上もややこしいので、避けた方が無難でしょう。ログ見る限り、WSH Host が程よく蹴ってるようです。
  • リンク先やサンプルコードのようにバッチファイルでキックのきっかけを開始する場合は、ファイルの文字コードに注意かも。再現テストしてないのですが、VSから作るテキストファイルを元に run.cmd を作った場合、UTF-8 BOM付き になったのですが、この場合に記事にある通りに @echo off って書いたら、後続のコマンドパスを誤認して動きませんでした。UTF-8 BOM無し(英数字だけならSHIFT_JIS)でいきましょう。これだけで結構なやみました。
  • WebJobs は、ファイルをデプロイした直後直ちに実行してるモジュールが入れ替わるわけではありません。ファイルの更新を見てるので、けっこう素早く入れ替わりはしますが、一分半程は変わらない時もあります。なので、デプロイが完了して ASP.NET Core 側の画面が開いて更新されたとしても、そのあとの数回は、古いファイル群で実行される可能性があります。別のテンポラリーフォルダにファイルがコピーされてスケジューリングされてから、新しいので走ります。それが嫌なら一旦は止めましょう。

設定の実際

構成は以下の通りです。

ASP.NET Core project  - refers -> SharedClassLibrary project
MyWebJobs .NET Core project  - refers -> SharedClassLibrary project

ASP.NET Core project:
 NETCore.App 1.1 “type”: “platform” / netcoreapp1.1

MyWebJobs project:
 NETCore.App 1.1 “type”: “platform” / netcoreapp1.1

SharedClassLibray project:
 netstandard1.6

ここからは画面キャプチャのオンパレード。

ASP.NET Core はこんなノリ。GitHub のコードは、 .NET Core 1.1 を対象にしてあります。ポイントは、フォルダの名前の付け方。

ASP.NET Core だと使わない /App_Data/ フォルダを自作してあげてくださいな。で、cron による時間指定をするジョブは、以下の感じでフォルダを作成して、中にスケジュールジョブを配置してあげてください。

/App_Data/jobs/triggered/{適当なジョブ名}/

run.cmd は、叩ければ前の説明通りなんでもいいです。GitHubに上がってるのは、

dotnet --version
dotnet MyWebJobs.dll

こんなんです。実行の度にバージョン確認してますが、ログでバージョンをみたいだけなので、本来不要です。話が前後するのですが、これで実行すると、WebJobs の実行ログに以下のように出ます。このログは、Azureの管理ポータルで見る事が出来ます。

https://github.com/dotnet/cli/releases/tag/v1.0.0-preview3-004056 なんすね

dotnet MyWebJobs.dll が後述するバッチ処理用のアセンブリで、その結果が表示されてます。

このファイルの指定で、先のリンクにある先頭の @echo off を足したり、exe ビルドしたものを投げ込んで exe をキックしたりしても、私のテスト中は動きませんでした。このようにログは出るので、なんとか追っていく感じになるのですが、ここで sni.dll を対象とした System.IO.FileNotFoundException とか出ると、諦めたくなりますな。

settings.job のほうはシンプルです。

simple —

このあたりは ここ の記事参考に。まんまです。

継続的に走らせる場合は、triggered の代わりに continuous フォルダを作成して配置するのですが、ちょっとだけ指定出来る事が増えたりするので、別途検索して調べてみてください。

肝心要の project.json です。

他の記事にもある通りですが、要点は二カ所。

"publishOptions": {
"include": [
"wwwroot",
"**/*.cshtml",
"appsettings.json",
"web.config",
"App_Data/jobs/**"
]
},

これはいいとして、以下。バッチファイル用のアセンブリをビルドして、実行可能な状態にするために、パブリッシュ用に Temp 領域に配置されたプロジェクトのフォルダ内の /App_Data/jobs/triggered の下に publish します。

"scripts": {
"prepublish": [ "bower install", "dotnet bundle" ],
"postpublish": [
"dotnet publish ../MyWebJobs/ -o %publish:OutputPath%/App_Data/jobs/triggered/MyWebJobs/",
"dotnet publish-iis --publish-folder %publish:OutputPath% --framework %publish:FullTargetFramework%"
]
}

ここの表記のフォーマットは、以下で参照出来ます。

Visual Studio のログはこんな感じ。 dotnet publish のなかで、依存 Assembly のビルド状態を確認して(ログでは、ASP.NET Core 本体の依存先のDLLなのでビルドが終わっていて Skip してます)、依存するファイル群も含めてプッシュされるフォルダの /App_Data/jobs/triggered/{指定したフォルダ} に出力されているのがわかります。なので、これで同じコードから生成された同一ビルドの参照先のアセンブリがコピーされてる事がわかります。


Processing wwwroot/js/site.min.js
Publishing MyWebJobs for .NETCoreApp,Version=v1.1
Project SharedClassLibrary (.NETStandard,Version=v1.6) was previously compiled. Skipping compilation.
Project MyWebJobs (.NETCoreApp,Version=v1.1) was previously compiled. Skipping compilation.
publish: Published to C:\Users\ax\AppData\Local\Temp\PublishTemp\AspNetCore66/App_Data/jobs/triggered/MyWebJobs/
Published 1/1 projects successfully
Configuring the following project for use with IIS: 'C:\Users\ax\AppData\Local\Temp\PublishTemp\AspNetCore66'
Updating web.config at 'C:\Users\ax\AppData\Local\Temp\PublishTemp\AspNetCore66\web.config'
Configuring project completed successfully
publish: Published to C:\Users\ax\AppData\Local\Temp\PublishTemp\AspNetCore66

これで、必要なファイルがセットになった状態で、稼働環境の Web Apps にデプロイされていきます。

MyWebJobs も普通の .NET Core App です。SharedClassLibray を呼び出してるだけです。本来はここで、業務処理アセンブリを呼び出すイメージで、たとえば ASP.NET Core のモデルに実装された業務処理を呼び出すようなコードを書くわけです。DI させるも良し、上手く投げ込めるように調整してあげてください。

project.json はこんなノリ。DLLとして生成し、 dotnet コマンドで実行するので、これでかまいません。 net461 なアセンブリ他呼ぶ場合の “portable-net45+win8” は必要に応じて。(デプロイする以前に設定終わってるはず)

{
"version": "1.0.0-*",
"buildOptions": {
"emitEntryPoint": true
},
"dependencies": {
"Microsoft.NETCore.App": {
"type": "platform",
"version": "1.1.0"
},
"SharedClassLibrary": "1.0.0-*"
},
"frameworks": {
"netcoreapp1.1": {
"imports": "dnxcore50"
}
}
}

SharedClassLibray は至って普通の .NET Core な Class Library なので特に書かないですが、良くある開発環境なら、ここで Azure Storage を操作するような NuGet パッケージを見てたり、EF Core 呼び出したりしてるはずで、なんやかんやで重たくなる部分が。これです。

デプロイすると

デプロイした先の Web Apps の以下、

Web ジョブを開くと、以下が見えて、自動的にジョブが登録されてるのがわかります。便利ですな。

こんなノリで実行状態が見えるので、詳細をクリックすれば、先のログの画面になります。以下ですね。

先の画面とは違うデプロイのログなので、 Temp フォルダ名が変わってる事に気がつきますでしょうか。

まとめ

今回もまとめる事はあまりないのですが、いまいちいい感じのサンプルがなく、かつ .NET Core でいろいろ揺れてて、大変に苦労したりした話だったので、共有しときます。ハイ。