2017 年から動かしていた Debian 9 のメールサーバ(Postfix + Dovecot)を、Oracle Cloud(OCI)上の新しいサーバへ移行した。 MTA には Rust 製のオールインワンメールサーバ Stalwart を選んだ。つまずいた点を中心に記録しておく。

旧サーバ新サーバ
OS / CPUDebian 9 / x86_64Ubuntu / aarch64(OCI の無料枠)
MTA / IMAPPostfix + DovecotStalwart v1.0.1
メールの保存MaildirRocksDB(Stalwart 内蔵)
証明書certbotStalwart 内蔵の ACME
送信MailgunMailgun(変更なし)

移行したメールは 65 フォルダ、約 37,000 通、3.3 GiB。作業は 1 日で終わり、MX を切り替えて外部からの受信を確認できた。

サーバは OCI の無料枠(2 OCPU / 12 GB)

新サーバは OCI の Always Free の Ampere A1(Arm)で動かしている。 この無料枠は 2026 年 6 月 15 日に縮小され、以前の 4 OCPU / 24 GB から 2 OCPU / 12 GB(月 1,500 OCPU 時間 / 9,000 GB 時間)になった。目立った告知は無く、ドキュメントが書き換わっただけだった。2 OCPU / 12 GB のインスタンスなら、1 か月動かし続けてもこの範囲に収まる。

実際のインスタンスはこうなっている。

$ free
               total        used        free      shared  buff/cache   available
Mem:        12210692      932928     3980652        9100     7534128    11277764
Swap:              0           0           0
$ df
Filesystem     1K-blocks    Used Available Use% Mounted on
/dev/sda1       46212176 7378772  38817020  16% /
...
$ cat /proc/cpuinfo
processor       : 0
CPU implementer : 0x41
CPU part        : 0xd0c
...
processor       : 1
...

CPU は 2 コアで、CPU part : 0xd0c は Arm の Neoverse N1。

移行が終わった状態で、Stalwart を含めたメモリの使用量は 1 GB 弱。3.3 GiB のメールを入れても、ディスクは 45 GB のうち 7 GB しか使っていない。個人のメールサーバなら、縮小後の枠でも十分に余裕がある。

気をつける点は次のとおり。

  • 外向きの 25 番ポートは使えない。 送信は外部の SMTP サービスに任せる(うちは Mailgun)
  • アイドル状態のインスタンスは回収されることがある。 7 日間の CPU 使用率(95 パーセンタイル)、ネットワーク、メモリ(A1 の場合)がすべて 20% 未満だとアイドルと見なされる。個人のメールサーバは負荷が軽いので、この条件に当てはまりやすい
  • 作れるのはホームリージョンだけ。 混んでいるリージョンでは、A1 の空きが無くて作成に失敗することもある
  • CPU は Arm。 amd64 専用の Docker イメージは動かない(後述の imapsync がそうだった)

Stalwart を選んだ理由と注意点

  • SMTP、IMAP、JMAP、スパムフィルタ、ACME が 1 つのバイナリにまとまっている。素晴らしい。
  • Web の管理画面があり、Postfix / Dovecot / Rspamd / certbot を別々に管理しなくてよい

ただし、次の点には注意が必要。

  • 2026 年 10 月に v1.0.0 が出たばかり。 0.16 系からはデータ構造が変わっている
  • ネット上の解説は 0.16 以前の設定書式が多い。 config.toml や queue.route.* などの例はそのままでは使えない
  • Web メールは付いていない

なお OCI では、2021 年 6 月以降に作ったテナンシーは外向きの 25 番ポートが既定でブロックされている。うちは送信を Mailgun 経由にしているので、サーバが担当するのは受信と IMAP だけで済んだ。

ポートを開けたのに届かない(2 か所)

OS の iptables:REJECT より後ろに入れていた

OCI の Ubuntu イメージには、22 番以外を拒否するルールが最初から入っている。-I INPUT 6 で追加したところ、こうなった。

4    ACCEPT     tcp  ...  state NEW tcp dpt:22
5    REJECT     all  ...  reject-with icmp-host-prohibited
6    ACCEPT     tcp  ...  tcp dpt:993 state NEW
...

ルールは上から順に評価されるので、5 番の REJECT より後ろのルールには届かない。REJECT の位置(ここでは 5)に挿入し直す。

bash
for p in 25 443 465 993; do
  sudo iptables -D INPUT -p tcp --dport $p -m state --state NEW -j ACCEPT
  sudo iptables -I INPUT 5 -p tcp --dport $p -m state --state NEW -j ACCEPT
done
sudo netfilter-persistent save
sudo iptables -L INPUT -n --line-numbers
ポート用途
25受信
443管理画面と証明書の取得(TLS-ALPN-01)
465クライアントからの送信
993IMAPS

OCI のセキュリティリスト

それでも外から管理画面に入れなかった。サーバ内の curl https://localhost は応答し、外からはタイムアウトする。Connection refused ではなくタイムアウトになるのは、途中でパケットが捨てられているサイン。

原因は OCI コンソール側のセキュリティリストだった。サブネット → セキュリティリストで Ingress ルールを追加する。

  • ソース CIDR:0.0.0.0/0、プロトコル:TCP
  • 宛先ポート範囲:25,443,465,993(ソースポート範囲ではない)

インストールと初期設定

bash
curl --proto '=https' --tlsv1.2 -sSf https://get.stalw.art/install.sh -o install.sh
less install.sh   # 中身を確認してから実行
sudo sh install.sh
用途パス
設定/etc/stalwart/config.json
環境変数/etc/stalwart/stalwart.env
データ/var/lib/stalwart
ログ/var/log/stalwart

初回の一時パスワードは journal に出る。

bash
sudo journalctl -u stalwart -n 200 | grep -A8 'bootstrap mode'

セットアップ用のウィザードは 8080 番の HTTP で動き、総当たり攻撃への対策が無い。外には公開せず、SSH トンネルで入った。

bash
ssh -N -L 8080:127.0.0.1:8080 ubuntu@<サーバのIP>
# http://127.0.0.1:8080/admin を開く

ホスト名は旧サーバと並行稼働するため mail02.poppo-ya.com にした。ストレージは RocksDB、ディレクトリは Internal、TLS は ACME。

証明書が自己署名のまま

管理画面の証明書が issuer=CN=rcgen self signed cert のままだった。ログはウィザードの設定で /var/log/stalwart/ に出ている。

ACME order started ... hostname = ["autoconfig.poppo-ya.com", "autodiscover.poppo-ya.com",
  "mail02.poppo-ya.com", "mta-sts.poppo-ya.com", "ua-auto-config.poppo-ya.com"]
ACME authentication error ... DNS problem: NXDOMAIN looking up A for autoconfig.poppo-ya.com

Stalwart はホスト名に加えて、クライアントの自動設定用などのサブドメイン 4 つも 1 回の申請にまとめて取りに行く。1 つでも検証に失敗すると申請全体が失敗する。

ホスト名用途
autoconfigThunderbird などの自動設定
autodiscoverOutlook などの自動設定
mta-stsTLS での配送を必須にするポリシー(MTA-STS)の公開
ua-auto-configJMAP や CalDAV も含む新しい自動設定

4 つとも mail02.poppo-ya.com への CNAME を追加して解決した。DNS を触りたくなければ、ドメイン設定の Subject Alternative Names をホスト名だけにする方法もある。

DNS を追加した直後の再試行は NXDOMAIN のまま失敗し、約 15 分後の自動再試行で成功した。Let's Encrypt には検証失敗の回数制限(同じホスト名で 1 時間に 5 回)があるので、焦って何度も申請し直さないほうがよい。

ちなみに、sudo grep acme /var/log/stalwart/* は失敗する。ディレクトリが一般ユーザーから読めない権限になっていて、* の展開は sudo より先に自分の権限で行われるため。ファイル名は直接指定する。

サブアドレスを - 区切りにする

旧サーバでは Postfix の recipient_delimiter = - で user-xxxx@ を受けていた。Stalwart の標準は + なので、Management › Domains › ドメイン › Sub-Addressing を Custom にする。

項目値
Ifcontains(rcpt, '-')
Thensplit_once(rcpt, '-')[0]
Elsercpt
  • rcpt は @ より前の部分だけ(0.16 以降の仕様)。@ を含む正規表現を使う古い例は動かない
  • 最初の - より後ろが切り捨てられるので、アカウント名に - を使わない
  • ルールが間違っていてもエラーが出ないので、必ずテストする

テストは swaks で行った。Stalwart は FQDN でない EHLO を拒否する(550 5.5.0 Invalid EHLO domain)ので、--ehlo を付ける。

bash
swaks --to user-test@poppo-ya.com --server 127.0.0.1 \
  --ehlo test.example.com --from test@example.com

WebUI のバグで管理者がいなくなった

Management › Directory › Accounts の「+ Create User」を押すと、新規作成ではなく既存アカウントの編集画面が開く(v1.0.1 時点)。 気づかずに一般ユーザーを「新規作成」したつもりが、管理者アカウントが一般ユーザーに置き換わり、管理者がゼロになった。

リカバリー管理者で入る

環境変数 STALWART_RECOVERY_ADMIN を設定すると、データベースのアカウントとは別の緊急用の管理者が有効になる。通常運用のまま使えるので、メールを止めずに復旧できる。

bash
# /etc/stalwart/stalwart.env に追記
STALWART_RECOVERY_ADMIN=<ユーザー名>:<パスワード>
bash
sudo systemctl restart stalwart

STALWART_RECOVERY_MODE のほうは有効にしない。8080 番以外のリスナーが止まり、メールを受信できなくなる。

CLI で管理者を作り直す

WebUI を避けて、CLI(Docker イメージ)で作った。コンテナからサーバ自身のパブリック IP への折り返しはうまくいかないことがあるので、ホストのネットワークを使い、ホスト名を 127.0.0.1 に向ける。

bash
 alias stw='docker run --rm -i --network host --add-host mail02.poppo-ya.com:127.0.0.1 stalwartlabs/cli --url https://mail02.poppo-ya.com --user <リカバリー用ユーザー> --password '\''<パスワード>'\'''

先頭のスペースは、コマンドを履歴に残さないため(Ubuntu 既定の HISTCONTROL=ignoreboth で有効)。パスワードはシングルクォートで囲む。ダブルクォートだと $ などの記号がシェルに解釈され、authentication failed (HTTP 401) になる。

bash
$ stw query Domain
Id  Domain Name   Enabled  ...
b   poppo-ya.com  Yes      ...

$ stw create Account/User \
  --field name=<管理者名> \
  --field 'domainId=b' \
  --field 'credentials={"0":{"@type":"Password","secret":"<パスワード>"}}' \
  --field 'roles={"@type":"Admin"}' \
  --field 'permissions={"@type":"Inherit"}' \
  --field 'memberGroupIds={}' \
  --field 'quotas={}' \
  --field 'aliases={}' \
  --field 'encryptionAtRest={"@type":"Disabled"}'

domainId にはドメイン名ではなく ID(b)を指定する。roles を Admin にするのがポイント。

新しい管理者で入れたら、STALWART_RECOVERY_ADMIN を必ず消して再起動する。残したままだと、常に使える裏口になる。

imapsync でメールを移行

Stalwart はメールを独自のデータベースに格納するので、Maildir を置いても見えない。IMAP 経由でサーバ間コピーする imapsync を使った。

imapsync は IMAP クライアントとして旧サーバと新サーバの両方にログインし、旧サーバから読み出したメールを新サーバに書き込む。どちらのサーバにも特別なソフトは要らず、今回は新サーバの上で動かした。

imapsync の仕組み。新サーバ上の imapsync が、旧サーバ(host1、Dovecot、IMAPS 993)からメールを読み出し(FETCH)、新サーバ(host2、Stalwart、127.0.0.1:993)に書き込む(APPEND)。--automap で Sent は Sent Items、Junk は Junk Mail、Trash と Deleted Messages は Deleted Items にまとめられ、フォルダの区切りは . から / に変わる。止まっても再実行で続きから、同期中は Stalwart を再起動しない、IMAP の Rate Limit を一時的に緩める、--delete2 は付けない

ARM では Perl スクリプトを直接動かす

Ubuntu の apt には無く、公式の Docker イメージは amd64 専用で ARM では exec format error になる。imapsync は Perl スクリプト 1 本なので、ライブラリを入れればそのまま動く。

bash
sudo apt install \
  libauthen-ntlm-perl libcgi-pm-perl libcrypt-openssl-rsa-perl \
  libdata-uniqid-perl libencode-imaputf7-perl libfile-copy-recursive-perl \
  libfile-tail-perl libio-compress-perl libio-socket-inet6-perl \
  libio-socket-ssl-perl libio-tee-perl libhtml-parser-perl \
  libjson-webtoken-perl libmail-imapclient-perl libparse-recdescent-perl \
  libproc-processtable-perl libmodule-scandeps-perl libreadonly-perl \
  libregexp-common-perl libsys-meminfo-perl libterm-readkey-perl \
  libunicode-string-perl liburi-perl libwww-perl make time

mkdir -p ~/imapsync && cd ~/imapsync
wget -N https://raw.githubusercontent.com/imapsync/imapsync/master/imapsync
chmod +x imapsync

パスワードはコマンドラインに書かず、ファイルで渡す(ps や履歴から見えるため)。

bash
umask 077
echo '<旧サーバのパスワード>' > old.pw
echo '<新サーバのパスワード>' > new.pw

確認 → ドライラン → 本番

bash
./imapsync \
  --host1 <旧サーバ> --port1 993 --ssl1 --sslargs1 SSL_verify_mode=0 \
  --user1 <旧ユーザー> --passfile1 old.pw \
  --host2 127.0.0.1 --port2 993 --ssl2 --sslargs2 SSL_verify_mode=0 \
  --user2 <新ユーザー>@poppo-ya.com --passfile2 new.pw \
  --automap --exclude '^virus$' \
  --justfoldersizes        # ① サイズ確認だけ
  # --dry --justfolders    # ② フォルダの対応付けを確認
  # (オプションなし)       # ③ 本番
  • --justfoldersizes は確認だけで、何もコピーしない。これで「同期が終わった」と勘違いした
  • --automap で、Sent → Sent Items、Junk → Junk Mail、Trash と Deleted Messages → Deleted Items に変換される。Maildir の . 区切りも / 区切りになり、日本語のフォルダ名もそのまま移る
  • 本番は tmux の中で実行する。途中で止まっても、同じコマンドを再実行すれば続きからコピーされる

同期中に Stalwart を再起動しない

同期の途中で設定のために Stalwart を再起動したら、imapsync は BYE Server shutting down で終了した。データは壊れず、再実行で続きからコピーされたが、同期中は再起動しないほうがよい。

IMAP のレート制限

再開後、ETA が 3 日後になった。

could not add flags [NonJunk \Seen]: 2003 NO [LIMIT] Too many requests, retry in 15 seconds

Stalwart の IMAP の Rate Limit に引っかかっていた。imapsync はエラーが 50 件になると終了するので、放置すると途中で止まる。管理画面の IMAP の設定で一時的に緩めて、移行が終わったら元に戻す。

緩めた後は約 1 時間半でエラー 0 件のまま完了した。

「--delete2 を使え」に従わない

最後に次の案内が出る。

The sync is not strict, there are 1259 among 36838 identified messages in host2 that are not on host1.
Use --delete2 and sync again to delete them and have a strict sync.

ゴミ箱 2 つを 1 つにまとめたので、完全には一致しないのが正常。--delete2 を付けると、まとめたメールが新サーバから消える。

切り替え

  1. Thunderbird の受信サーバを mail02.poppo-ya.com(993、SSL/TLS)に変更
  2. MX を mail02.poppo-ya.com に変更し、imapsync で差分をコピー
  3. Gmail から送り、Received: ヘッダで Stalwart に TLS 1.3 で届いたことを確認
  4. 旧 MX の TTL の間は旧サーバにも届くので、旧サーバの /var/log/mail.log で受信が止まったのを確認してから、最後の差分コピー

旧サーバで使っていた .forward のパイプ処理は、Stalwart の MTA Hook で置き換えた。これは別の記事にまとめた → Stalwart の MTA Hook で .forward のパイプ処理を置き換える

つまずきポイントまとめ

症状原因対処
ポートを開けたのに届かないiptables の ACCEPT が REJECT より後ろREJECT の前に挿入
外からはタイムアウトOCI のセキュリティリストIngress ルールを追加
証明書が rcgen self signed certautoconfig などが NXDOMAIN で一括申請が失敗CNAME を追加するか、SAN を絞る
swaks が Invalid EHLO domainEHLO が FQDN でない--ehlo test.example.com
新規ユーザー作成で管理者が消えたWebUI の Create User のバグSTALWART_RECOVERY_ADMIN で入り、CLI で作成
CLI が HTTP 401パスワードの記号がシェルに解釈されたシングルクォートで囲む
imapsync が入らない / 動かないapt に無い、Docker は amd64 のみPerl スクリプトを直接実行
imapsync が 0 通--justfoldersizes は確認だけオプションを外す
BYE Server shutting down同期中に再起動した再実行で続きから。同期中は再起動しない
Too many requestsIMAP のレート制限一時的に緩め、移行後に戻す
--delete2 を勧められるフォルダを統合したので不一致従わない

参考