フォルダに入れるだけでLAN内のPCが勝手に処理する分散処理システムを作った話 前編

この記事について

この記事では、LAN内の複数PCを使って、ファイル処理を自動分散する仕組みを作った話をまとめます。

最初に考えていたのは、複数PCを完全に1台のPCとして扱うような大きい仕組みではありません。 もっと素朴に、

inputフォルダにファイルを入れる
  ↓
サーバーがジョブ化する
  ↓
空いているワーカーPCが処理する
  ↓
outputフォルダに結果が出る

という形です。

ユーザーはAPIやSSHを意識しません。 フォルダに置いたら、LAN内のどこかのPCが勝手に処理してくれる。 そのくらいOSに溶け込んだ感じを目指しました。

最初は画像を白黒化するだけのMVPでした。 そこから少しずつ拡張して、今は次のような処理までできるようになっています。

  • 画像のグレースケール化
  • 画像リサイズ
  • 画像サムネイル作成
  • PDFのページ画像化
  • 動画サムネイル作成
  • 動画変換
  • 画像OCR
  • PDF OCR
  • 複数ワーカーへの自動割り当て
  • Windowsサービス化
  • ダッシュボード表示

小さい実験から始めたのに、気づいたらちょっとしたLAN内バッチ処理クラスタになっていました。


作りたかったもの

目的はこれです。

メインPC
  ジョブ管理サーバー
  input/outputフォルダを持つ

ワーカーPC
  常駐エージェント
  定期的に仕事を取りに行く
  処理結果を返す

ユーザーの操作はこれだけです。

server/input にファイルを入れる

処理結果はここに出ます。

server/output

最初に作ったMVPは画像処理でした。

input/sample.png
  ↓
workerが処理
  ↓
output/1_sample_processed.png

処理内容は、画像をグレースケール化して、横幅が大きければ800pxに縮小するだけです。 でも、これだけでも分散処理システムとして必要な部品は一通り出てきます。

  • フォルダ監視
  • ジョブ登録
  • ジョブ状態管理
  • ワーカーのポーリング
  • ファイルダウンロード
  • 結果アップロード
  • 失敗通知
  • 二重取得防止

このあたりをちゃんと作ると、あとから画像以外にも広げられます。


全体構成

構成はシンプルです。

distributed-os-layer/
├─ server/
│  ├─ index.js
│  ├─ db.js
│  ├─ config.json
│  ├─ input/
│  ├─ output/
│  ├─ uploads/
│  └─ public/dashboard.html
│
└─ worker/
   ├─ worker.py
   ├─ config.json
   └─ distributed-worker.exe

技術は次の通りです。

役割 技術
メインサーバー Node.js + Express
DB SQLite
フォルダ監視 chokidar
ワーカー Python
画像処理 Pillow
PDF処理 PyMuPDF
動画処理 FFmpeg
OCR Tesseract OCR
サービス化 NSSM
通信 HTTP API

サーバーは input を監視します。 ファイルが追加されると、SQLiteの jobs テーブルにジョブを作ります。

ワーカーは常駐していて、定期的にサーバーへ問い合わせます。

仕事ありますか?

サーバーは、そのワーカーが処理できるジョブだけを渡します。


ジョブ状態

ジョブは次の状態を持ちます。

pending
running
done
failed

意味はこうです。

状態 意味
pending まだ誰も処理していない
running どこかのワーカーが処理中
done 完了
failed 失敗

ワーカーがジョブを取得するときは、SQLiteのトランザクション内で pending から running に変えます。 これで、複数ワーカーが同時に問い合わせても、同じジョブを二重取得しないようにしています。

重要なのはここです。

SELECT pending job
  ↓
UPDATE status = running

これを1つのトランザクションで行います。

SQLiteなので巨大クラスタ向けではありません。 でも、LAN内で数台のPCを使うMVPとしてはかなり扱いやすいです。


最初のAPI設計

APIは最初かなり素朴でした。

GET  /api/jobs/next
GET  /api/jobs/:id/file
POST /api/jobs/:id/result
POST /api/jobs/:id/fail
GET  /api/jobs

役割はこうです。

API 役割
/api/jobs/next 次のジョブを1つ取得
/api/jobs/:id/file 入力ファイルをダウンロード
/api/jobs/:id/result 結果ファイルをアップロード
/api/jobs/:id/fail 失敗内容を送信
/api/jobs ジョブ一覧

あとから機能が増えて、今は次もあります。

GET  /api/stats
GET  /api/workers
POST /api/workers/heartbeat
POST /api/jobs/:id/retry

heartbeat はワーカーが生きていることを知らせるためのAPIです。 これがあると、処理中にワーカーPCが落ちた場合でも、一定時間後にジョブを pending に戻せます。


inputサブフォルダで処理を切り替える

途中から、画像だけでなく動画やPDFも扱いたくなりました。 そこで job_type を追加しました。

例えばこうです。

image_grayscale
image_resize
image_thumbnail
image_ocr
pdf_to_images
pdf_ocr
video_thumbnail
video_transcode

ファイルの置き場所で処理を切り替えます。

server/input/sample.png
  -> image_grayscale

server/input/resize/sample.png
  -> image_resize

server/input/thumbnail/sample.png
  -> image_thumbnail

server/input/ocr/sample.png
  -> image_ocr

server/input/document.pdf
  -> pdf_to_images

server/input/pdf-ocr/document.pdf
  -> pdf_ocr

server/input/video-thumbnail/movie.mp4
  -> video_thumbnail

server/input/video-transcode/movie.mp4
  -> video_transcode

この方式はかなり使いやすいです。

ユーザーから見ると、処理したいフォルダにファイルを入れるだけです。 Web画面からジョブを作る必要もありません。 CLIでコマンドを打つ必要もありません。

フォルダ = 処理モード

というルールにしたことで、かなりOSに近い感覚になりました。


ワーカー能力の自動判定

動画処理にはFFmpegが必要です。 OCRにはTesseractが必要です。 PDF処理にはPyMuPDFが必要です。

でも、すべてのワーカーPCに全部入っているとは限りません。

そこで、ワーカーは起動時に自分の能力を自動判定します。

例えば、FFmpegが見つかればこうなります。

video_thumbnail
video_transcode

Tesseractが見つかればこうなります。

image_ocr
pdf_ocr

実際のログはこんな感じです。

capabilities=image_grayscale,image_resize,image_thumbnail,image_ocr,pdf_ocr,pdf_to_images,video_thumbnail,video_transcode

サーバーはこの capabilities を見て、処理できるジョブだけを渡します。

つまり、FFmpegが入っていないワーカーに動画ジョブは渡りません。 Tesseractが入っていないワーカーにOCRジョブも渡りません。

この仕組みを入れたことで、ワーカーPCごとの役割分担が自然にできます。

古いPC
  画像リサイズだけ

そこそこ速いPC
  PDFとOCR

強いPC
  動画変換

みたいな構成もできます。


Windowsサービス化で常駐させる

手動でワーカーを起動するだけなら簡単です。

.\distributed-worker.exe

でも、これだとPowerShellを開きっぱなしにする必要があります。 PCを再起動したら止まります。

そこでNSSMを使ってWindowsサービス化しました。

DistributedOsLayerServer
DistributedOsLayerWorker

サービス化すると、PC起動時に自動で立ち上がります。

メインPC起動
  ↓
サーバー自動起動

ワーカーPC起動
  ↓
ワーカー自動起動

これで、普段はサービスの存在を意識しなくてよくなります。


NSSMで詰まったところ

今回かなりハマったのがNSSMのパス設定です。

特に重要なのはこの3つです。

nssm get DistributedOsLayerWorker Application
nssm get DistributedOsLayerWorker AppDirectory
nssm get DistributedOsLayerWorker AppEnvironmentExtra

期待する形はこうです。

Application  = D:\DistributedWorker\distributed-worker.exe
AppDirectory = D:\DistributedWorker
CONFIG_PATH  = D:\DistributedWorker\config.json

最初、K: のような割り当てドライブを使っていました。 でもWindowsサービスでは、ユーザーがログインしている時のドライブ割り当てが見えないことがあります。

そのため、サービスではできるだけ実ドライブのパスを使うことにしました。

K:\distributedworker

ではなく、

D:\DistributedWorker

のように揃えます。

さらに、FFmpegやTesseractをインストールしても、PowerShellから見えるだけでは足りません。 サービス環境の PATH にも入れる必要があります。

$ffmpegBin = Split-Path (Get-Command ffmpeg).Source
$tesseractBin = Split-Path (Get-Command tesseract).Source

nssm set DistributedOsLayerWorker AppEnvironmentExtra `
  "CONFIG_PATH=D:\DistributedWorker\config.json" `
  "PATH=C:\Windows\System32;C:\Windows;C:\Windows\System32\WindowsPowerShell\v1.0;$ffmpegBin;$tesseractBin"

この設定ができると、ワーカーの能力に動画とOCRが出ます。

video_thumbnail
video_transcode
image_ocr
pdf_ocr

ここは地味ですが、実運用ではかなり大事でした。


日本語OCR対応

OCRはTesseractを使っています。

英語だけなら eng で動きます。 日本語を読むには、日本語の言語データが必要です。

確認はこれです。

tesseract --list-langs

日本語が入っていれば、こう出ます。

jpn
jpn_vert

入っていない場合は、Tesseract公式の tessdata から jpn.traineddata を追加します。

$dest = "C:\Program Files\Tesseract-OCR\tessdata\jpn.traineddata"
$url = "https://github.com/tesseract-ocr/tessdata/raw/main/jpn.traineddata"
Invoke-WebRequest -Uri $url -OutFile $dest

縦書き用も入れるならこれです。

$dest = "C:\Program Files\Tesseract-OCR\tessdata\jpn_vert.traineddata"
$url = "https://github.com/tesseract-ocr/tessdata/raw/main/jpn_vert.traineddata"
Invoke-WebRequest -Uri $url -OutFile $dest

ワーカー設定はこうしました。

"ocr_lang": "jpn+eng"

これで日本語と英語が混ざった画像も読めます。


ダッシュボード

簡易ダッシュボードも作りました。

見えるものは次の通りです。

  • pending / running / done / failed の件数
  • worker一覧
  • worker capabilities
  • 最新ジョブ一覧
  • failedジョブのRetryボタン
  • エラー内容

ブラウザで開きます。

http://メインPCのIP:3000/

API tokenを入力すると、ジョブ状態が見えます。

最初はAPIだけでも十分だと思っていました。 でも、実際に使ってみると、ダッシュボードがあるだけでかなり安心感が違いました。

ジョブが作られているか
ワーカーが生きているか
どの能力を持っているか
失敗しているか
pendingのまま詰まっているか

このあたりがすぐ見えます。

特に capabilities は重要でした。 動画やOCRが動かない時、ワーカー能力に出ていないことがすぐ分かります。


いま実際にできること

今の状態でできることをまとめると、こうです。

画像

input\sample.png
  -> 白黒画像

input\resize\sample.png
  -> リサイズ画像

input\thumbnail\sample.png
  -> サムネイルJPEG

input\ocr\sample.png
  -> OCR結果txt

PDF

input\document.pdf
  -> ページ画像zip

input\pdf-ocr\document.pdf
  -> OCR結果txt

動画

input\video-thumbnail\movie.mp4
  -> サムネイルJPEG

input\video-transcode\movie.mp4
  -> 変換済みmp4

運用

複数ワーカー
自動ジョブ選別
ワーカー能力別割り当て
Windowsサービス常駐
ログローテーション
失敗ジョブ再実行

ここまで来ると、単なる画像処理スクリプトではなくなりました。

LAN内で動く小さい分散処理基盤です。


この仕組みが得意なこと

このシステムが得意なのは、バッチ処理です。

例えばこういうものです。

  • 画像を大量にリサイズする
  • 動画からサムネイルをまとめて作る
  • PDFをページ画像に変換する
  • 日本語OCRを大量に回す
  • 条件の違うシミュレーションを並列実行する
  • Blenderのレンダリングを複数PCに分ける

1つの巨大な処理を複数PCでリアルタイムに分けるより、独立したジョブをたくさん処理する方が向いています。

つまり、

1つの処理を10台で速くする

より、

100個の処理を10台にばらまく

方が得意です。

OCRや動画サムネイルのような処理は、かなり相性が良いです。


後編へ

ここまでで、画像、PDF、OCR、動画処理までをフォルダ投入型の分散ジョブにできました。

このあと、さらに遊び心のある拡張として、物理シミュレーションとBlenderレンダリングも追加しました。

その話は後編に分けます。

後編: 物理シミュレーションとBlenderレンダリングも分散ジョブにした話


詰まったところ

作っていて詰まったところは、処理ロジックそのものより運用まわりでした。

PowerShellの実行ポリシー

.ps1 を実行しようとすると止まりました。

このシステムではスクリプトの実行が無効になっているため

一時的に回避するならこうです。

powershell -ExecutionPolicy Bypass -File .\build-worker-exe.ps1

config.jsonのJSONミス

小さいミスですが、これでワーカーが即落ちしました。

間違い:

"server_url": ""http://192.168.1.7:3000",

正しい形:

"server_url": "http://192.168.1.7:3000",

ログにはこう出ました。

json.decoder.JSONDecodeError

サービスとPATH

PowerShellでは ffmpegtesseract が見えるのに、サービスでは見えないことがありました。

原因は、Windowsサービスが別の環境変数で動くからです。

そのため、NSSMの AppEnvironmentExtraPATH を明示しました。

Kドライブ問題

ワーカーPCで K: として見えている場所が、サービスからは見えないことがありました。

サービスでは割り当てドライブより、実ドライブパスを使う方が安定します。

D:\DistributedWorker

のようにしました。


最低限の確認コマンド

サーバー確認:

Get-Service DistributedOsLayerServer

ワーカー確認:

Get-Service DistributedOsLayerWorker

NSSM設定確認:

nssm get DistributedOsLayerWorker Application
nssm get DistributedOsLayerWorker AppDirectory
nssm get DistributedOsLayerWorker AppEnvironmentExtra

ワーカーログ:

Get-Content D:\DistributedWorker\logs\worker.log -Tail 40

サーバーダッシュボード:

http://メインPCのIP:3000/

FFmpeg確認:

ffmpeg -version

Tesseract確認:

tesseract --version
tesseract --list-langs

公開前チェックリスト

この仕組みをLAN内で使う前に見るところです。

メインPC

  • Node.jsが入っている
  • server/config.json がある
  • apiToken を設定している
  • DistributedOsLayerServer がRunning
  • Windows Firewallで3000番を許可している
  • inputoutput の場所を確認している

ワーカーPC

  • distributed-worker.exe がある
  • config.json がある
  • server_url がメインPCのIPになっている
  • api_token が一致している
  • DistributedOsLayerWorker がRunning
  • NSSMの CONFIG_PATH が正しい
  • FFmpeg/Tesseractを使う場合はサービスのPATHにも入っている

ダッシュボード

  • workerが表示されている
  • last_seen_at が更新されている
  • capabilities が期待通り
  • ジョブが pending のまま止まっていない

今後やりたいこと

今後やるなら、このあたりが面白そうです。

  • OCR前処理の強化
  • 失敗理由ごとの再実行ルール
  • ジョブ優先度
  • outputファイルのダウンロードリンク
  • ワーカーごとの処理時間表示
  • 処理完了通知
  • GPU OCRワーカー

特にOCRは、前処理でかなり精度が変わりそうです。

  • 二値化
  • 拡大
  • 傾き補正
  • ノイズ除去
  • コントラスト調整

このあたりを image_ocr_preprocess のようなジョブにしてもよさそうです。


まとめ

最初は、画像を白黒にするだけの分散処理MVPでした。

でも、作っていくうちに、必要な部品がだんだん見えてきました。

フォルダ監視
ジョブ管理
ワーカー常駐
二重取得防止
heartbeat
サービス化
能力判定
ダッシュボード

このあたりが揃うと、処理内容はあとから増やせます。

結果として、今はこういう仕組みになりました。

ファイルをinputに入れる
  ↓
サーバーが種類を判定する
  ↓
処理できるワーカーに渡す
  ↓
ワーカーが処理する
  ↓
outputに結果が出る

大げさなクラスタではありません。 でも、LAN内の余っているPCを自然に使う仕組みとしては、かなり気持ちよく動きます。

こういう小さい分散処理は、身近なPCを少しだけ賢く使える感じがあって楽しいです。