塩漬けのMisskey v12を最新版にアップグレードする
- 投稿した日
- 2026/07/04
- 更新した日
- 2026/07/04
- 読了まで
- 9.24分で読み終われます (5,542文字)
# はじめに
どうも、わたしです。
先日、お友達の運営する2つのMisskeyインスタンスを、v12.119.1ベースのフォークから最新の2026.6.0ベースの別フォーク(tempura)へ移行しました。およそ3年半の塩漬けインスタンスです。
バックアップはありませんでしたし、FWもなく、inbound全解放の状態でした。よく今まで無事だったなと。
旧サーバはConoHa VPS上のUbuntu 22.04にMisskey install shell scriptで構築された環境でした。これをKAGOYA VPSのUbuntu 26.04へDocker構成で丸ごと引っ越します。DBはPGroonga入りのPostgreSQL18にし、files/配下にあったメディア類はR2へ、デプロイはcompose-cdでGitOps化するところまでやりました。当エントリはその備忘録です。
# 移行前と移行後
旧 | 新 | |
|---|---|---|
サーバ | ConoHa VPS(Ubuntu 22.04) | KAGOYA VPS(Ubuntu 26.04LTS) |
Misskey | v12.119.1フォーク(systemd) | tempura 2.0.9(Docker) |
DB | PostgreSQL15 | PostgreSQL18.4+PGroonga |
全文検索 | 無し | PGroonga |
メディア | ローカル | Cloudflare R2 |
リバースプロキシ | nginx+certbot | Caddy+Cloudflare Origin証明書 |
バックアップ | 無し | R2へ1日2回 |
デプロイ | compose-cd |
compose一式は、以前misskey.blue用に組んだprovisionレポジトリを下敷きにしています。 概ね中身は以前書いた構築記事のものです。
成果物はここに置いてあります。
# アップグレードの方針
フォークのまま多段アップグレードはしません。というより、できませんでした。
旧サーバが使っていたv12.119.1ベースのフォークは元レポジトリ自体がすでに消滅していて、差分をたどって移行パスを検証するという選択肢がなかったためです。なのでDBをバニラ相当とみなし、上流の通常アップグレード手順にそのまま追従させて最新版まで上げてから、最後にtempuraフォーク差分を適用することにしました。
手順としては、旧サーバでpg_dumpしたものを新サーバのpostgres:15コンテナへpg_restoreし、あとはappイメージのtagを差し替えて起動、マイグレーションの完走を見届けてまた次のtagへ。
ここでdocker compose exec -Tがstdinを消費することにハマりました。スクリプトを流し込む段階で、execが残りのスクリプトをstdinとして食ってしまい、以降のコマンドが実行されなかったためです。restoreやインデックス作成がエラーを出さずに飛ぶので気づきにくいですが、SQLは< file、参照系は</dev/nullで明示的にリダイレクトするといいでしょう。
今回は、12.119.1 → 13.14.2 → 2023.12.2 → 2024.10.0 → 2025.1.0 → 2026.6.0 → tempura 2.0.9の順でアップグレードしました。最大の難所はv12→v13で、かなり大規模な差分が入っています。マイグレーション数でいうと350本前後、tempura独自の57本を足して最終的に400本ちょっとでした。
v12からの移行で苦しんだことも記憶に新しいですが、もう3年前なんですね、あれ。
移行に際して、default.ymlの項目も幾分か変わっているのですが、id方式(aid)だけは全世代を通して変えないことが必要です。これはデータの整合性を担保するために必須です。
また、PG15からPG18への移行はメジャーバージョン跨ぎなのでデータディレクトリの流用はできず、ここもdump/restoreで行いました。PGDATAが/var/lib/postgresql/18/dockerにあるので、親ディレクトリごとマウントすれば永続化できます。今回は合わせてPGroongaの導入も行っているので、CREATE EXTENSION pgroongaしてnote.textにインデックスを張り、fulltextSearch: sqlPgroongaを有効にしました。v12には無かった全文検索が使えるようになりました。
# メディア移行とMIME
drive_fileは53,534行ありましたが、ローカルに実体を持つのは4,007件だけで、残り49,527件はリモートのリンク参照でした。ファイルそのものは9,564個(原本4,007+サムネイル3,898+webpublic1,659)で計8.8GiBほどありました。これらをまとめて旧サーバからrcloneでR2へ直接上げました。
DB側の既存メディアのURL書き換えは、
UPDATE drive_file SET url = replace(url, 'https://<domain>/files/', 'https://media.<domain>/local/') WHERE ...;で行い、空のフィールドは空のまま残るので404は出ません。非空URLの合計(4,007+3,898+1,659=9,564)が物理ファイル数とぴったり一致したことを確認しました。
また、リモートにはfiles/配下のファイルを参照するデータが残っていることを考慮し、アプリケーションの前段に置いているCaddyでリダイレクトする経路をあわせて用意しています。
example.com {
tls /etc/ssl/certs/certificate.pem /etc/ssl/private/key.pem
encode gzip
header /assets Cache-Control "public, max-age=31536000, immutable"
file_server
log {
output file /var/log/caddy/access.log {
roll_size 500mb
roll_keep 10
}
format json {
time_format iso8601
}
}
# 旧メディアURLをR2へ
@legacyfiles path_regexp legacyfiles ^/files/(.*)$
redir @legacyfiles https://media.example.com/local/{re.legacyfiles.1} permanent
reverse_proxy app:3000 {
header_up X-Real-IP {header.CF-Connecting-IP}
header_up X-Forwarded-For {header.CF-Connecting-IP}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
health_uri /
health_interval 10s
health_timeout 2s
health_status 200
}
handle_errors {
# appコンテナ停止時などのバックエンドエラー
@backend_down `{err.status_code} in [500, 501, 502, 503, 504, 522]`
handle @backend_down {
# /api系は外形監視のためメンテナンスを返さず本来のエラー(5xx)を通す
@api path /api/*
handle @api {
respond {err.status_code}
}
# それ以外はメンテナンスページを200で返す
handle {
reverse_proxy maintenance:80 {
handle_response {
copy_response_headers
copy_response 200
}
}
}
}
}
}R2への転送で一つ問題があり、Misskeyのローカルファイルはファイル名に拡張子がなく(accessKeyがそのままファイル名)、rcloneは拡張子からしかMIMEを判定しないため、全部application/octet-streamでアップロードされていました。画像はブラウザのスニッフィングで表示されてしまうため気づきにくいのですが、動画がインライン再生できない等の問題が発生します。 修正として、S3のCopyObject(metadata REPLACE)で、Content-Typeだけ貼り替えました。file --mime-typeの実判定値を9,564件流し込んで完了しました。ちなみに、並列にaws-cliを立ち上げるやり方はメモリ2GiBの子には荷が重すぎたようでOOMしてしまいました。boto3+スレッドプールの単一プロセスでやるのが速くて安全そうです。
余談ですが、アップロード中に止めようとして打ったpkill -f "rclone copy"は、自分がsshで送ったコマンド文字列そのものにもマッチするようでセッションごと死んでしまい、exit 255が返ってきて数秒固まりました。pkill -x rcloneを使いましょう。
# 全員のアイコンがidenticonになる
v12からv13へのアップグレードで、既存ユーザのavatarUrl/bannerUrlがnullになりました。Misskeyの挙動として、identiconにフォールバックされるので、移行直後は全ユーザーのアイコンがidenticon表示になってしまいました。失敗したのかとちょっと焦りましたね。
解決策として、drive_file自体は無事(なはず)なので、そこから再構築しました。
UPDATE "user" SET "avatarUrl" = COALESCE(NULLIF(f."webpublicUrl", ''), f."url") FROM drive_file f WHERE ...;これで9,507人のアバターと7,238件のバナーが正常に表示されます。
注意点として、ユーザ情報はin-memoryでキャッシュされているので、DBを直しただけでは反映されませんので、Misskeyのアプリケーション本体を再起動するところまでやりましょう。
ちなみに、絵文字も似たような問題を踏んでいて、emoji.publicUrlが旧URLのまま残り、ローカルの絵文字だけダミー画像にフォールバックしていました。URLをdrive_fileと同じ直R2形式に置換し、Redisの<domain>:singlecache:localEmojisを消して再起動することで事なきを得ました。 ここで安直にFLUSHALLしてしまうとジョブキューごと消えてしまうのでやってはいけません。
# 管理者がいない
今回利用しているフォークのtempuraでは、管理者/モデレータの判定をpermissionGroupという独自カラム(Admin/MainModerator/Normal/Community)で行っており、バニラにあるisAdministratorのbool値を見ていません。管理者ロールをDBで作って割り当てても、permissionGroup='Admin'でなければ権限は付かないことになります。
さらに、v12からの移行ではuser.isAdminがロールに変換されません。よって、移行後のインスタンスには管理者が存在しないインスタンスが完成してしまいます。
対応として、DBで管理者ロールを手組みしました。IDはaid形式(タイムスタンプのbase36を8桁+ランダム2桁)を自前で用意し、role_assignmentで割り当てて、ロールキャッシュ解消のためapp再起動。これで事なきを得ました。
# 横展開
同じ手順で2台目も移行しました。1台目のレポジトリをコピーしてfindとsedでドメインとレポジトリ名を置換し、compose.yamlを初期状態(postgres:15+バニラ)に戻してから、同じ手順をもう一度たどるだけで、データ規模が小さかったこともあり、あっさりと終わりました。
# さいごに
移行結果を確認しておきます。1台目でいうとnotes 189,545、users 9,841、drive_file 53,534。移行の前後で1件も欠けていません(usersだけ+1ですが、これはインスタンスactorが追加されたぶんなので正常です)。
3年半塩漬けだったデータを、そのまま現行環境へ載せ替えられました。
同じようなことを試みる誰かの参考になれば幸いです。
それでは。


