前の記事で、古典籍のページ画像を渡さないまま OCR だけ実行してもらう流れを通しました。ただ、あのとき私は自分が何を作ったのかを正確に説明できていませんでした。「動いた」とは言えるが、「このコンテナは何を要求されているのか」は答えられない。

この記事はその穴を埋めるものです。Compute-to-Data(C2D)に自作のプログラムを登録するとき、コンテナ側が守らされる約束を洗い出します。値はすべて ocean-node 3.2.0 の実装を読み、手元で実行して確かめたものです。

先に結論: 5 つの約束

自作プログラムを C2D で動かすには、コンテナが次を満たす必要があります。ドキュメントに一覧としては書かれておらず、私は全部を失敗して学びました

約束具体的に破るとどうなるか
入力の場所/data/inputs/フラットに置かれる掘りに行くと 0 件
出力の場所/data/outputs/ にだけ書ける他所に書くと権限エラー
作業ディレクトリWORKDIR / 必須$ALGO が解決できず起動しない
実行ユーザUID 1000 固定(root ではない)root 前提の処理が落ちる
ネットワーク環境が許さなければ遮断実行時ダウンロードは全部失敗

実行ユーザはソース上の定数で、設定では変えられません。

const C2D_CONTAINER_UID = 1000;

ネットワークのほうは無条件ではなく、環境側の設定で決まります。遮断される場合は Docker のネットワーク自体が切られます。

if (!env.enableNetwork) {
    hostConfig.NetworkMode = 'none';
}

利用者側からは、どちらの環境に当たるか事前に分かりません。 遮断される前提で作っておくのが安全です。

資源上限もあります。ただしこれはノードの設定次第で、固定値ではありません。私の環境で computeEnvironments が返した値はこうでした。

無料枠: cpu 2 / ram 2 / disk 4   同時実行 10 本 / 1ジョブ 300 秒

既定のままではなく、検証中に自分で広げたあとの値です(無料枠の同時実行は既定 3 本でした)。上限は交渉の対象ではなくノード運用者が決めるもので、こちらは与えられた枠に収める側になります。

「アルゴリズム」は 2 つの部品でできている

ここが最初に躓いたところです。C2D で言う「アルゴリズム」は 1 個のものではなく、別々に登録される 2 つの組み合わせです。

① コンテナイメージ  … 実行環境(Python、ライブラリ、モデル重み)
                      → DDO の metadata.algorithm.container に「名前とダイジェスト」で書く

② アルゴリズム本体  … 実際に走るスクリプト 1 ファイル
                      → 資産の files として登録し、URL は暗号化されて預けられる

実行時、ノードは②をダウンロードして /data/transformations/algorithm に置き、①のコンテナの中で python $ALGO として起動します。

登録時には、コンテナイメージ(DDOに名前とダイジェストで記載、モデルと依存を焼き込む)とアルゴリズム本体(資産のfilesとして登録するスクリプト1ファイル)を別々に用意する。実行時のコンテナ内では、UID 1000・ネットワークなしの環境で、/data/inputs に入力が、/data/transformations/algorithm にアルゴリズム本体が置かれ、書き込めるのは /data/outputs だけ。起動コマンドの $ALGO は相対パス data/transformations/algorithm に展開されるため WORKDIR は / でなければならない。

この分離が WORKDIR / 必須の理由です。$ALGO は絶対パスではなく data/transformations/algorithm という相対パスに展開されます。/ 以外を作業ディレクトリにすると、そこからの相対で探しにいって見つかりません。Dockerfile にこう書き残しました。

# ノードが $ALGO を相対パスに置換するため、ここは変更しないこと
WORKDIR /

②を差し替えれば処理内容だけ変えられる、という設計です。逆に言うと、イメージに入れた CMD は使われません。私は最初これを勘違いして、CMD に処理を書いて「何も起きない」状態を作りました。

入出力の契約を、コード側で受け止める

ノードが決めているのは置き場所だけで、その中身の解釈はこちら側の責任です。今回書いたラッパはこれだけのことをしています。

INPUT_DIR = "/data/inputs"
OUTPUT_DIR = "/data/outputs"

def find_images(root):
    found = []
    for dirpath, _dirnames, filenames in os.walk(root):
        for name in filenames:
            if name.lower().endswith(EXTS):
                found.append(os.path.join(dirpath, name))
    return sorted(found)

落とし穴: 入力ファイル名は「配信 URL の basename」になる

入力は DID ごとのフォルダに分かれて来るものと思い込んでいましたが、実際はフラットに /data/inputs/<name> に置かれ<name> は配信 URL の basename でした。

これが効いてくるのは、拡張子が無いと OCR 側のフィルタに掛からないからです。URL を .../kotenseki-page のような形で配信していると、中身が JPEG でも無視されて「対象画像 0 件」で正常終了します。エラーにならないぶん厄介でした。

原因が見えるように、0 件のときは中身を出すようにしています。

if not images:
    # 拡張子が落ちている場合にここへ来る。原因が分かるように中身を出す。
    print("画像が見つかりませんでした。入力にあったファイル:")
    for name in listing[:20]:
        print(f"  - {name}")
    print("(拡張子が無いファイルは OCR 側のフィルタで無視されます)")

C2D は手元でデバッグできません。コンテナの中で何が起きたかを知る手段は、ログとして出したものだけです。失敗時に状態を吐く価値が、普段の何倍もあります。

イメージの作り方

ネットワークが無いので、モデルは焼き込む

enableNetwork: false で動くので、実行時にモデル重みを取りに行く作りは成立しません。約 79 MiB の重みを含めて COPY しています。

# OCR 本体(モデル重み約 79 MiB を含む)
COPY src/ /ndlkotenocr/src/

結果、イメージは 770 MB になりました。C2D 向けのイメージが太るのは、この制約からくる必然です。

GUI 依存を外す

上流の requirements.txt には GUI アプリ用の flet が入っていますが、CLI (src/ocr.py) には不要です。aarch64 の wheel 有無に左右されるうえイメージも太るので、依存を列挙し直して外しました。

RUN pip install --no-cache-dir \
      onnxruntime==1.23.2 \
      pillow==12.1.1 \
      numpy==2.2.2 \
      ...

NDL古典籍OCR-Lite が onnxruntime のみで動く(PyTorch も GPU も要らない)ことが、ここでは大きく効いています。CPU だけの環境で完結します。

いちばん理不尽だった失敗: イメージ名

ビルドは通るのに pullImage が 3 回失敗しました。原因は 2 つ重なっていました。

1つ目は OCI の image indexdocker build は既定で attestation 付きのマニフェストリストを作りますが、ノードはこれを解釈できませんでした。単一マニフェストにします。

docker build --platform linux/arm64 --provenance=false --sbom=false -t ...

2つ目が分かりにくいものでした。ノードの Docker 操作は、マウントされたソケット経由でホストの Docker デーモンが実行します。したがってイメージ名は「ホストから見た名前」でなければなりません。host.docker.internal はノードのプロセスからは見えても、デーモンからは解決できません。

✗ host.docker.internal:5050/ndlkotenocr-lite   ノードからは見えるが pull は失敗
✓ localhost:5050/ndlkotenocr-lite              ホストのデーモンが解決できる

「コンテナの中から見たアドレス」と「Docker を動かしている側から見たアドレス」が違う、というだけの話ですが、エラーメッセージからは辿り着けませんでした。

登録する

publish は 2 資産です。アルゴリズム資産の要点は container の 3 つ組です。

container: {
  entrypoint: 'python $ALGO',
  image: 'localhost:5050/ndlkotenocr-lite',
  tag: 'arm64',
  checksum: 'sha256:c54303263f75e60f6de8a0bb90ab9f9ba7c94e59915f18d3010a9ec9d17aa517'
}

チェックサムはイメージのダイジェストと一致していなければなりません。 リビルドすると変わるので、ここの更新忘れは何度もやりました。

ここにもう 1 つ罠があります。ローカルでビルドしたイメージと、レジストリに push したあとのイメージでは、ダイジェストが違います。

ndlkotenocr-lite:arm64                  sha256:74390c53c241fa86…  ← 手元のビルド
localhost:5050/ndlkotenocr-lite:arm64   sha256:c54303263f75e60f…  ← push 後

DDO に書くべきは後者です。ノードが pull してきて突き合わせるのは、レジストリ側のダイジェストだからです。手元の docker images を見て前者を書き写すと、通らないのに理由が分かりません。

データセット側は、どのアルゴリズムを許すかを宣言します。

compute: {
  allowRawAlgorithm: false,
  publisherTrustedAlgorithms: [{ did: algoDid, ... }]
}

ただし前の記事に書いたとおり、無償の実行経路ではこの宣言が読まれていません。書いたとおりに効くのは有償経路だけです。宣言としては正しく、強制はされていない、という状態です。

動かした結果

この節の内容は、別記事の実演動画で画面の操作として見られます。アルゴリズムの選択からジョブの実行、成果物の受け取りまでが 1 分半ほどです。

古典籍のページ画像 1 枚を投入して受け取ったものです。

{
  "engine": "NDL古典籍OCR-Lite",
  "license": "CC BY 4.0",
  "images": 1,
  "totalLines": 9,
  "totalCharacters": 34,
  "perFile": [
    { "file": "kotenseki-page.jpg", "ok": true,
      "lines": 9, "characters": 34, "seconds": 10.7 }
  ]
}

outputs.tar の中身は 5 ファイルでした。

ファイルサイズ中身
kotenseki-page.txt84 B本文テキスト
kotenseki-page.xml1,049 BNDL 形式
kotenseki-page_tei.xml2,000 BTEI(帰属表示を含む)
kotenseki-page.json3,937 B座標つきの認識結果
ocr-summary.json335 B処理の要約

読めたテキストの冒頭はこうです。

に
Dionter
大正
1.3.3
二.
同同一
天和二歳

精度は良くありません。 34 文字のうち Dionter1.3.3 は明らかに誤りです。ただしこれは題材の問題で、私が使ったのは実在資料ではなく検証用に用意した合成画像です。OCR の性能を測った実験ではありません。

ここで確かめたのは精度ではなく、「画像を渡さずに、テキストと TEI と座標だけを受け取れるか」です。それは通りました。

自分の失敗として書き残しておくこと

publish スクリプトを読み返して見つけたものです。アルゴリズム資産の DDO に、前の実験の説明文がそのまま残っていました

description: '……入力の IIIF Manifest を読み、アノテーションの件数を数えて出力します。'
language: 'javascript',

実際に動いているのは Python の OCR です。処理そのものは正しく差し替わっているので結果には影響しませんが、カタログ上の説明が実体と食い違っている状態でした。

これは単なる不注意ですが、C2D の性質を考えると軽くありません。利用者はコンテナの中を見られず、カタログの記述を信じて実行を依頼するしかないからです。checksum はイメージの同一性を保証しますが、「そのイメージが説明どおりのことをするか」は誰も保証していません。私が自分の資産で証明してしまいました。

まとめ

自作プログラムを C2D に載せるとき、実際に要求されたのはこれだけでした。

  • 入力は /data/inputs にフラットに来る。ファイル名は配信 URL の basename
  • 出力は /data/outputs にだけ書ける
  • WORKDIR /$ALGO が相対パスで渡るため
  • UID 1000 で動く。ネットワークは無いものとして、依存は全部イメージに焼き込む
  • イメージ名はホストの Docker デーモンから解決できる名前にする
  • checksum はダイジェストと一致させる。リビルドのたびに更新する

技術的な難所は、どれも「知っていれば 1 行」のものでした。難しかったのは、手元で再現できない場所で起きている失敗を、ログだけから推測することのほうです。

そして最後の一件が示すとおり、この仕組みが守ってくれるのは「データが外に出ないこと」であって、「実行されるものが説明どおりであること」ではありません。後者を担保する層は、まだこの上に必要です。

確認できていないこと

  • 検証は単一ノード・単一ジョブの範囲です。複数ノードでの実行や、資源競合時の挙動は見ていません
  • 精度評価は行っていません。上記の値は合成画像に対するもので、実資料に対する性能を示すものではありません
  • allowRawAlgorithm などの宣言が有償経路で実際に強制されるかは、無償経路で読まれないことを確認したのみで、有償経路では試していません