Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

【R&D DevOps通信】研究開発部の.NET基盤をご紹介

技術本部 研究開発部 Architectグループの島です。本連載を最初2回*1*2だけ書いてとんずらしていましたが復活です。

今回は、研究開発部にて .NET 技術がどのように使われているか・何に取り組んできたかをご紹介したいと思います。

当社にはSansanなど.NET開発のメインストリームがある中で、研究開発部における.NET開発はそれらと交流がほぼなく、社内でも隠れた存在です。この記事は外部向けではあるものの、社内のほかの .NET エンジニアへの情報共有も兼ねています。

.NETシステムの経緯・現状

先にまとめ

  • 研究開発部は元々.NET製マイクロサービス開発からスタート。
  • 次第にPython開発が多数を占め、.NETは追加改修や保守が現在では主流。
  • Sansanの初期から価値を生み出し続けており、今後も維持していく予定。

創業以来の当社主力プロダクトのSansanは、初期から .NET(C#)が使われてきました。

研究開発部は、名刺のデータ化業務の効率化を使命として2013年に発足しました*3。Sansanを支える名刺入力システム*4からスタートしたことで、おのずと研究開発部が作るシステムも .NET 製となりました。私は研究開発部の第1号社員としてそこに従事してきました*5

基本的には研究開発部が作るシステムはマイクロサービスとし、名刺入力システム(GEES)本体からHTTPリクエストで同期的に受け付ける形を取りました。以下の図にある各ステップについて、それぞれ対応するサービスを1つまたは複数開発しています。

名刺のデータ化フロー図

また、Bill Oneの請求書データ化サービスについてもその一角は .NET で作られています。ご興味あればエンジニア向け会社紹介資料などをご参照ください。

2010年代前半はいわゆる純粋な(職人芸的な)画像処理技術のサービスを次々と開発しました。その後、名刺データ化にとどまらない展開を見せるのと同時に機械学習等を活用するサービスが増え、それにつれPython実装の割合が増えていきました。2023年現在では、新規案件の大半はPythonで開発しており、 .NET の新規開発例は少なくなりました。*6

正直に言えば、現状では研究開発部において .NET 利用は保守の意味合いが大きくなりました。ただし、名刺や請求書のデータ化を中心に今でも数多くの .NET サービスが稼働しており、機能追加や精度改善などは絶えず継続しています。

開発体制

研究開発部は2023年4月現在で約50人を擁する中で、C#で開発する(できる)のは5~6人前後、中でもC#を主力に開発しているのは2~3人ほどです。C#しか書けない者はおらず、皆Python等ほかの言語も使いこなしています。

保守の意味合いが強まっていると書きましたが、最新への追従に相当力を注いでいます。最新の .NET 7 や 6 で稼働するサービスが複数あります。社内のGitHubを時折調べる限り、.NET Core*7になって以後は研究開発部が社内最速で最新バージョンをサービスインしてきました*8。その旗振り役や実務の大半を私が一手に引き受け、業務の隙を見て2017年頃 (.NET Core 1.1の頃) からじわじわと進めています。.NET Core になることで、従来EC2 Windows Serverで稼働していたサービスをLambda, ECS等へ移行することが容易になり、コストダウン・運用容易化となります。それを大義名分に掲げています。

それ以外にも現場目線では、.NET Core 化によりテストの容易化、高速化、モダンな技術の利用促進等の効果があります。

ただシステム規模が絶えず拡大する中で刷新に全力を注げる体制ではなく、今ではレガシーと言える .NET Framework 製のサービスもまた複数が現役です。それら含めた保守・機能追加・マイグレーションをバランスを取りながら進めているのが日々の業務となっています。

利用するインフラ

Amazon EC2 / Google Compute Engine インスタンスでの運用が元来100%だったところを、年を追うごとにサーバレス等の他の選択肢(以下リストの太字)へと移行しています。

  • AWS
    • Lambda
    • ECS
    • EC2
  • Google Cloud
    • Cloud Run
    • Compute Engine

マイグレーションの手順として、まずはEC2/GCEのままミドルウェアのみ .NET Core へと改修し、それが安定したのちにインフラを変更するという2段階としています。

例えばECS FargateがWindowsコンテナをサポートするようになるなど、.NET Core化を果たさなくともインフラのみ刷新する選択肢は出てきています。ただ、現在でも何だかんだと追加改修の機会は多く、都度つらみが増していきますから、レガシーを引きずったまま生きながらえることを良しとしていません。改修頻度が低いサービス以外は実装を .NET Core 化し、Linuxのコンテナで動作できるよう取り組んでいます。

.NET Core 化については詳しく書くと長くなりそうで、機会があれば別の記事でご紹介しようと思います。

アプリケーションの役割

前述のように名刺・請求書のデータ化に関わるシステムを中心に使われています。例えば以下のような処理が含まれます。

  • 画像処理
  • OCR・文字列処理
  • 自然言語処理
  • 他のPython製の機械学習マイクロサービスを呼び出すハブ的役割
  • KPIの集計

特に初期から屋台骨として稼働している画像処理やOCRには一部プラットフォーム依存の実装があり、その点は保守・マイグレーションの課題となっています。

CI/CD

開発した年代や設置したインフラ、アプリケーションの特性に応じて使い分けています。

Jenkinsは最も古くから運用し、じわじわ移行中ながら今でも最多のサービスを受け持ちます。JenkinsはAmazon EC2インスタンスでホスティングしており、ゆえに他のAWSサービスや社内システムへのアクセスが容易で、それらへの疎通を要する結合テストに便利です。GitHubのWebhookで連携することで、pushの都度自動テストを走らせています。また、GitHubのpull requestにコメントを書くことで、後述するNuGetパッケージの発行やサービスのデプロイを行います。仕組みは異なりますが使用感は以下記事に近いです。

buildersbox.corp-sansan.com

GitHub Actionsは近年.NETに限らず利用を深めています。linterや単体テスト等であれば圧倒的にJenkinsより運用が楽になりました。ただEC2でのホスティングに分があるシーンもまだあり、例えば社内privateな他システムとの疎通が必要なテストを動かしたい場合は厄介です。またC++のビルドを伴うシーンについては標準Runnerではスペック不足を感じやすいです *9

AWS CodeBuild, Google Cloud Build については、それぞれAWS/Google Cloudのサービスへの疎通(権限設定)が容易である点や、高いスペックのRunnerを使える点が主な強みと考えています。

社内ライブラリ

経緯

上記の役割に沿って各機能を実現する NuGet パッケージを作成しており、社内向けにホスティングしています。20種類前後あります。

同じ処理は2度書かない、というプログラマとして当然の発想から作られました。ただし時間が経つにつれ運用負荷を感じており、現在は徐々に利用を縮小しています。以下のような課題があります。

  • サービスはそれぞれマイクロサービスで疎結合だが、NuGetライブラリを共通で使うことになると実装上密結合になる
  • 各パッケージ間の互換性の維持に骨が折れる
  • 依存するサードパーティのパッケージの整合性に骨が折れる
  • (.NET特有) .NET Frameworkから.NET Coreへの大変革期に当たってしまい、両対応にすることに骨が折れる

要するに、共通処理があっても各サービスで実装をコピペのほうがマイクロサービスにおいてはうまく回るのかもしれません。もちろんケースバイケースです。社内ライブラリも選りすぐりを残そうとしています。

またリストの最後に示した .NET Coreの大変革期の要素は大変苦労しています。当時は情報が少ない中、例えばTargetFrameworkの指定の仕様は以下のリンク先のように混迷を極めていました。

learn.microsoft.com

ほかにも、.csprojファイルがJSONに変わると思いきや結局やめたという件も混乱したものです。今思えば、過渡期はスルーして .NET Core 3.1くらいで落ち着いてから移行を始めればよかったかもしれません。

ホスティング

NuGetパッケージの管理については、前述のJenkinsをホスティングしているEC2インスタンスに同居しています。NuGet.Serverを立て、社内からのアクセスでのみ見えるパッケージとしています。

github.com

C#ソリューション直下に nuget.config ファイルを配置し、以下でいうところの MyNuGetServer という社内パッケージのホスト先を登録しています。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="MyNuGetServer" value="https://xxxxx.net/nuget />
  </packageSources>
  <activePackageSource>
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
  </activePackageSource>
  <packageSourceCredentials>
    <MyNuGetServer>
      <add key="Username" value="hoge" />
      <add key="ClearTextPassword" value="piyo" />
    </MyNuGetServer>
  </packageSourceCredentials>
</configuration>

この方法の課題は、GitHub Actions等の社外のCIサービスで疎通に困ることです。最近はGitHub PackagesによりNuGetパッケージのホスティングが可能ですから、そちらへの移行を検討しています。

github.co.jp

ログ

初期の実装では Apache log4netを使っています。これは意図は無くて、単に10年前のSansanの実装由来でした。

logging.apache.org

EC2インスタンス内にログがたまるので、Filebeatによってfluentd経由でAmazon S3へ転送しています。普段はこのS3を起点に問題調査やKPI算出等に活用しています。なおEC2以外のLambda等のインフラでは、単に標準出力にprintすればCloudWatch LogsだとかGoogle Cloud Loggingにログが出てくれる仕組みが多いと思いますので、Filebeatの利用は減少傾向です。

www.elastic.co

.NET Core化以後は NLog を使うようにしています。

nlog-project.org

EC2での運用を続ける場合でも、AWS.Logger.NLog パッケージを使うことで直接CloudWatch Logsに出力できて便利です。

docs.aws.amazon.com

なおCloudWatch Logsに出力することはNLog以外の方法でもサポートされています。

github.com

C++資産の活用

おそらく社内で最もこの事例が多そうなので挙げておきます。最近は減りましたが、2010年代前半頃はたびたび「C++のライブラリをどう呼び出すか」というシーンがありました。画像処理ロジックをC++で書いたケースや、社外製C++ライブラリを使いたいケース等です。

C++/CLI

最初に取った方法はこれでした。現在もわずかに現役です。Windows限定で良いなら最良の案だと今でも思います。

.NETのコードとネイティブC++のコードを混ぜて書けます。 learn.microsoft.com

年々サポートが細っている気がしつつ、まだ大丈夫です。課題として、C#でサポートされた最新のランタイム・言語機能が降りてくるまで時間がかかることが常です(ずっと来ない場合もあります)。あと文法が気持ち悪いとの声がしばしば見受けられますが、慣れるとかわいくなってきます。

// 画像中の非0の画素数を返す例
#include <opencv2/opencv.hpp>

Int32 CountNonZeroPixels(array<Byte> ^imageData)
{
    pin_ptr<uchar> p = &imageData[0];
    cv::Mat buffer(1, imageData->Length, CV_8UC1, p);
    cv::Mat img = cv::imdecode(buffer, cv::IMREAD_GRAYSCALE);
    return cv::countNonZero(img);
}

C APIとしてFFI

一般によく取られる方法で、マルチプラットフォーム化も可能です。情報は多数あるので詳細は省略します。

learn.microsoft.com

課題はCの形式でエクスポートする面倒を見てあげることに尽きまして、リソースの解放、メモリ上のレイアウト、GCの効き具合、C++にしかないものをCでどう表現するか、OSやアーキテクチャの差異の吸収、等に繊細に神経を使う必要があります。

// Mersenne Twisterによる乱数値を返す適当な例
#include <random>
#include <cstdint>

// __declspec(dllexport) はVC++向け
extern "C"  __declspec(dllexport) std::uint32_t __cdecl get_rand(std::int32_t seed)
{
    std::mt19937 engine(seed);
    return engine();
}
// C# 呼び出し側 (要.NET7)
static class NativeMethods
{
    [LibraryImport("nativelib", EntryPoint = "get_rand")]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static partial uint GetRand(int seed);
}

.NETランタイムにはGCがありますが、ネイティブコードで確保したメモリは感知できません。画像を扱うようなメモリを大きく消費するケースでは、GCが有効に働かないままメモリ不足の死を迎えることがあります。細かく解放に注意するのが基本ですが、GC.Collect(); をしょっちゅう呼ぶのもなんだかんだ有効です。例としてTorchSharpのドキュメントにわかりやすい解説があります。

github.com

今やるとしたら

ネイティブライブラリがあると環境構築が面倒になるなど「足回りが重く」なります。他の方法としてはPure C++の単独マイクロサービスを立てるのが有力案の1つと考えており、現在新規案件をそれで開発しています。例えばCrow というC++のWebフレームワークは非常にシンプルに実装できます。

crowcpp.org

WebAPIではなくてローカルの実行形式とし、外部プロセスとして呼び出しする、というのもこの亜種と言えるかもしれません。これには事例があります。プロセスが何らかの理由で固まってしまうなど異常時の対処が面倒であるとか、スケールさせにくい、大きな入出力のやり取りが難しい*10、といった課題はあります。

Pythonと.NET(C#)が並び立つ現状について

現状についての判断

研究開発部メンバーの大半がPython等を書くエンジニアになり、その点だけで言えば、仮に無限のリソースがあるならば既存サービスを全部Pythonに移行しても問題ないと考えます。日々の開発、運用、また採用等は環境が絞られた方が良いのは間違いありません。

ただし、.NET (C#)もまた時代の最先端を行く環境であり *11、今も昔も十二分にモダンな開発体験を得られます。既存の安定したサービスまでPythonへ無理にでも移行するのは、何年かかるやらという話でコストに見合わないと考えています(ChatGPTがやってくれる時代がすぐそこかもしれませんが!)。

.NETの肩を持つとすれば、まず1つに性能上有利と思われます。ただし負荷の多くは裏側のネイティブコードや外部WebAPIの処理待ちであり、埋めがたい差とまでは言えません。もう1つに、一般に大規模開発には向いているであろう点があります。マイクロサービス開発が主体なのでこれも強い理由とはなりませんが、確かに現存システムを見るとC#実装のものの方がコード規模が大きい傾向があります。これらも加味してそのままのシステム構成での単純な書き換えは考えていませんが、サービスを細分化したりまたは全くの新規手法に置き換えるといった形での他言語置き換えは実施例がありますし今後も選択肢として持っています。

Python開発への参画経緯

長年、屋台骨の.NETサービスの維持・発展で手一杯でしたが、多少落ち着いてきたことや人員増加もあり、さらに幅広く研究員の下支えをしようということになりました。名刺のデータ化のみならず、請求書データ化や更にそれ以外の新規事業のPythonサービスが増えており、それらのDevOpsも担うようになりました。

Python自体は昔から何となくは書けたのでそう困りませんでしたが、実際の応用、特にテストに課題を感じました。

.NETの各サービスは創部以来ずっと見てきた子供のような存在で、今だテスト完備には程遠いもののよく仕様まで理解しています。しかしながら自分が内部までそう詳しくない、かつテストが不十分なPythonサービスを受け持つと、途端に不安に襲われました(自分のことは棚に上げて)。

しかしテストを書こうにも、研究開発部のアプリケーションは非専門家から見れば「画像や文字列をモデルに突っ込んだらなんかいい感じのが返ってくる」というブラックボックスにしか見えないフシがあるわけで、処理を解きほぐしていかないと入力と期待値の対応が見出しにくいです。ところが解きほぐそうにもMLモデルやAWS依存等がコード中で絡み合っていて、単体テストを書きづらいことが往々にしてあります。

その時これまで得た知見が役に立ちました。少なくとも私にとってはPythonとC#の両方で開発している経験は確実に利がありました。そして組織としてのPython開発の底上げにもつながりました。その一例を以下で述べます。

.NET 開発経験がPython側にも生きた例

最近ではASP.NET CoreによるWebAPI開発を多数手がけています。触れば触るほどよくできたフレームワークですので、ぜひ皆さん触ってみると良いと思います。古い ASP.NET MVCアプリケーションからの移行に伴って得るものが大きいです。例えばDI (依存性注入) の概念はその一例です。従来の実装はかなりstaticを多用しており、特にテストが課題でした。ASP.NET Core (正確にはGeneric Host?) によるDIを使った実装に置き換えることで、自由自在にモックを入れこめますし、テストを簡単かつ自然に実装できるようになりました。DI自体は新しい概念ではないと思いますが、ASP.NET Coreでは半ば強制されるというところが大きく、「なんか従ってたらいやでも良い実装にされてしまう」感覚です *12

このASP.NET Coreで得た開発体験をPython (FastAPI) でのWebAPI開発にも輸入しようと考えました。以下の記事にまとめています。FastAPI + DIの設計はあまり巷に例がないようで、この記事は貴重な前例になっていると多少自負しています。

zenn.dev

こういうのは御託を並べるだけでは世界は変わらないので、あるPythonサービスに自分で手を動かしてDependency InjectorやDIを生かしたテストをしれっと導入しました。そんなこんなを言うだけ言って私は2か月少々育児休業に入ったわけですが、その後復帰してみるともう他の2、3サービスにDependency Injectorが導入されていました。今では研究開発部でのアプリケーション開発の雛形にこれが組み込まれるまでになっています。私の投げっぱなしの提案を受け入れてくれた研究開発部の各位には大変感謝しております。アーキテクトはもちろん、研究員にも実装に長けた人間が多い当部の層の厚さを感じます。

その他、最近のPythonは型を付ける (typing) のが常識になりつつありますが、それによって行き着く実装スタイルはJavaやC#等で当たり前にやっているところとかなり似通っているように感じています。その意味でも経験が生きています。

なおこのあたり、アーキテクトチームはかつてかなり人数が少なかったので、もう少し開発職の層が厚ければもっと早く実現していたかもしれません。ぜひぜひまずはお見知りおきのほどよろしくお願いいたします。

*1:https://buildersbox.corp-sansan.com/entry/2021/02/05/110000

*2:https://buildersbox.corp-sansan.com/entry/2021/02/18/110000

*3:いくらかの組織改編はありました。現在は名刺データ化以外にも手を広げています。

*4:Eightの名刺も当初からターゲットです

*5:社歴10年を迎え、私の目が黒いうちに歴史を書いておこうというのが本記事の1つの動機です。

*6:Python, C#以外に活用する言語もあります。

*7:.NET 5以降はCoreが付きませんが、.NET Frameworkと比較する便宜上こう書きます。

*8:マイクロサービスゆえ対応しやすい面はあります。

*9:self-hosted runnerはこれらの解決につながる可能性があり、検討しています。

*10:典型的には一時ファイルを介することになりますが、連続稼働では消しそこないやファイル名重複などに悩まされがちです。他にはプロセス間通信という手もあります。

*11:むしろ変化の多さでは群を抜いていそうです。

*12:.NET界隈は「お上が決めた唯一絶対のもの」に倣えという空気が強く、これが好き嫌いのわかれるところではあると思います。倣ってみれば気持ちが良いという世界です。

© Sansan, Inc.