概要
ブラウザで開く画面と、コンテナで動くバックエンド。ちょっとしたツールや試作でよくあるこの構成を、Markdown 1ファイルに収めて、読めて、そのまま動く 形にできないかと考えました。
普通に作ると compose ファイル、Dockerfile、サーバーのコード、HTML…とファイルが増えていき、人に渡すにも説明を読むにも一手間かかります。それを1ファイルにまとめてしまおう、という試みです。
- FrontMatter(
---で囲まれた先頭部分)に docker compose の定義 を書く - 本文に 説明とコード を書く(コードブロックに
file=を付けるとファイルになる) - コードブロックからファイルへの展開は コンテナの中だけ で行い、ホストには何も展開しない
という仕組みで、名前は mdfs(Markdown File System)にしました。
- GitHub: https://github.com/uniras/mdfs
- Docker Hub: riafeed/mdfs
完成形
まずはこんな app.md を書きます(抜粋)。
---
name: mdfs-hello
x-open: http://localhost:8000
services:
app:
build:
context: .
args:
MDFS_OUT: /app
dockerfile_inline: |
FROM riafeed/mdfs:2.4.3
RUN pip install --no-cache-dir -r requirements.txt
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
ports:
- "8000:8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/hello')"]
interval: 2s
retries: 15
---
# mdfs hello
`/api/hello` がメッセージを返し、それ以外のパスは `static/` を配信する。
```python {.python file=server.py}
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
@app.get("/api/hello")
def hello():
return {"message": "Hello from FastAPI"}
app.mount("/", StaticFiles(directory="static", html=True), name="static")
```
```text {.text file=requirements.txt}
fastapi
uvicorn
```
```html {.html file=static/index.html}
<!doctype html>
<button id="btn">APIを呼ぶ</button>
<div id="out"></div>
<script>
document.getElementById("btn").onclick = async () => {
const res = await fetch("/api/hello");
document.getElementById("out").textContent = (await res.json()).message;
};
</script>
```
起動はこれだけです。
./mdrun.sh app.md # Windows は mdrun.cmd app.md(ドラッグ&ドロップでも可)
ビルド → 起動 → healthcheck が通るまで待機 → x-open の URL をブラウザで開く、まで自動で進みます。止めるときは ./mdrun.sh app.md down です。
仕組み
役割分担はこうなっています。
| どこで | 何をするか |
|---|---|
| ホスト(mdrun) | FrontMatter を切り出して docker compose に標準入力で渡すだけ |
| docker compose | FrontMatter をそのまま compose ファイルとして解釈 |
| コンテナ(ビルド中) |
riafeed/mdfs の ONBUILD が .md をコピーし、Entangled でコードブロックをファイルに展開 |
ホストが解析するのは「1行目の --- から次の --- まで」だけです。awk 1行で済みます。
awk 'NR==1&&/^---$/{f=1;next} f&&/^---$/{exit} f' app.md \
| docker compose -f - --project-directory . up --build --wait
mdrun は、これに少し便利機能を足しただけのスクリプトです。
riafeed/mdfs イメージ
python:3.13-slim に Entangled を入れ、ONBUILD で展開する処理を仕込んだだけのイメージです。Dockerfile の要の部分はこれだけです。
FROM python:3.13-slim
ARG ENTANGLED_VERSION=2.4.3
RUN pip install --no-cache-dir "entangled-cli==${ENTANGLED_VERSION}"
# (/usr/local/share/mdfs/entangled.toml に設定を同梱。後述)
ONBUILD ARG MDFS_SRC=app.md
ONBUILD ARG MDFS_OUT=/out
ONBUILD COPY ${MDFS_SRC} ${MDFS_OUT}/
ONBUILD WORKDIR ${MDFS_OUT}
ONBUILD RUN cp /usr/local/share/mdfs/entangled.toml . \
&& entangled tangle -a naked \
&& rm -rf .entangled entangled.toml \
&& chmod -R a+rX .
FROM riafeed/mdfs:2.4.3 と書いた時点で、app.md の中身が MDFS_OUT に展開されます。
file= のパスは MDFS_OUT からの相対パスなので、アプリの最終的なファイル配置をそのまま書く 感覚です。
Python のアプリならこのイメージをそのまま実行用に使えます。ほかの言語なら、マルチステージにしてディレクトリごとコピーします。
FROM riafeed/mdfs:2.4.3 AS src
FROM node:22-slim
COPY --from=src /out /app
設計の経緯
ここが一番書きたかったところです。何度か方針を変えています。
1. なぜ FrontMatter Markdown なのか
最初は「ホスト用シェルスクリプト + Dockerfile 役のスクリプト + compose 役の設定」を1ファイルにまとめる案でした。ただ、これだと人間が読めるファイルになりません。
FrontMatter Markdown なら、
- compose はもともと YAML なので、FrontMatter にそのまま書ける
- 本文は GitHub やエディタでそのまま読めるドキュメントになる(README を兼ねる)
- VS Code の標準機能で、FrontMatter は YAML として、コードブロックは各言語としてハイライトされる
という利点があります。
YAML のキーの1つに Markdown を入れる案も考えました。しかし、ブロックスカラーはインデントを 0 にできないうえ、中身がただの文字列になってハイライトも効かなくなるのでやめました。
2. なぜコンテナの中で展開するのか
コードブロックをファイルに展開する処理をホストでやると、展開されたファイルがホストに散らかって、後始末が必要になります。
コンテナの中で展開すれば目に見えず、コンテナやイメージごと消せば終わりです。
そして、ホストで展開しないなら、ホストでの解析は最小限にしたい。ここで FrontMatter が効いてきます。「--- の間を切り出す」だけなら awk 1行で済み、それ以外の解析はすべてコンテナ側に押し込めます。
逆に言えば、ホストで展開してよいなら FrontMatter にこだわる理由はなくなります。この2つはセットの設計です。
3. なぜ Entangled なのか
最初は Python で展開スクリプトを自作していました。しかし、似たことをする既存ツールを探したところ、オランダの国立研究機関 Netherlands eScience Center の Entangled が見つかりました。文芸的プログラミング用のツールで、PyPI で継続的に更新されています(2026年10月現在)。
自作するより信頼できるので、乗り換えました。結果的に、Dockerfile 1ファイルで済むようになりました。
4. 書き戻し(stitch)は非対応にした
Entangled には、展開したファイルを編集して Markdown に書き戻す stitch という機能があります。ただ、この構成では
- ホストの
.mdを直接編集するのが前提 - 展開はコンテナ内の使い捨て
なので、書き戻しは使いません。.md が唯一の正本 です。
注:これは、コンテナに展開する時点の話で、ホスト環境において独自に書き戻し機能を使うことを制限するわけではありません。
ハマりどころ
エディタでハイライトさせたい
Entangled 標準の書き方は ``` {.python file=server.py}``` (Pandoc由来の記法)です。しかし、VS Code はこれを言語名として認識しないので、ハイライトされません。(Python等の一部の言語では特別扱いでハイライトするようですが)
そこで、Entangled の markers 設定を変えて、```python {.python file=server.py} ``` のように 波括弧の前に言語名を書いてもよい ようにしました。
version = "2.0"
[markers]
open = '^(?P<indent>\s*)```[^\s{`]*\s*\{(?P<properties>[^{}]*)\}\s*$'
close = '^(?P<indent>\s*)```\s*$'
これをイメージに同梱し、展開時に .md の隣にコピーしています(Entangled はカレントディレクトリの entangled.toml しか読まないため)。
Entangled が知らない言語
html や dockerfile などは Entangled の組み込み言語にありません。とはいえ、-a naked(注釈コメントなし)で展開する分には警告が出るだけで、ちゃんと展開されます。
言語ごとの設定を足していくとキリがないので、追加はしていません。
展開されたファイルが 600
Entangled はファイルを 600 で書き出します。後段の非 root ユーザーが読めなくなるので、chmod -R a+rX で 644 / 755 にそろえています。
ONBUILD の既定値を ENV にすると上書きできない
使う側が build.args で MDFS_SRC を渡せるようにしたかったのですが、ベースイメージで同名の ENV を設定すると、ENV が ARG より優先されて上書きできなくなります。既定値は ONBUILD ARG 側に持たせました。
compose はトップレベルの未知のキーを許さない
Entangled は FrontMatter に entangled: キーがあると、それを その文書専用の設定 として読んでくれます。これを使えば、素の Entangled でもマーカー設定込みで .md 1つで完結します。
ところが compose はトップレベルに未知のキー(x- 以外)があるとエラーにします。
そこで、ホスト側で FrontMatter を切り出した後だけ entangled: を x-entangled: に書き換えて compose に渡すことにしました。
awk '...' app.md | sed 's/^entangled:/x-entangled:/' | docker compose -f - ...
コンテナに届くのは書き換え前の .md なので、Entangled はそのまま entangled: を読めます。万が一変な置き換えが起きても、影響は compose に渡す YAML の中だけで済みます。
起動前にブラウザが開く
素朴に「up -d → ブラウザを開く」とすると、サーバーが起動しきる前にブラウザが開いてしまいます。
compose の up --wait と healthcheck を組み合わせれば、healthy になるまでコマンドが戻らないので、スクリプト側で待つ処理を書く必要はありません。
Windows のバッチファイルは中身を PowerShell に
mdrun.cmd は、ダブルクリックやドラッグ&ドロップで使えるようにバッチファイルにしていますが、中身はほぼ PowerShell です。
<# : batch part
@echo off
setlocal
set "MDRUN_SELF=%~f0"
set "MDRUN_ARGS=%*"
powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-Expression (Get-Content -LiteralPath $env:MDRUN_SELF -Raw -Encoding UTF8)"
exit /b %errorlevel%
#>
# ここから下は PowerShell
cmd は <# : の行をラベル扱いで読み飛ばし、自分自身を PowerShell で実行し直します。PowerShell からは <# ... #> がブロックコメントに見えるので、バッチ部分は無視されます。
mdrun の使い方
mdrun.sh app.md # up(ビルド・起動・待機・ブラウザで開く)
mdrun.sh app.md open # ブラウザで開くだけ
mdrun.sh app.md down # 停止
mdrun.sh app.md logs -f app # それ以外は docker compose にそのまま渡す
mdrun.sh app.md --no-open # 起動後にブラウザを開かない(MDRUN_NO_OPEN=1 でも可)
app.md 以外のファイル名でも動くように、mdrun は .md のファイル名を環境変数 MDFS_SRC に入れて compose に渡します。FrontMatter 側で受け取ってください。
build:
args:
MDFS_SRC: ${MDFS_SRC:-app.md}
注意点
-
.mdを1文字でも変えると展開以降のビルドキャッシュが無効になるので、pip installなども毎回走ります -
.mdを実行するということは、そこに書かれた compose 定義とコードを実行するということです。信頼できないファイルは実行しないでください
まとめ
- FrontMatter = compose、本文 = ドキュメント + コード、という形で Markdown 1ファイルをアプリ にできた
- ホストは FrontMatter を切り出すだけで、展開はコンテナの中だけ
- 展開には既存ツールの Entangled を使い、イメージに
ONBUILDで仕込んだ
説明書きがそのまま動くアプリになっている、という感覚が気に入っています。
興味があれば触ってみてください。