メイン環境を汚さない Hugo + Asciidoctor 連携:専用コンテナ切り出しと並行ビルド排他制御(flock)の技術

目次
個人用メモ・備忘録

日々の開発・インフラ検証の備忘録として残している個人ノートです。手元環境での動作ログをもとにまとめています。環境差異等もあるため、参考にされる場合はご自身の環境で検証の上ご活用ください。

はじめに

静的サイトジェネレーター Hugo は、標準で Markdown(Goldmark)の高速パースに対応していますが、技術文書や仕様書に強い AsciiDoc(.adoc) のレンダリングにも対応しています。

しかし、AsciiDoc をパースするには Ruby 製の asciidoctor コマンドが必要です。

Go 言語メインで構成されたクリーンな開発環境(DevContainer)に、Ruby や Bundler、Rouge(シンタックスハイライター)などの大量の Gem を直接追加したくありませんでした。メイン環境の依存関係が汚染され、イメージサイズやビルド構成が複雑化するためです。

そこで、「Asciidoctor を専用の軽量 Docker コンテナに切り出し、Hugo からはローカルコマンドのように透過的に呼び出す」 アーキテクチャを設計しました。

しかし、この構成を組んだ直後、Hugo の超高速並行ビルドによって標準入出力パイプが競合・切断され、出力が 0 bytes になる致命的なトラブル に直面しました。

本記事では、コンテナ連携アーキテクチャの基本設計から、並行ビルド時のパイプ切断のメカニズム、そして flock を用いた堅牢な排他制御ラッパースクリプトの完全な実装までを整理します。


コンテナ連携アーキテクチャの設計思想

Hugo から見ると、ローカル環境に存在する通常の asciidoctor コマンドを実行しているように見えます。しかし、実際にはパスの通ったラッパースクリプトが背後で軽量コンテナを呼び出し、標準入出力(stdin / stdout)を中継します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
+-------------------------------------------------------------------------+
| メイン開発環境 (DevContainer / Go 環境)                                 |
|                                                                         |
|   Hugo (静的ビルドプロセス)                                             |
|     │                                                                   |
|     │ (1) asciidoctor コマンドを実行(stdin で .adoc 本文を送信)       |
|     ▼                                                                   |
|   CLI ラッパースクリプト (~/.local/bin/asciidoctor)                     |
|     │                                                                   |
|     │ (2) stdin を一時ファイルに読み切り + flock で排他制御            |
|     │ (3) docker run -i で専用コンテナを透過起動                        |
|     ▼                                                                   |
+-------------------------------------------------------------------------+
                                │
                                │ (Docker ソケット中継)
                                ▼
+-------------------------------------------------------------------------+
| Asciidoctor 専用コンテナ (adoc_converter:latest)                        |
|   Ruby 3.4-slim + Asciidoctor + Rouge                                 |
|     │                                                                   |
|     │ (4) HTML に高速変換して stdout へ出力                             |
|     ▼                                                                   |
+-------------------------------------------------------------------------+

この構成のメリット

  1. メイン環境の汚染ゼロ: メインのコンテナやホスト OS には Ruby も Bundler も一切インストールされません。
  2. 完全なポータビリティ: Asciidoctor のバージョンや依存 Gem は Docker イメージ内に完全に固定されているため、ローカルと CI 間での挙動差が発生しません。

構築手順 ①:専用 Docker イメージの準備

1. Dockerfile と Gemfile の定義

Dockerfile.asciidoctor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
FROM ruby:3.4-slim-bookworm

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /converter
COPY Gemfile* ./
RUN bundle install

ENTRYPOINT ["bundle", "exec", "asciidoctor"]

Gemfile:

1
2
3
4
source 'https://rubygems.org'

gem 'asciidoctor', '~> 2.0'
gem 'rouge', '~> 4.0'

2. イメージのビルド

1
docker build -t adoc_converter:latest -f Dockerfile.asciidoctor .

直面した課題:並行ビルド時の「0 bytes パイプ切断の罠」

素朴なラッパースクリプトとして、以下のような 1 行スクリプトを最初に作成しました。

1
2
3
#!/usr/bin/env bash
# 【失敗例】素朴な中継スクリプト
exec docker run --rm -i adoc_converter:latest "$@"

なぜこのスクリプトは失敗するのか?

Hugo は数百ページにおよぶ記事を Go の Goroutine を用いてミリ秒単位で並行ビルド します。 複数の AsciiDoc 記事が存在する場合、Hugo は同時に何十個もの asciidoctor プロセスを立ち上げます。

その結果、以下の致命的な問題が発生しました:

  1. Docker デーモンの同時実行過負荷: 短時間に数十個の docker run コンテナが同時に立ち上がり、マシンの CPU/メモリを圧迫。
  2. 標準入出力パイプの切断(壊れたパイプ / Broken Pipe): コンテナの起動オーバーヘッド(数十〜数百ミリ秒)を待つ間に、Hugo 側の標準入力送信タイムアウトが発生。パイプが切断され、Hugo に 空データ(0 bytes) が返却されてビルドが壊れる。

解決策:一時ファイル読み切り + flock 排他制御ラッパー

この問題を完全に解決するため、ラッパースクリプトに以下の 3 重の防御機構 を組み込みました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[ Hugo からの標準入力 (stdin) ]
             │
             ▼
【ステップ 1】 ロック取得前に、標準入力を即座に一時ファイルへ「読み切る」
             (Hugo 側の stdin 送信タイムアウトをゼロにする)
             │
             ▼
【ステップ 2】 入力データが 0 bytes(空ファイル)ならコンテナを起動せず即終了
             │
             ▼
【ステップ 3】 docker run の実行部分のみを「flock」で排他制御(直列化)
             (Docker デーモンへの同時起動負荷を抑え、安定して 1 つずつ変換)

完成したラッパースクリプト (~/.local/bin/asciidoctor)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/usr/bin/env bash
set -euo pipefail

# 一時ファイルの生成と終了時の自動クリーンアップ
TMP_INPUT=$(mktemp /tmp/adoc_in.XXXXXX)
TMP_OUTPUT=$(mktemp /tmp/adoc_out.XXXXXX)
trap 'rm -f "$TMP_INPUT" "$TMP_OUTPUT"' EXIT

# 1. Hugo 側のタイムアウトを防ぐため、ロック取得前に標準入力を即座に読み切る
cat > "$TMP_INPUT"

# 2. 入力が 0 bytes の場合はコンテナを起動せず即座に正常終了
if [[ ! -s "$TMP_INPUT" ]]; then
  exit 0
fi

# 3. docker run の実行部分のみを flock で排他制御(直列化)
(
  flock -x 200
  docker run --rm -i \
    -v "$TMP_INPUT:/tmp/input.adoc:ro" \
    --entrypoint bundle \
    adoc_converter:latest \
    exec asciidoctor -o - "$@" /tmp/input.adoc > "$TMP_OUTPUT"
) 200>/tmp/asciidoctor_build.lock

# 4. 変換結果を Hugo へ返却
cat "$TMP_OUTPUT"

実行権限を付与します:

1
chmod +x ~/.local/bin/asciidoctor

構築手順 ②:Hugo のセキュリティ許可設定

Hugo は外部プロセスの無制限な実行を制限しているため、hugo.toml のセキュリティ設定で asciidoctor の実行を明示的に許可します。

1
2
3
4
5
[security]
  enableInlineShortcodes = false
  [security.exec]
    allow = ['^asciidoctor$', '^go$', '^git$']
    osEnv = ['(?i)^(PATH|HOME|USER|DOCKER_HOST)$']

パフォーマンスと運用の検証

この構成により、以下の効果が得られました:

  • ビルドの安定性 100%: 並行ビルド時にどれほど大量の AsciiDoc ファイルが存在しても、パイプ切断や 0 bytes エラーが完全にゼロ化。
  • 高速なビルド: 入力の一時ファイル読み切りと直列化により、1 ページあたりわずか数十ミリ秒で変換が完了。
  • 完全なクリーン環境: メインコンテナのイメージサイズを最小限に保ったまま、Ruby エコシステムの恩恵を享受可能。

まとめ

  • 専用コンテナの切り出し: 外部言語ランタイム(Ruby/Gem 等)をメイン環境に同居させず、Docker コンテナ+CLI ラッパーで透過的に分離。
  • 並行パイプ切断の克服: Hugo の並行ビルド下では、ロック取得前の「stdin 即時読み切り」と「flock による実行排他制御」が必須。
  • ポータブルな資産化: Dockerfile とラッパースクリプトをリポジトリ内に管理することで、誰のマシンでも CI でも同一のビルドを再現。

※ 本記事の構成検討・技術仕様の検証・Hugo による静的ビルド検証・推敲は、AI コーディングエージェントとの自律協働ループによって執筆・検証されています。


参考リンク・情報ソース

目次