式年遷宮の手順メモ(2026年9月・クリーンインストール移設)
2026 年 9 月に、kmyblue(Mastodon 系)と Misskey の 2 鯖を Ubuntu 26.04 をクリーンインストールした VPS へ移設し、メディアを Cloudflare R2 に統一したときの手順メモ。上書きアップグレード(インプレース)ではなく、更地に建て直して DB だけ運ぶやり方を、わーさんは「式年遷宮」と呼んでいる。読み物は雑記 式年遷宮したことについて(わーさん)と 式年遷宮——鯖を建て直すという選択(コードさん)、前回までの記録は kmyblue引っ越してみたメモ・Ubuntuインプレースアップグレードのメモ・Elasticsearch のメモ。実作業は Claude Code(コードさん)が鯖に入って行い、わーさんは管理画面・DNS・鍵登録と【おけ】を担当した。
コマンドは実際に流したものから、鍵・パスワード・IP を伏せて載せている。ユーザー名やパスは自分の環境に読み替えること。
全体の段取り
VPS は 4 コア/4GB/NVMe 200GB のものが 2 台(年契約)と、もう 1 台の 3 台体制から出発。年契約の 2 台は手放さずに使い続けたいので、玉突きで更地を作った。
| 段 | 内容 | 実績 |
|---|---|---|
| ① | 3 台目の鯖(旅空ノ夢・Misskey)を退役 | 09-08。Cloudflare Worker で 410 Gone を返して恒久停止 |
| ② | kmyblue・MySky のメディアを R2 へ | 09-07〜08。kmyblue の停止は約 4 分、Misskey は無停止 |
| ③ | ①で空いた VPS を 26.04 でクリーンインストール → kmy2 | 09-08、20 分 |
| ④ | kmyblue を kmy2 へ移設(Elasticsearch 8 + Sudachi) | 09-09、停止 19 分 |
| ⑤ | ④で空いた旧 VPS を 26.04 で再インストール → mysky2、MySky を Docker ハイブリッドで移設 | 09-11、停止 17〜20 分 |
| 後始末 | 5 日間の安定確認のあと旧鯖を削除 | 09-16 |
ポイントは「メディアを先に R2 へ逃がしておく」こと。これで移設当日に運ぶものが DB ダンプと設定ファイルだけになり、停止時間が 20 分前後に収まる。
どの段も、旧鯖は止めるだけで消さない。DNS を戻せば即座に旧鯖へ戻れる状態を、安定確認が終わるまで保つ。
② メディアの R2 引越し
R2 側の準備(Cloudflare ダッシュボード、20 分)
- R2 でバケットを作る(鯖ごとに 1 つ)
- バケットに カスタムドメインを接続(kmyblue は
k-files.my-sky.blue、Misskey はfiles.my-sky.blue)。Cloudflare で DNS を管理していれば自動で CNAME が入る - CORS を設定(本体のドメインからだけ読めればよい)
[
{
"AllowedOrigins": ["https://k.my-sky.blue"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
- R2 の API トークン(オブジェクトの読み書き権限)を発行し、Access Key ID と Secret Access Key を控える。画面を閉じると二度と見られない
kmyblue(Mastodon 系)— 難度低、停止 4 分
仕組み: Mastodon は DB にファイルの相対パスだけを持ち、URL は S3_ALIAS_HOST から組み立てる。public/system/ の中身をそのままバケットの根に置けばパスが一致するので、DB の書き換えは要らない。
rcloneの単体バイナリを作業ユーザーのホームに置く(sudo 不要。~/bin/rcloneに置いた場合、非ログインシェルでは PATH に入らないのでフルパスで呼ぶ)。設定~/.config/rclone/rclone.conf(600):
[r2]
type = s3
provider = Cloudflare
access_key_id = <Access Key ID>
secret_access_key = <Secret Access Key>
endpoint = https://<accountid>.r2.cloudflarestorage.com
acl = private
no_check_bucket = true
- 稼働中に一次転送(
cache/は除外。ローカル財産だけなら 1.5GB・4,416 ファイルで数分)。長いのでnohupで:
~/bin/rclone copy /home/mastodon/live/public/system/ r2:<bucket>/ --exclude "cache/**" \
--fast-list --transfers 16 --checkers 32 --s3-chunk-size 16M --s3-upload-concurrency 4 \
--stats 1m --stats-one-line --log-level NOTICE --log-file ~/rclone-1.log
cache/(他鯖のアイコン・絵文字・添付。こちらは 20GB・28 万件)も運ぶかは好み。運ばなければ他鯖の古い画像が一度消えて再取得になるだけ。今回は運んだ(43 分、毎秒 100 ファイル前後)。
- 停止して差分転送(今度は
cache/込み。停止中に増えた分だけなので数分):
sudo systemctl stop mastodon-web mastodon-sidekiq mastodon-streaming mastodon-streaming@4000
systemctl list-units --type=service --state=running --no-pager --plain | grep mastodon # 何も出なければ停止完了
~/bin/rclone copy /home/mastodon/live/public/system/ r2:<bucket>/ \
--fast-list --transfers 16 --checkers 32 --s3-chunk-size 16M --s3-upload-concurrency 4 \
--stats 30s --stats-one-line --log-level NOTICE --log-file ~/rclone-2.log
grep -c ERROR ~/rclone-2.log # 0 であること
.env.productionに追記して起動:
sudo cp /home/mastodon/live/.env.production /home/mastodon/live/.env.production.bak
sudo tee -a /home/mastodon/live/.env.production >/dev/null <<'EOF'
# --- Cloudflare R2 ---
S3_ENABLED=true
S3_BUCKET=<bucket>
S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com/
S3_ALIAS_HOST=k-files.my-sky.blue
S3_PROTOCOL=https
S3_PERMISSION=
AWS_ACCESS_KEY_ID=<Access Key ID>
AWS_SECRET_ACCESS_KEY=<Secret Access Key>
EOF
sudo systemctl start mastodon-web mastodon-sidekiq mastodon-streaming@4000
S3_PERMISSION= の空は必須。R2 は ACL に対応していないので、ここが既定の public-read のままだとアップロードで転ぶ。S3_REGION は書かなくてよい(弾かれたら S3_REGION=auto)。
- 確認: 既存投稿の画像・アイコン・絵文字が表示され、画像 URL が
https://k-files.my-sky.blue/media_attachments/...になっている。新規投稿に画像を付けてrclone ls r2:<bucket>/media_attachments | tail -3に増える - 旧 URL(
https://k.my-sky.blue/system/…。連合先が覚えている)の転送は、鯖ではなく Cloudflare の Redirect Rule で: Rules → Redirect Rules → Wildcard pattern、Request URLhttps://k.my-sky.blue/system/*→ Targethttps://k-files.my-sky.blue/${1}、301、Preserve query string ON。鯖に依存しないので、鯖を建て直しても効き続ける。確認はcurl -sI https://k.my-sky.blue/system/<何かのパス>が 301 でLocation: https://k-files…
戻し方: .env.production.bak を戻して restart。ローカルファイルは無傷なので即戻る。public/system は移設が終わるまで消さない。
Misskey — 難度中、無停止
仕組み: Misskey は DB にフル URL を持っていて、しかもコントロールパネルのオブジェクトストレージ設定は「これから上げるもの」にしか効かない。既存ファイルは自前で R2 に置き、DB の URL を書き換える必要がある。Python(boto3+psycopg2)で移行スクリプトを書いて流した。スクリプトの本文は環境依存が強いので載せないが、やったことは以下。
- 事前に DB ダンプと保険テーブルを取る:
sudo -u postgres pg_dumpall | gzip -c > /var/backup/pre-r2-$(date +%Y%m%d%H%M).gz
sudo -u postgres psql -d <db> -c 'CREATE TABLE drive_file_bak AS SELECT * FROM drive_file'
sudo -u postgres psql -d <db> -c 'CREATE TABLE user_bak AS SELECT id, "avatarUrl", "bannerUrl" FROM "user"'
sudo -u postgres psql -d <db> -c 'CREATE TABLE emoji_bak AS SELECT * FROM emoji'
- コントロールパネル → オブジェクトストレージを設定(Base URL
https://files.my-sky.blue、Endpoint<accountid>.r2.cloudflarestorage.com、Regionauto、Prefix は空、Access Key/Secret)。ここから先の新規アップロードは R2 に入る - 移行スクリプトの中身:
drive_fileのstoredInternal = trueの行を対象に、ローカルのfiles/<accessKey>を R2 へput_object。キーは既存のaccessKeyをそのまま使う(削除処理が accessKey をキーにするので、ずらすと後で消せなくなる)。thumbnail・webpublic も同様- 内部保存のファイルは拡張子なしの UUID 名なので、
file --mime-typeで判定した Content-Type を個別に付ける(付けないと R2 がapplication/octet-streamで返し、ブラウザで表示されない) drive_fileのurl/thumbnailUrl/webpublicUrlをhttps://files.my-sky.blue/<accessKey>に、storedInternalをfalseに更新- 鍵は環境変数で渡す(
read -sで打てば履歴に残らない)。--dry-run(件数と欠損の一覧だけ)→--limit 20(20 件だけ流してブラウザで確認)→ 全件、の順
- 派生テーブルの URL を SQL で一括置換。
user(avatarUrl・bannerUrl)・meta・emoji・announcement・ad・channelの文字列列を総当たり:
BEGIN;
DO $$
DECLARE r record; n int;
BEGIN
FOR r IN
SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = 'public'
AND data_type IN ('character varying', 'text')
AND table_name IN ('user', 'meta', 'emoji', 'announcement', 'ad', 'channel')
LOOP
EXECUTE format('UPDATE %I SET %I = replace(%I, %L, %L) WHERE %I LIKE %L',
r.table_name, r.column_name, r.column_name,
'https://my-sky.blue/files/', 'https://files.my-sky.blue/',
r.column_name, 'https://my-sky.blue/files/%');
GET DIAGNOSTICS n = ROW_COUNT;
IF n > 0 THEN RAISE NOTICE '%.%: % 行', r.table_name, r.column_name, n; END IF;
END LOOP;
END $$;
-- NOTICE の内訳を見て納得したら
COMMIT;
取りこぼし: user.avatarUrl にプロキシ URL の一部として URL エンコード形(%2Ffiles%2F)で埋まっていた旧 URL は LIKE に掛からず、後から %2Fmy-sky.blue%2Ffiles%2F → files.my-sky.blue%2F の置換を別に流した。URL 書き換えはエンコード形も検索対象に含めること
5. 旧 URL(/files/<key>)を nginx で 301。443 の server ブロック内、location / より前に:
location ~ ^/files/(?!app-default\.jpg)([^/]+) {
return 301 https://files.my-sky.blue/$1;
}
app-default.jpg は Misskey 内蔵のダミー画像なので除外。sudo nginx -t && sudo systemctl reload nginx のあと curl -sI https://my-sky.blue/files/<旧 accessKey> が 301 になること。この設定は鯖を建て直すとき新鯖の nginx にも持っていく
- Misskey を再起動(ユーザー情報のキャッシュに旧 URL が残る)。
select count(*) from drive_file where "storedInternal"が 0 なら完了。ローカルのfiles/は消さずに残す
戻し方: コントロールパネルのオブジェクトストレージを OFF → UPDATE drive_file d SET url=b.url, "thumbnailUrl"=b."thumbnailUrl", "webpublicUrl"=b."webpublicUrl", "storedInternal"=b."storedInternal" FROM drive_file_bak b WHERE d.id=b.id;(user・emoji も bak から)→ nginx の location を外して reload → 再起動。
③⑤ 新鯖の共通の型(bootstrap)
管理画面から OS を再インストールしたら、root で入って一本のスクリプトを流す。やることは 9 つで、最後の sshd は「設定を書くだけで再起動しない」。別窓で新ユーザーの鍵ログインと sudo が通るのを確かめてから systemctl restart ssh(締め出し防止)。
#!/usr/bin/env bash
set -euo pipefail
HOSTNAME_NEW="mysky2"
ADMIN_PUB="ssh-ed25519 AAAA... 管理ユーザーの公開鍵"
export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a
# 1. apt と基本パッケージ
apt-get update -y
apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" full-upgrade
apt-get install -y ufw fail2ban unattended-upgrades curl git htop
# 2. timezone / hostname
timedatectl set-timezone Asia/Tokyo
hostnamectl set-hostname "$HOSTNAME_NEW"
grep -q "[[:space:]]$HOSTNAME_NEW\$" /etc/hosts || echo "127.0.1.1 $HOSTNAME_NEW" >> /etc/hosts
# 3. 管理ユーザーと鍵(--disabled-login は 26.04 だとシェルが nologin になるので使わない)
adduser --disabled-password --gecos "" admin
usermod -aG sudo admin
install -d -m 700 -o admin -g admin /home/admin/.ssh
printf '%s\n' "$ADMIN_PUB" > /home/admin/.ssh/authorized_keys
chmod 600 /home/admin/.ssh/authorized_keys && chown admin:admin /home/admin/.ssh/authorized_keys
# 4. sudoers
echo 'admin ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/admin && chmod 440 /etc/sudoers.d/admin && visudo -cf /etc/sudoers.d/admin
# 5. ufw
ufw default deny incoming; ufw default allow outgoing
ufw allow 22/tcp; ufw allow 80/tcp; ufw allow 443/tcp
ufw --force enable
# 6. fail2ban(24.04 以降は auth.log が無いので backend=systemd)
cat > /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
backend = systemd
bantime = 1h
findtime = 10m
maxretry = 5
[sshd]
enabled = true
EOF
systemctl enable --now fail2ban
# 7. unattended-upgrades
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
systemctl enable --now unattended-upgrades
# 8. swap(無ければ作る。今回のプランは最初から 2G あった)
if ! swapon --show --noheadings | grep -q '/swapfile'; then
fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
fi
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swappiness.conf && sysctl -q -p /etc/sysctl.d/99-swappiness.conf
# 9. sshd(書くだけ。再起動は別窓で確認してから)
# sshd_config.d は名前順に読まれ最初の値が勝つ。cloud-init が 50-cloud-init.conf に
# PasswordAuthentication yes を置くことがあるので、それより若い番号にする
cat > /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
EOF
sshd -t
echo "別窓で ssh admin@<IP> sudo -n true が通ったら、この窓で systemctl restart ssh"
再起動後の確認: ssh root@<IP> が拒否される/ssh -o PubkeyAuthentication=no admin@<IP> が拒否される。root のパスワードは管理画面のコンソールから入る非常口として残る。
④ kmyblue の移設
運ぶのは DB ダンプと .env.production だけ。証明書一式・nginx 設定・systemd のユニット定義も写すが、メディアは R2 に居るので運ばない。前回(2024-02)の移設で一番重かった「public/system の tar」が丸ごと消えた。
旧鯖から持ち出すもの(旧鯖の作業ユーザーで)
mkdir -p ~/handoff && chmod 700 ~/handoff
sudo tar czf ~/handoff/letsencrypt.tgz /etc/letsencrypt # 証明書。持ち込めば certbot の取り直し無しで即 HTTPS
sudo tar czf ~/handoff/sudachi.tgz /etc/elasticsearch/sudachi # Sudachi の辞書と config.json
sudo cp /home/mastodon/live/.env.production ~/handoff/env.production
sudo chown -R $USER:$USER ~/handoff && chmod 600 ~/handoff/*
systemctl cat mastodon-web mastodon-sidekiq mastodon-streaming@4000 > ~/handoff/units.txt # 新鯖で写す
cat /etc/nginx/sites-available/mastodon > ~/handoff/nginx-mastodon.conf
sudo grep -hE '^-Xm[sx]' /etc/elasticsearch/jvm.options # ES のヒープ値を控える
新鯖への受け渡しは手元 PC を経由せず、VPS 間で直接: ssh 旧 'tar cz -C ~/handoff .' | ssh 新 'mkdir -p /tmp/handoff && tar xz -C /tmp/handoff'
新鯖の建て込み(停止なし、30 分〜1 時間)
kmyblue 公式の setup スクリプトは 24.04 前提なので、26.04 では手組み。順番:
- 依存パッケージ:
build-essential git curl libssl-dev libyaml-dev libreadline-dev zlib1g-dev libidn-dev libicu-dev libpq-dev postgresql redis-server nginx certbot python3-certbot-nginx ffmpeg imagemagick libvips-tools libjemalloc2 libjemalloc-dev。PG は 26.04 標準の 18 でよい(旧鯖の 17 からダンプ経由で互換 OK)。Redis の中身は運ばないので版差は気にしない mastodonユーザー:adduser --disabled-password --gecos "" mastodon(--disabled-loginだとシェルが nologin になりsudo -iu mastodonで止まる)→chmod o+x /home/mastodon(nginx がlive/publicを直接配信するため)- Ruby(rbenv):
RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install <.ruby-version の版>→rbenv global。libjemalloc-devが無いと configure で止まる。4 コアで 7 分 - Node(nodesource で 1 系統だけ。
nと併用しない)→corepack enable git clone https://github.com/kmycode/mastodon.git live && cd live && git checkout <旧鯖と同じタグ>(同じ版に揃えるとdb:migrateが空振りで済む。版上げは移設後に)- clone の直後に
ln -s config/elasticsearch.default-ja-sudachi.yml .elasticsearch.yml(mastodon ユーザーで)。置き忘れたままsearch deployを走らせると素の解析器で索引が出来る bundle config set --local deployment true && bundle config set --local without 'development test' && bundle install -j4 && yarn install --immutable.env.productionをinstall -o mastodon -g mastodon -m 600で配置(S3 設定入りのまま)。letsencrypt.tgzを/に展開。nginx 設定と systemd ユニットを写し、ExecStartの node のパスと証明書のパスを新鯖に合わせる- DB の役割:
.env.productionのDB_PASSでCREATE ROLE mastodon LOGIN PASSWORD '...'。ロケールは旧ダンプがja_JP.UTF8を名指ししているのでlocale -a | grep ja_JPで有ることを確認(無ければlocale-gen ja_JP.UTF-8)
Elasticsearch 8.19 + Sudachi 3.6(建て込みの間に)
# 倉庫(8.x)
sudo wget -O /usr/share/keyrings/elasticsearch.asc https://artifacts.elastic.co/GPG-KEY-elasticsearch
echo "deb [signed-by=/usr/share/keyrings/elasticsearch.asc] https://artifacts.elastic.co/packages/8.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-8.x.list
# 版を指定して入れて hold(Sudachi が対応する版まで。8.19.15 は 2026-09 時点)
sudo apt update && sudo apt install elasticsearch=8.19.15 && sudo apt-mark hold elasticsearch
# 8 系は導入時に認証と TLS を自動設定する。localhost 専用なので切る(追記ではなく書き換え。同じキーが二つあると起動しない)
sudo sed -i -e 's/^xpack.security.enabled: true/xpack.security.enabled: false/' \
-e 's/^xpack.security.enrollment.enabled: true/xpack.security.enrollment.enabled: false/' \
-e 's/^ enabled: true/ enabled: false/' /etc/elasticsearch/elasticsearch.yml
grep -n 'security\|enabled' /etc/elasticsearch/elasticsearch.yml # 全部 false
# ヒープ(4GB の同居鯖。search deploy が OOM で落ちるなら 512m〜1g)
printf -- '-Xms256m\n-Xmx256m\n' | sudo tee /etc/elasticsearch/jvm.options.d/heap.options
# Sudachi プラグイン(ES の版と厳密一致。entitlement の確認が出るので --batch)
cd /tmp && curl -LO https://github.com/WorksApplications/elasticsearch-sudachi/releases/download/v3.6.0/elasticsearch-8.19.15-analysis-sudachi-3.6.0.zip
sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install --batch file:///tmp/elasticsearch-8.19.15-analysis-sudachi-3.6.0.zip
# 辞書(旧鯖の sudachi.tgz を展開。root:elasticsearch 750/640)
sudo tar xzf /tmp/handoff/sudachi.tgz -C /
sudo chown -R root:elasticsearch /etc/elasticsearch/sudachi && sudo chmod 750 /etc/elasticsearch/sudachi && sudo find /etc/elasticsearch/sudachi -type f -exec chmod 640 {} +
# 起動(assets:precompile が終わってから)
sudo systemctl enable --now elasticsearch && sleep 30 && curl -s localhost:9200 | grep number # "8.19.15"
curl -s localhost:9200/_cat/plugins # analysis-sudachi
kmyblue 側は .env.production の ES_ENABLED=true/ES_HOST=localhost/ES_PORT=9200 のまま(ES_USER/ES_PASS は不要)。索引は旧鯖から運ばない。切替後に tootctl search deploy --reset-chewy で作り直す(149 万投稿で 53 分、裏で走らせてよい)。Sudachi が効いているかは curl -s localhost:9200/<索引>/_analyze -H 'Content-Type: application/json' -d '{"text":"式年遷宮の予行演習"}' で「式年」「遷宮」に割れるのを見る。
予行(旧鯖は稼働中のまま)
手元の日課ダンプ(pg_dumpall の gz)を新鯖に scp して復元し、本番までの手順を一度通す。復元の実時間をここで測るのが本番の停止見込みの根拠になる。
zcat kmyblue-backup.gz | sudo -u postgres psql # role "postgres" already exists は無害
sudo -iu mastodon bash -c 'cd live && RAILS_ENV=production bundle exec rails db:migrate' # 同版なら空振り
sudo -iu mastodon bash -c 'cd live && RAILS_ENV=production NODE_OPTIONS=--max-old-space-size=2048 bundle exec rails assets:precompile'
sudo systemctl start elasticsearch
sudo systemctl start mastodon-web # web だけ。sidekiq は上げない(本物の .env で動くと旧鯖と二重配送になる)
curl -k -H 'Host: k.my-sky.blue' https://127.0.0.1/api/v1/instance | head -c 300
実測: apt 3 分/Ruby ビルド 7 分/復元 6 分半(DB 6.2GB)/bundle+yarn 2 分/precompile 50 秒。見積の半分以下だった。
本番の切替(停止 19 分)
# --- 旧鯖 ---
sudo systemctl stop mastodon-web # 先に入口だけ止め、sidekiq のキューを 1〜2 分で捌かせる
redis-cli llen queue:default; redis-cli llen queue:push; redis-cli llen queue:pull; redis-cli llen queue:ingress # 全部 0
sudo systemctl stop mastodon-sidekiq mastodon-streaming mastodon-streaming@4000
sudo systemctl disable mastodon-web mastodon-sidekiq mastodon-streaming@4000 # 万一の再起動で二重起動しないように
sudo -u postgres pg_dump -Fc mastodon_production > ~/final-$(date +%m%d%H%M).dump # 約 4 分(1.3GB、圧縮込み)
# --- 新鯖 ---(旧鯖から直送。同一事業者内なので 100 秒)
scp <旧鯖>:~/final-*.dump /var/tmp/ # /home 配下は postgres が読めないので /var/tmp
sudo systemctl stop mastodon-web # 予行の web を止める(接続が残ると dropdb が失敗)
sudo -u postgres dropdb mastodon_production # 予行の箱を捨てて空箱を作り直す
sudo -u postgres createdb -T template0 -E UTF8 --locale=ja_JP.UTF8 -O mastodon mastodon_production
sudo -u postgres pg_restore -j 4 --no-owner --role=mastodon -d mastodon_production /var/tmp/final-*.dump # 6 分 38 秒
sudo -iu mastodon bash -c 'cd live && RAILS_ENV=production bundle exec rails db:migrate' # 空振り
sudo -iu mastodon bash -c 'cd live && RAILS_ENV=production bin/tootctl cache clear'
sudo systemctl start mastodon-web mastodon-sidekiq mastodon-streaming@4000 # ここで初めて sidekiq を上げる
curl -k -H 'Host: k.my-sky.blue' https://127.0.0.1/health
# --- Cloudflare --- k.my-sky.blue の A レコードを新 IP に(Proxied のまま、即時)
# --- 復帰後(停止なし)---
sudo -iu mastodon bash -c 'cd live && RAILS_ENV=production bin/tootctl feeds build' # ホーム TL。Redis は運ばないので最初は空。忘れると TL が消える
sudo -iu mastodon bash -c 'cd live && RAILS_ENV=production nohup bin/tootctl search deploy --reset-chewy > ~/search-deploy.log 2>&1 &'
sudo certbot renew --dry-run
外からの確認: /api/v1/instance・ログイン・投稿・別鯖から見た連合・添付(R2)・絵文字・ストリーミング・監視(Uptime Kuma)が Up に戻る。
停止中に減るもの: ホーム TL の一時的な空白と、検索が戻るまでの遅れだけ。投稿・添付・絵文字・フォローは DB と R2 にあるので欠けない。停止中の他鯖からの配送は相手側が再送する。
戻し方: DNS を旧 IP に戻して旧鯖の systemctl enable --now mastodon-web mastodon-sidekiq mastodon-streaming@4000。
⑤ MySky(Misskey)の移設 — Docker ハイブリッド
本体だけ公式コンテナ像、PostgreSQL・Redis・nginx・certbot はホスト直。PG のメジャー更新はどうせ「ダンプ→リストア」なので、コンテナに入れても手間は変わらない。本体だけ像にすれば Node の版に振り回されなくなり、アプデが pull → up -d の定型になる。
旧鯖から持ち出すもの
ssh <旧鯖> 'sudo tar cz -C / etc/letsencrypt etc/cloudflare/cloudflare.ini etc/nginx/conf.d/my-sky.blue.conf home/misskey/misskey/.config/default.yml' \
| ssh <新鯖> 'mkdir -p /tmp/handoff && tar xz -C /tmp/handoff'
default.yml の db.pass と redis の設定は旧鯖のまま使う(新鯖の PG に同じロールを同じパスワードで作る)。ワイルドカード証明書を dns-cloudflare で更新している場合は cloudflare.ini も一緒に。
ホスト側の建て込み
sudo apt update && sudo apt install -y postgresql redis-server nginx certbot python3-certbot-dns-cloudflare ffmpeg
# DB(パスワードは default.yml の db.pass。psql -c にヒアドキュメントで渡すと履歴に残らない)
sudo -u postgres psql <<'SQL'
CREATE ROLE misskey LOGIN PASSWORD '<db.pass>';
SQL
sudo -u postgres createdb -T template0 -E UTF8 --locale=en_US.UTF-8 -O misskey <db>
# 証明書・nginx
sudo tar xzf /tmp/handoff/letsencrypt.tgz -C / # 展開したものを /etc/letsencrypt に
sudo install -m 600 /tmp/handoff/etc/cloudflare/cloudflare.ini /etc/cloudflare/cloudflare.ini
sudo cp /tmp/handoff/etc/nginx/conf.d/my-sky.blue.conf /etc/nginx/conf.d/ # ②の /files/ 301 も入っている
sudo nginx -t && sudo systemctl reload nginx
sudo certbot renew --dry-run
# Docker
sudo apt install -y docker.io docker-compose-v2 && sudo systemctl enable --now docker
sudo mkdir -p /opt/misskey/.config /opt/misskey/files
sudo install -m 600 /tmp/handoff/home/misskey/misskey/.config/default.yml /opt/misskey/.config/default.yml
sudo chown -R 991:991 /opt/misskey/.config /opt/misskey/files # 公式像の実行ユーザーは UID/GID 991
/opt/misskey/compose.yml:
services:
web:
image: misskey/misskey:2026.9.0 # 更新はここのタグを変えて pull → up -d
container_name: misskey
restart: always
network_mode: host # ホストの PG/Redis に localhost のまま届く。3000 もホストに直接開く(ufw が外からは閉じている)
environment:
NODE_ENV: production
# 予行の間だけ有効にする 2 行(本番前に消す)
MK_ONLY_SERVER: "1"
MK_NO_DAEMONS: "1"
volumes:
- ./files:/misskey/files
- ./.config:/misskey/.config:ro
logging:
driver: journald # journalctl CONTAINER_NAME=misskey で読める
default.yml の db.host・redis.host は localhost のままでよい(network_mode: host なので)。MK_ONLY_SERVER/MK_NO_DAEMONS は「Web だけ起動して配送やジョブを動かさない」指定で、予行で旧鯖と二重配送にならないための保険。
予行
# 旧鯖で予行用ダンプ(保険テーブルは除く)→ 新鯖へ直送
sudo -u postgres pg_dump -Fc -T '*_bak*' <db> > /var/backup/rehearsal.dump
scp <旧鯖>:/var/backup/rehearsal.dump /var/tmp/
# 新鯖で復元して web だけ起動
sudo -u postgres pg_restore -j 4 --no-owner --role=misskey -d <db> /var/tmp/rehearsal.dump
cd /opt/misskey && sudo docker compose pull && sudo docker compose up -d
sudo docker compose logs web | grep -iE "migrat|listening|error"
curl -sk -X POST -H "Host: my-sky.blue" -H "Content-Type: application/json" -d "{}" https://127.0.0.1/api/meta | grep -oE '"version":"[^"]*"'
sudo docker compose down
ここで PG 18 でマイグレーションが通ることと、コンテナが起動して /api/meta が版を返すことを確かめる。復元の実時間も測る(6.5GB で 11 分だった)。
本番の切替(停止 17〜20 分)
切替は新鯖に置いたスクリプトを nohup で流す。予行の復元中に手元の ssh が 2 時間半切れた(鯖側は無事に完走していた)ので、糸が切れても向こうで続く形にした。
# --- 旧鯖 ---
sudo systemctl stop <misskey の unit>
sudo -u postgres pg_dump -Fc -T '*_bak*' <db> > /var/backup/final-$(date +%m%d%H%M).dump # 2 分
# --- 新鯖 ---
scp <旧鯖>:/var/backup/final-*.dump /var/tmp/ # 2 分
nohup bash ~/cutover.sh /var/tmp/final-*.dump > /dev/null 2>&1 &
tail -f /var/tmp/cutover-*.log
cutover.sh の中身(要点):
#!/usr/bin/env bash
set -euo pipefail
DUMP="${1:?final ダンプのパスを渡す}"
LOG="/var/tmp/cutover-$(date +%m%d%H%M).log"
exec > >(tee -a "$LOG") 2>&1
sudo docker ps -q --filter name=misskey | grep -q . && { echo "コンテナが動いている。先に down"; exit 1; }
echo "== 予行の箱を捨てて空箱"
sudo -u postgres dropdb <db>
sudo -u postgres createdb -T template0 -E UTF8 --locale=en_US.UTF-8 -O misskey <db>
echo "== pg_restore -j 4"; SECONDS=0
sudo -u postgres pg_restore -j 4 --no-owner --role=misskey -d <db> "$DUMP"
echo "restore done in ${SECONDS}s" # 656 秒だった
echo "== compose.yml から予行の 2 行を消す"
cd /opt/misskey
sudo sed -i '/予行の間だけ有効にする/d;/MK_ONLY_SERVER/d;/MK_NO_DAEMONS/d' compose.yml
grep -q 'MK_ONLY_SERVER\|MK_NO_DAEMONS' compose.yml && { echo "予行フラグが残っている"; exit 1; }
echo "== 本番起動"
sudo docker compose up -d
for i in $(seq 1 36); do
sudo docker compose logs web 2>/dev/null | grep -q "Now listening on port" && { echo "listening after ~$((i*5))s"; break; }
sleep 5
done
curl -sk -X POST -H "Host: my-sky.blue" -H "Content-Type: application/json" -d "{}" https://127.0.0.1/api/meta | grep -oE '"version":"[^"]*"'
echo "CUTOVER-DONE"
スクリプトが CUTOVER-DONE を出したら Cloudflare で my-sky.blue の A レコードを新 IP に。停止 17〜20 分の内訳: 停止 → 最終ダンプ 2 分 → 直送 2 分 → 復元 11 分 → コンテナ起動 40 秒 → DNS 切替 → 経路完全移行(切替直後の 1 分は一部が旧経路にも流れる)。
再起動試験
sudo apt-get update && sudo apt-get full-upgrade -y && sudo reboot # upgrade ではなく full-upgrade(新カーネルを入れるため)
# 戻ってきたら
uname -r # 版が上がっていること
sudo docker compose -f /opt/misskey/compose.yml ps # healthy まで 40 秒ほど
以後のアプデ
compose.yml の image: のタグを新しい版に書き換えて sudo docker compose pull && sudo docker compose up -d。マイグレーションはコンテナ起動時に自動で走る。ホスト側の PG・Redis・nginx は apt に任せる。
躓きと教訓
apt-get upgradeは新カーネルを保留する。 再起動試験を「合格」と書いてからカーネルが古いままなのに気付き、full-upgradeでやり直した- Cloudflare の DNS 切替直後の約 1 分は、一部の PoP がまだ旧鯖へ流す。旧鯖の nginx が止まった本体の代わりに 502 を返し、同じ 1 分の中で他所からの配達は新鯖に 202 で届いていた。壊れているのではなく反映の途中
- 長い鯖作業は
nohup+ 完了マーカー。手元の接続は切れるものとして組む adduser --disabled-loginは新しい Ubuntu だとシェルが nologin になり、あとでそのユーザーに切り替えられない。--disabled-passwordにするpsql -cはシェル変数は展開するが psql の変数構文(:'var')は展開しない。パスワードを含む処理はヒアドキュメントか標準入力から流す- Node の導入経路を二重にしない(nodesource と
nの併用など)。サービス定義が見る実行体とビルド時の実行体がずれて起動不能になったことがある - jemalloc の
LD_PRELOADは、libjemalloc-devが欠けているとエラーを出さずに無効のまま起動する。journal にld.soの ERROR が無いことを見る - プロセスをパターンで止めるとき、絞りが甘いと自分の ssh セッションごと落とす。
pkill -f "search dep[l]oy"のように自分自身が引っかからない書き方にする - 予行の web は本番前に必ず止める。接続が残っていると
dropdbが失敗する - わーさんの端末(PowerShell 5.1)向けの一行に
&&を渡さない
インプレースでは起きたが、今回は起きなかったこと
2024 年 12 月の上書きアップグレードでは、vendor の作り直し・Ruby の入れ直し・PostgreSQL の collation 警告・Sudachi の版ずれが出た。どれも「古い OS の上に新しい OS を重ねて、ファイルをそのまま引き継いだ」ことから来ている。今回は更地に建てて DB を SQL レベルで流し込んだので、索引も照合順序も新環境の値で作り直され、4 つとも最初から起きなかった。
次の遷宮のために
- Ubuntu の LTS は 2 年ごと。次は 28.04 の頃(2028 年)が目安で、横着するなら 1 回飛ばし(30.04)でも安泰
- 2 台体制で更地を作るには、一時避難先(仮殿)が要る。候補はラズパイ 5 + Cloudflare Tunnel。2 年後のことは分からないので、そっと
- ES の更新は「Sudachi の対応表を先に見て、ES を版指定+hold」の型
ソース
- Misskey 手動インストール(misskey-hub) — 要件と手順
- Misskey Docker インストール(misskey-hub)/compose_example.yml/Dockerfile — UID/GID 991、
migrateandstart - env.ts —
MK_ONLY_SERVER・MK_NO_DAEMONS - Misskeyのfilesをs3にあげてurlを解決するメモ(zenn) — content-type 付け直し+nginx 転送の先行例
- Cloudflare R2 S3 API compatibility — ACL 非対応
- Cloudflare R2 × rclone/Cloudflare R2 × Mastodon — 設定値の公式形
- Mastodon docs: Object storage/Full-text search
- kmyblue wiki: ElasticSearch設定方法 —
.elasticsearch.ymlとsearch deploy - elasticsearch-sudachi releases — ES の版との対応表