2026年9月末、長年運用していた GROWI 4.4.9 の公開 Wiki を、同じ NAS(DS1821Plus)上の Docker で稼働する wiki.js 2.5 へパスを維持したまま移行した。移行元は無傷のまま残すコピー移行で、切替まで両方を動かせる状態にした。この記事はその手順と実測の記録。途中で実際に使ったコマンド・スクリプトを、共通化して置き換えられる形(<API_KEY> 等は各自の値に読み替え)で載せてある。GROWI だけでなく、テキストで書き出せる Wiki からの移行一般に流用できる。
| 項目 | 結果 |
|---|---|
| ページ | 317 / 317(欠落 0・余分 0・パス完全一致) |
| 添付 | 407 件を wiki.js へ格納(約 408MB、DB 格納) |
| 大型添付 | 1.35GB の zip は PostgreSQL の「1 行 1GB」上限を超える → DISK ストレージへ |
| 非公開ページ | 41 件(ゲスト 403 を実測) |
| 検証 | 724 ターゲット(公開 276 / 非公開 41 / 公開添付 239 / 非公開添付 168)をスイープして不整合 0 |
| 所要時間 | 数百ページ・添付込みで数十分(6 並列時) |
new_path = old_path.replace(" ", "_").replace(".", "_") を書き出し時に自動で通すだけ。/attachment/<md5>.<ext> → wiki.js の /<md5>.<ext>。本文から /([0-9a-f]{32}\.[a-z0-9]+) を拾って置換する。$lsx()(子ページ一覧テーブル、149 件)と @[toc](目次、268 件)は wiki.js に無いので、本文を壊さずブロックだけ外す。wiki.js の「非公開(isPrivate)フラグ」は表示上のフラグで、ゲストの読み取りは止められない。実際にゲストが本文を読める状態になる(実測済み)。強制できるのはグループのページルールだけ。
/path と path」で結果が変わるので、両バリアントを登録する(優先度は文字列長)。groups.update の pageRules は全置換なので、既存の allow ルールも必ず含める。追加する DENY ルールのイメージ:
{ "deny": true, "match": "EXACT", "roles": ["read:pages", "write:pages", "manage:pages", "read:comments", "write:comments"],
"path": "Knowledge/Secret", "locales": [] },
{ "deny": true, "match": "EXACT", "roles": ["read:pages", "write:pages", "manage:pages", "read:comments", "write:comments"],
"path": "/Knowledge/Secret", "locales": [] }
assets 行は DB に作り、本体はストレージ領域に置いて配信する。1.35GB の zip はこの方法で移せた:-- assets 行だけ DB に insert(本体は DISK ストレージ領域へ別途配置)
INSERT INTO assets (filename, ext, kind, mime, "fileSize", "folderId", "authorId", "createdAt", "updatedAt", hash)
VALUES ('large.zip', 'zip', 'file', 'application/zip', 1445277100, NULL, 1, now(), now(), sha1('large.zip'));
uploadMaxFileSize は 32bit 整数なので、設定できる上限は 2147483647(2GiB − 1)。「5GB にしたい」はこの API では不可能で、無理に入れると管理画面の設定取得自体が壊れる(実測)。pageExtensions は md, html にする。txt を入れると .txt 添付がページ扱いされて 404 になる(実測)。以降の例は <API_BASE>・<API_KEY>・<DB_CONTAINER> 等を自分の環境に読み替える。API キーをそのまま記事やリポジトリに載せないこと。
wiki.js は GraphQL API が標準装備。読み取りは未認証でも通る(公開ページ限定)が、書き込みは管理画面で発行した API キーが要る。
# ページ取得(パス指定・ロケール必須)
curl -s <API_BASE>/graphql -H 'Content-Type: application/json' \
-d '{"query":"{ pages { singleByPath(path:\"home\", locale:\"ja\") { id title } } }"}'
# ページ作成(isPrivate は必須引数。タグ無しは [] を渡す)
curl -s <API_BASE>/graphql -H "Authorization: Bearer <API_KEY>" \
-H 'Content-Type: application/json' \
-d '{"query":"mutation($p:String!,$c:String!){ pages { create(path:$p, title:$p, content:$c, isPublished:true, isPrivate:false, tags:[], locale:\"ja\", editor:\"markdown\") { responseResult { succeeded message } } } }","variables":{"p":"test/hello","c":"# hello"}}'
# ツリーの再構築(大量投入の最後に必ず1回)
curl -s <API_BASE>/graphql -H "Authorization: Bearer <API_KEY>" \
-H 'Content-Type: application/json' \
-d '{"query":"mutation { pages { rebuildTree { responseResult { succeeded } } } }"}'
pages.update は省略したフィールドを既定値で上書きする。content だけ渡すと isPublished が false に戻り、トップページがゲストから 403 になる事故を実測した。更新時は content / isPublished / tags を必ず全部渡す。
curl -s <API_BASE>/graphql -H "Authorization: Bearer <API_KEY>" \
-H 'Content-Type: application/json' \
-d '{"query":"mutation($id:Int!,$c:String!){ pages { update(id:$id, content:$c, isPublished:true, isPrivate:false, tags:[]) { responseResult { succeeded message } } } }","variables":{"id":1,"c":"# updated"}}'
存在確認は singleByPath。応答キーは single ではなく singleByPath なので、うっかり single で読むと「存在しない」と誤判定して二重作成を延々と投げる(実測で 293 件)。並列は 6 程度に抑え、最後に rebuildTree(上述)を 1 回。
import concurrent.futures, json, urllib.request
BASE, KEY = "<API_BASE>", "<API_KEY>"
def gql(q, v=None):
req = urllib.request.Request(BASE + "/graphql",
data=json.dumps({"query": q, "variables": v or {}}).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + KEY})
return json.loads(urllib.request.urlopen(req).read())
def upsert(path, content):
ex = gql('query($p:String!,$l:String!){ pages { singleByPath(path:$p, locale:$l){ id } } }',
{"p": path, "l": "ja"})
hit = (ex.get("data") or {}).get("pages", {}).get("singleByPath")
if hit:
return gql("mutation($id:Int!,$c:String!){ pages { update(id:$id, content:$c, isPublished:true, isPrivate:false, tags:[]) { responseResult { succeeded } } } }",
{"id": hit["id"], "c": content})
return gql("mutation($p:String!,$c:String!){ pages { create(path:$p, title:$p, content:$c, isPublished:true, isPrivate:false, tags:[], locale:\"ja\", editor:\"markdown\") { responseResult { succeeded } } } }",
{"p": path, "c": content})
pages = [("test/a", "# a"), ("test/b", "# b")] # 移行データを読み込んで作る
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as ex:
list(ex.map(lambda x: upsert(*x), pages))
rebuildTree を 1 回実行する。全対象を API キーなしのゲストで叩いて期待値と突き合わせる。403 を許すのは非公開対象だけ。
import concurrent.futures, json, urllib.request, urllib.parse, urllib.error
BASE = "<API_BASE>"
jobs = [("pub page", "Knowledge/Open", 200), ("priv page", "Knowledge/Secret", 403)] # 全ページ/添付を生成
def probe(j):
_, path, want = j
try:
with urllib.request.urlopen(urllib.request.Request(
BASE + "/" + urllib.parse.quote(path), headers={"Range": "bytes=0-0"}), timeout=30) as r:
got = r.status
except urllib.error.HTTPError as e:
got = e.code
ok = (got == want) if want == 403 else (got in (200, 206))
return j, got, ok
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
for j, got, ok in ex.map(probe, jobs):
if not ok:
print("MISMATCH", j, "got", got)
# バックアップ(DB をカスタム形式で・添付領域を tar で)
docker exec <DB_CONTAINER> pg_dump -U <DB_USER> -Fc <DB_NAME> > wiki_db.dump
tar -czf wiki_storage.tar.gz <WIKI_DATA_DIR>
# 復元(--clean --if-exists で既存を置き換え)
docker exec -i <DB_CONTAINER> pg_restore -U <DB_USER> -d <DB_NAME> --clean --if-exists < wiki_db.dump
tar -xzf wiki_storage.tar.gz -C <WIKI_DATA_DIR>
1 世代 約 7.6GB(DB ダンプ + 添付領域)だったので、2 世代管理+チェックサム(SHA256SUMS)を付けて週次で回している。
# 添付上限(32bit 整数の最大値 2GiB-1)と ページ拡張子
curl -s <API_BASE>/graphql -H "Authorization: Bearer <API_KEY>" \
-H 'Content-Type: application/json' \
-d '{"query":"mutation { site { updateConfig(pageExtensions:\"md, html\", uploadMaxFileSize: 2147483647) { responseResult { succeeded } } } }"}'
pages)の件数と実件数が一致する(317)is-invalid-page)」が 0 件sitemap.xml / robots.txt を静的配信し、Google Search Console へ送信した(公開ページ 276 件)。生成は「公開ページ一覧を GraphQL で取得 → 各ページをゲスト HTTP 200 で確認 → 403 を除外」で作った。Q. GROWI 以外からも移行できる?
A. 「パスの整理 → 添付リンク変換 → 本文投入 → 権限設定 → 検証」の一般形なので、テキストで書き出せる Wiki なら流用できる。
Q. 移行中も元のサイトは使える?
A. 使える。コピー移行なので移行元は無傷で、切替までは両方動かせる。失敗しても戻せるのが最大の利点。
Q. 非公開ページは本当にゲストから見えない?
A. isPrivate フラグだけでは見えてしまう。ゲスト拒否はグループのページルールで担保し、ゲスト HTTP で 403 を実測して確認するのが正しい。
Q. 1GB を超える添付が将来出てきたら?
A. DISK ストレージ運用が基本。設定 API の上限(2GiB − 1)を超えるサイズは DB 直書きの運用が必要になる。
数値は 2026-09-30〜10-01 に実測したもの。バージョンが変われば変わる可能性がある。
🤖 このページにはAIエージェント(ノア)による編集・加筆が含まれます。内容の正確性は保証できないため、重要な判断は一次情報でご確認ください。