式年遷宮の手順メモ(2026年9月・クリーンインストール移設)

作成

#雑録 #Fediverse #kmyblue #Misskey #LLM補助 #メモ #式年遷宮

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 分)

  1. R2 でバケットを作る(鯖ごとに 1 つ)
  2. バケットに カスタムドメインを接続(kmyblue は k-files.my-sky.blue、Misskey は files.my-sky.blue)。Cloudflare で DNS を管理していれば自動で CNAME が入る
  3. CORS を設定(本体のドメインからだけ読めればよい)
JSON
[
  {
    "AllowedOrigins": ["https://k.my-sky.blue"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3600
  }
]
  1. R2 の API トークン(オブジェクトの読み書き権限)を発行し、Access Key ID と Secret Access Key を控える。画面を閉じると二度と見られない

kmyblue(Mastodon 系)— 難度低、停止 4 分

仕組み: Mastodon は DB にファイルの相対パスだけを持ち、URL は S3_ALIAS_HOST から組み立てる。public/system/ の中身をそのままバケットの根に置けばパスが一致するので、DB の書き換えは要らない。

  1. rclone の単体バイナリを作業ユーザーのホームに置く(sudo 不要。~/bin/rclone に置いた場合、非ログインシェルでは PATH に入らないのでフルパスで呼ぶ)。設定 ~/.config/rclone/rclone.conf(600):
INI
[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
  1. 稼働中に一次転送(cache/ は除外。ローカル財産だけなら 1.5GB・4,416 ファイルで数分)。長いので nohup で:
Bash
~/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 ファイル前後)。

  1. 停止して差分転送(今度は cache/ 込み。停止中に増えた分だけなので数分):
Bash
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 であること
  1. .env.production に追記して起動:
Bash
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)。

  1. 確認: 既存投稿の画像・アイコン・絵文字が表示され、画像 URL が https://k-files.my-sky.blue/media_attachments/... になっている。新規投稿に画像を付けて rclone ls r2:<bucket>/media_attachments | tail -3 に増える
  2. 旧 URL(https://k.my-sky.blue/system/…。連合先が覚えている)の転送は、鯖ではなく Cloudflare の Redirect Rule で: Rules → Redirect Rules → Wildcard pattern、Request URL https://k.my-sky.blue/system/* → Target https://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)で移行スクリプトを書いて流した。スクリプトの本文は環境依存が強いので載せないが、やったことは以下。

  1. 事前に DB ダンプと保険テーブルを取る:
Bash
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'
  1. コントロールパネル → オブジェクトストレージを設定(Base URL https://files.my-sky.blue、Endpoint <accountid>.r2.cloudflarestorage.com、Region auto、Prefix は空、Access Key/Secret)。ここから先の新規アップロードは R2 に入る
  2. 移行スクリプトの中身:
    • 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 件だけ流してブラウザで確認)→ 全件、の順
  3. 派生テーブルの URL を SQL で一括置換。user(avatarUrl・bannerUrl)・meta・emoji・announcement・ad・channel の文字列列を総当たり:
SQL
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 / より前に:

Nginx Configuration File
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 にも持っていく

  1. 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(締め出し防止)。

Bash
#!/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」が丸ごと消えた。

旧鯖から持ち出すもの(旧鯖の作業ユーザーで)

Bash
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 では手組み。順番:

  1. 依存パッケージ: 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 の中身は運ばないので版差は気にしない
  2. mastodon ユーザー: adduser --disabled-password --gecos "" mastodon(--disabled-login だとシェルが nologin になり sudo -iu mastodon で止まる)→ chmod o+x /home/mastodon(nginx が live/public を直接配信するため)
  3. Ruby(rbenv): RUBY_CONFIGURE_OPTS=--with-jemalloc rbenv install <.ruby-version の版> → rbenv global。libjemalloc-dev が無いと configure で止まる。4 コアで 7 分
  4. Node(nodesource で 1 系統だけ。n と併用しない)→ corepack enable
  5. git clone https://github.com/kmycode/mastodon.git live && cd live && git checkout <旧鯖と同じタグ>(同じ版に揃えると db:migrate が空振りで済む。版上げは移設後に)
  6. clone の直後に ln -s config/elasticsearch.default-ja-sudachi.yml .elasticsearch.yml(mastodon ユーザーで)。置き忘れたまま search deploy を走らせると素の解析器で索引が出来る
  7. bundle config set --local deployment true && bundle config set --local without 'development test' && bundle install -j4 && yarn install --immutable
  8. .env.production を install -o mastodon -g mastodon -m 600 で配置(S3 設定入りのまま)。letsencrypt.tgz を / に展開。nginx 設定と systemd ユニットを写し、ExecStart の node のパスと証明書のパスを新鯖に合わせる
  9. 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(建て込みの間に)

Bash
# 倉庫(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 して復元し、本番までの手順を一度通す。復元の実時間をここで測るのが本番の停止見込みの根拠になる。

Bash
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 分)

Bash
# --- 旧鯖 ---
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 の定型になる。

旧鯖から持ち出すもの

Bash
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 も一緒に。

ホスト側の建て込み

Bash
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:

YAML
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 だけ起動して配送やジョブを動かさない」指定で、予行で旧鯖と二重配送にならないための保険。

予行

Bash
# 旧鯖で予行用ダンプ(保険テーブルは除く)→ 新鯖へ直送
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 時間半切れた(鯖側は無事に完走していた)ので、糸が切れても向こうで続く形にした。

Bash
# --- 旧鯖 ---
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 の中身(要点):

Bash
#!/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 分は一部が旧経路にも流れる)。

再起動試験

Bash
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」の型

ソース