きっかけは、常に 500 を返す API だった
プロジェクト管理に OpenProject を使っている。タスクや案件の記録をここに集約し、外部からは API v3 を通じて読み書きする。そのうち、ワークパッケージ同士を関連付ける POST /api/v3/work_packages/{id}/relations が、常に HTTP 500 を返していた。
最初は呼び出し方の問題を疑った。パラメータの綴り、認証、権限。どれも正しい。それでも 500 が返る。500 は「サーバー側で想定外のことが起きた」という意味の応答で、原因を教えてくれない。だから、こちらから原因を掘りに行くしかない。
調べていくと、原因はリクエストボディに _links.from が無い場合の処理にあった。URL パス上の {id} を関連元として補完しない設計のため、Relation#from が nil のまま検証処理へ渡り、最終的に NoMethodError: undefined method 'id' for nil:NilClass を経由して 500 になる。
Rails のバックトレースを取ると、経路は行レベルではっきりした。
POST /api/v3/work_packages/568/relations
→ API::V3::WorkPackages::WorkPackageRelationsAPI#post
(/app/lib/api/v3/work_packages/work_package_relations_api.rb:54-59)
→ API::V3::Utilities::Endpoints::Create(model: Relation)
· params_source = request.request_body(URL の {id} は含まれない)
· default_instance_generator = ->(_params) { } ※nil を返す
→ Relations::CreateService#perform
→ Relations::BaseService#update_relation(validate_and_save)
→ Relations::CreateContract → Relations::BaseContract#validate_nodes_relatable
WorkPackage.relatable(model.from, model.relation_type, ignored_relation: model)
※ model.from が nil(attributes に from_id が無いため)
→ WorkPackage::Scopes::Relatable#relatable
→ relatable_ensure_single_relation(ignored_relation, work_package)
work_package.id ← work_package が nil のため NoMethodError
要するに、URL のパスに書いた {id} を関連元として使わない、という設計である。呼び出し側で from を明示すれば、実運用は回避できる。だが待ってほしい。これは上流のバージョンでは既に修正されている種類の不具合だった。
回避策を積むより先に、確かめるべきことがある。今動いている OpenProject は、いったいどのバージョンなのか。
これが、「最新を使っているつもり」が音を立てて崩れる瞬間だった。
実測した現状 — 13.4.1
まず現在の構成を実機で確認した。推定ではなく、すべてコマンドの出力である。
| 項目 | 実測値 |
|---|---|
| OpenProject バージョン | 13.4.1(/app/lib/open_project/version.rb の MAJOR=13 / MINOR=4 / PATCH=1) |
| アプリイメージ | openproject/community:13(3.01GB) |
| データベース | PostgreSQL 13.23 |
| DB サイズ | 30MB / テーブル数 136 |
| 稼働ホスト | common-docker-host01(192.168.1.102 / Debian 13 / 4 vCPU / 8GB RAM / ディスク空き 70GB) |
| Docker / Compose | Docker 29.8.1 / Compose v5.5.1 |
| 構成管理 | /opt/openproject/docker-compose.yml の1ファイルのみ。git 管理外、.env なし、override なし |
13.4.1 は 2024年3月のリリースである。日々のタスク管理には十分に動いているが、決して「最新」ではない。
そしてこの構成には、後で効いてくる問題がもう一つあった。compose ファイルが実質的に手作業で維持されており、バージョンもイメージ名も、どこにも宣言的に書かれていない。つまり、上げるときの手順が残っていない。動いていることと、管理されていることは別なのだと、この時点で思い知らされた。
「動いている」は「正しい」ではない。「動いている」は「最新」でもない。この二つを取り違えると、ある日突然、原因の分からない不具合として跳ね返ってくる。
「最新」はどこにあるのか — 17.8.0 と三つの壁
次に、最新版が何かを調べた。GitHub のリリースを確認すると、最新の安定版は v17.8.0(2026年9月2日) だった。13.4.1 から数えて 4 メジャー分先である。単純に数えれば、3年半分の更新が溜まっていることになる。
ここから、単純にバージョン番号を書き換えるだけでは終わらない理由が、三つ順番に見つかった。
壁その1 — イメージの配布元が変わっていた
Docker Hub のタグを実際に問い合わせると、openproject/community の最終タグは 13.4.1(2024年3月26日) であり、community:14 から community:17 は存在しなかった(404)。14 以降はリポジトリ自体が openproject/openproject へ移行しており、そちらには 14 / 15 / 16 / 17 / 17.8 / 17.8.0 のタグが存在することを manifest の取得で確認した。
つまり、compose の image: 行を openproject/community:17 に変えても、そのイメージは永遠に落ちてこない。「タグを変えたのに pull が通らない」という、原因の分かりにくい失敗に時間を溶かすところだった。バージョン番号を書き換える前に、タグの存在を確認する。当たり前のことだが、当たり前にやらないと嵌まる。
壁その2 — メジャーは1つずつしか上げられない
公式ドキュメントには、「X から X+1 への移行はサポートする。X+2 を直接行うことはできない」と明記されている。13 から 17 は、13→14→15→16→17 と数えて 4 メジャー分を跨ぐ。素朴な一発アップグレードは非サポートである。
壁その3 — PostgreSQL 16 以上が必須になっていた
これが一番効いた。公式のシステム要件に、OpenProject 16.0.0 以降は PostgreSQL 16 以上を公式サポートする、と書かれている。今動いている PostgreSQL 13 は、16 以降ではサポート外になる。
したがって、順序を間違えると詰む。「アプリを上げきって、最後に DB を上げる」という素直な順序は取れない。アプリが 16 に到達した時点で、DB が要件を満たさず止まってしまうからである。
三つの壁は、いずれも「知らなければ踏む」種類のものだった。そして三つ目は、単なる障害ではなく作業の順序そのものを決める制約だった。ここが今回の設計上の争点になった。
戦略 — 器である DB を先に用意する
ここで発想を切り替えた。アプリとデータベースを別々の問題として捉え、「器」を先に作ることにした。
PostgreSQL 17 を土台として先に用意し、その上でアプリを 13 → 14 → 15 → 16 → 17 と段階的に移行する。
アプリの段階移行には、公式が提供している bin/migrate を使う。これは OpenProject 10.x 以降の SQL ダンプを受け取り、内部で古いバージョンのイメージを順番に取得しながらマイグレーションを適用し、最新版相当のスキーマに到達したダンプを出力してくれるツールである。メジャーを跨ぐ移行を、公式の想定手順のまま安全に進められる。
このツールの価値は、「アプリのイメージを1つずつ立ててマイグレーションを流す」という作業を、DB ダンプに対して1回のパスでやってくれる点にある。手作業で 13→14→15→16→17 のコンテナを順に立てては DB を移す、という手順を踏むと、途中で失敗したときに「今どのバージョンのスキーマにいるのか」が分からなくなる。公式ツールに任せれば、その状態管理を自分で持たなくて済む。
DB そのものの移行は、PG13 から PG17 へダンプとリストアで一発行う。わざわざ 13→14→…→17 と5分割などしない。手数とリスクを増やすだけだからだ。中間バージョンの PostgreSQL を用意しても、得られる保証は増えない。
「アプリは段階、DB は一括」という非対称な戦略が、ここでの答えだった。
この非対称を選べたのは、壁その3を先に見つけていたからである。逆に「アプリを先に上げきって、最後に DB をまとめて上げる」という素直な順序を選んでいたら、アプリが 16 に到達した時点で DB 要件を満たせず、そこで作業が止まっていた。制約を先に把握することは、手順を最適化する以上に、「詰まない順序を選ぶ」という意味を持っている。
Blue-Green という考え方
この作業には、もう一つの方針を重ねた。Blue-Green デプロイである。
現在動いている環境(Blue)を止めるのではなく、新しい環境(Green)を別ポートで並行して立ち上げ、検証が済んでからトラフィックの向き先だけを切り替える。切り替えは数秒から数分で終わり、何かあれば向き先を戻すだけで即座に元へ復帰できる。
ダウンタイムを短くするというより、「いつでも戻せる」という安心を買う考え方である。今回のように、コンテナの差し替えとデータベースのマイグレーションを伴う作業では、この性質が効く。作業中も旧環境は稼働し続けるので、見込みでは全体で2〜3時間、実質のダウンタイムは切替時の数分に収まる。
Blue-Green の利点は、切り替えが「データの移行」と「トラフィックの移動」を分離できることにある。データの移行は時間がかかるし、途中で失敗する可能性もある。トラフィックの移動は、設定を1行変えるだけの操作で、失敗してもすぐ戻せる。時間がかかって危うい工程と、一瞬で戻せる工程を分けておけば、危うい工程を利用者に見えないところで済ませられる。
逆に言えば、Blue-Green は「新しい環境が正しく動くこと」を保証してくれるわけではない。保証してくれるのは「間違っていたときに元に戻れること」だけである。だから検証(Phase 3)は別途きちんとやる必要がある。ここを混同すると、切り替えたはいいが壊れている、という最悪の形になる。
実施
実際の作業は、大きく次の流れで進めた。
Phase 0 — 事前準備とバックアップ
何より先に、戻れる状態を作った。ロールバックの最終保証として仮想マシンのスナップショットを取得し、そのうえで論理バックアップとして pg_dump を取得した(DB は 30MB と小さく、数秒で終わる)。ボリューム(openproject_pgdata / openproject_opdata)も tar で退避し、compose ファイルも日付付きのコピーを残した。
バックアップは「念のため」ではなく、作業を大胆に進めるための投資である。戻れると分かっていれば、判断に迷う時間が減る。
Phase 1 — DB スキーマの最新化(PG17 を先行)
旧環境のデータベースから SQL ダンプを取得し、bin/migrate に通して、13 相当から 17 相当まで段階的にマイグレーションしたダンプ(-migrated.sql.gz)を生成した。この工程はオフラインで行い、旧環境はそのまま稼働させておく。所要はイメージの pull を含めて数十分から1時間程度。DB 本体が 30MB しかないので、実処理そのものは軽い。
ここで重要なのは、この時点で新環境の土台が PostgreSQL 17 に確定することである。以降アプリを 14、15、16 と上げていっても、DB 要件で止まらない。
Phase 2 — 新環境の並行構築
新環境は openproject/openproject:17 と postgres:17 の組み合わせで構築した。既存のボリュームは上書きせず、pgdata17 と opdata17 を新規に用意している。移行前の資産に手を付けず、新しい器の上にデータを載せ替える進め方である。
- 正となる compose をワークスペースに作成した(
docker-compose.green.yml) - 新規ボリューム
pgdata17を作り、変換済みダンプをリストアした - 添付・アップロード類(assets)を新環境へコピーした。実測で 90.5MB と軽量だった
- 新環境を 別ポート 8081 で起動し、旧環境(8080)と並行稼働させた
- Rails のマイグレーションと seed の状態をログで確認した
環境変数は現行のものを移植した。OPENPROJECT_HOST__NAME、OPENPROJECT_HTTPS、OPENPROJECT_HSTS=false、OPENPROJECT_DEFAULT_LANGUAGE=ja、TZ=Asia/Tokyo、SECRET_KEY_BASE、OPENPROJECT_APIV3__ENABLE__BASIC__AUTH などである。ここを1つ落とすと、認証が通らない、日本語にならない、時刻がずれる、といった形で後から効いてくる。移行作業の地味な勘所である。
新環境の起動は 2026年9月22日の夜。旧環境は community:13 のまま 8080 で動き続けており、作業中に既存の利用が止まることはなかった。
Phase 3 — 切替前の検証
切り替える前に、新環境が正しいことを確かめた。
- 新環境の Web / API が HTTP 200 を返すこと
- ワークパッケージ件数・プロジェクト数・ユーザー数・添付ファイル件数が旧環境と一致すること
- そもそもの発端だった API が解消していること
件数の突き合わせは、見た目の確認より効く。画面が表示されていても、データが半分しか入っていないことはあり得るからだ。
Phase 4 — 切替と後始末
検証を通過したうえで、リバースプロキシ(Cloudflare トンネル)の向き先を 8080 から 8081 へ切り替えた。切替は cloudflared の ingress 設定変更で行い、切替前後で https://openproject.cto-server.jp が HTTP 200 を返すことを実測している。
旧環境は停止するが、削除はしない。ボリュームごと当面残し、ロールバックの余地を確保しておく。
最後に台帳(OPENPROJECT_LEDGER.md)を v17 構成へ更新した。バージョン、イメージ名、DB バージョン、ボリューム名。この更新を忘れると、次の担当者(未来の自分を含む)が同じ調査をやり直すことになる。手順が残っていなかったことに起因する今回の一件を、同じ形で繰り返さないための記録である。
結果 — 上手く行ったのか
結論から書く。成功した。
新環境のバージョンを確認すると、MAJOR=17 / MINOR=8 / PATCH=0。目標どおり 17.8.0 が動いている。Cloudflare トンネルの ingress も openproject.cto-server.jp、openproject.kodama-system.com の両方を localhost:8081 へ向け直し、公開アクセスは新環境に切り替わった。
そして、そもそもの発端だった API も解消した。未使用のワークパッケージの組で POST /api/v3/relations を叩くと HTTP 201 が返り、後始末の DELETE は 204 で通った。13.4.1 では 500 だった操作が、17.8.0 では正しく成功する。これが「上げてよかった」と言い切れる一番の証拠である。
データも無事だった。移行後、既存のプロジェクト・ワークパッケージ・ウィキページはそのまま参照できる。件数の突き合わせでも、欠落は見つからなかった。
実質ダウンタイムは、トンネル切替の数分に収まった。作業の大半は旧環境を動かしたまま進められた。
付随して見つかったもの
アップグレードは成功したが、そこで終わりではなかった。新しい環境で、いくつかの「前の環境では見えなかった問題」が顔を出した。
Wikis タブが消えていた
ひとつは、Wikis タブが表示されないという問題である。原因を追うと、17 へのマイグレーションでウィキの管理方式が変わっていた。enabled_modules の wiki が廃止され、wikis.enabled カラムと wiki_providers テーブルで管理される方式に移行している。ところが新環境では wiki_providers が 0 件、つまり内部プロバイダが未登録のままだった。そのためウィキ機能全体が「無効」と判定され、タブごと消えていた。
幸い、データは健在だった。wikis は 12 件すべて有効、wiki_pages は 7 件。損失はない。公式のシーダーと同じロジックで内部プロバイダを冪等に登録し直すと、wikisAvailable=true が返り、ブラウザでもウィキページが描画されるようになった。見た目が消えていただけで、中身は無事だったのは不幸中の幸いである。
この対処は Playbook として残した。既存かつ有効なら何もしない(changed=0)、未作成なら追加、差分があれば更新、という冪等な作りにしてある。何度実行しても結果が変わらない形にしておけば、手順として安心して残せる。
関連 API の偽陰性
もうひとつは、関連 API の読み出しで生じた偽陰性である。旧来のエンドポイント GET /work_packages/{id}/relations は、17 では HTTP 308(恒久リダイレクト) を返すようになっていた。これを考慮せずに呼び続けると、常に 0 件が返る。つまり「関連が設定されていない」という誤った答えを、正常応答の顔で返していた。エラーが出ないだけに、気づきにくい種類の不具合である。
新しいエンドポイント GET /relations?filters=... へ切り替えると、実データ(1件、3件)が正しく返るようになり、偽陰性は解消した。
バージョンを上げると、動くようになるだけでなく、見え方の前提も変わる。この二件は、その典型例だった。いずれも記録として起票し、対応のうえ完了まで持っていった。
並行構築という考え方
今回の Blue-Green は、単発の思いつきではなく、自分の中で馴染んだ考え方の延長にある。
私はいわゆる富士通育ちで、本番環境の手前に ST 環境、IT 環境、PG 環境を構築する、という進め方が割と当たり前に身についている。本番に直接手を入れるのではなく、手前の環境で確かめてから上げる。その発想自体は、規模が変わっても本質は同じだ。
いま自分の周りでは、やりすぎると構成が複雑になりすぎるので、本番と開発、せいぜいそのくらいの分け方にとどめている。たとえば API の基盤も本番と開発に分けて運用している。環境を増やせば安全性は上がるが、その分だけ面倒も増える。増やした環境の面倒を誰が見るのか、という話になる。
今回は「更改」だったので、Blue-Green という形を取った。既存を止めずに新しいものを作り、よいと確認できたら切り替える。そして、今後もこの「並行構築」のアプローチは、他のツールにも積極的に取り入れていきたいと考えている。戻せる状態を作ってから進む、という一点は、環境の数に関係なく効くからだ。
学び
- 「最新を使っているはず」は、検証しないと分からない。 今回は、ある API の不具合調査という脇道から、本体が3年近く前のバージョンであることが露呈した。動いていることと、最新であることは、まったく別の話である。
- 大きなバージョンアップは、順序の設計でリスクが変わる。 今回は「DB の要件が上がった」という制約が先に見つかったことで、「器を先に、中身を段階的に」という順序を選べた。逆の順序で突っ込んでいたら、途中で要件を満たせず止まっていた。
- 配布元の変更は、静かに牙をむく。 イメージ名が変わっていたことに気づかなければ、pull が通らず、原因の分からない失敗に時間を溶かしていた。
- 新しくするときは、見え方も変わる。 ウィキの管理方式、関連 API のエンドポイント。どちらも、上げただけでは気づけず、実際に使って確かめて初めて分かった。
- 戻せる状態を作ってから進む。 Blue-Green で旧環境を残しておけば、新しい環境で問題が出ても、向き先を戻すだけで日常に戻れる。この安心感は、作業のスピードにもそのまま効く。
- 手順を残すこと自体が対策になる。 今回は構成が手作業で維持され、上げ方の手順がどこにもなかったことから調査が始まった。台帳と compose を正として整備したことで、次に同じことをするときの入口ができる。
まとめ
最新を使うということは、単に数字を上げるのではなく、その周辺技術(DB、通信、配布元、移行戦略)の全体像を把握することだ。
「最新を使っているつもり」を、実際に確かめる。地味な作業だが、今回ばかりはその価値がはっきりと出た。13.4.1 から 17.8.0 へ。三つの壁を順番に越え、4 メジャー分を跨いで、ようやく「最新」と言える状態になった。この検証を経て、私たちのインフラは一つ上のレベルへ到達したと言える。