Postfix + Dovecot から Stalwart 1.0 へメールサーバを移行したときのハマりどころ

目次
2017 年から動かしていた Debian 9 のメールサーバ(Postfix + Dovecot)を、Oracle Cloud(OCI)上の新しいサーバへ移行した。 MTA には Rust 製のオールインワンメールサーバ Stalwart を選んだ。つまずいた点を中心に記録しておく。
| 旧サーバ | 新サーバ | |
|---|---|---|
| OS / CPU | Debian 9 / x86_64 | Ubuntu / aarch64(OCI の無料枠) |
| MTA / IMAP | Postfix + Dovecot | Stalwart v1.0.1 |
| メールの保存 | Maildir | RocksDB(Stalwart 内蔵) |
| 証明書 | certbot | Stalwart 内蔵の ACME |
| 送信 | Mailgun | Mailgun(変更なし) |
移行したメールは 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)に挿入し直す。
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 | クライアントからの送信 |
| 993 | IMAPS |
OCI のセキュリティリスト
それでも外から管理画面に入れなかった。サーバ内の curl https://localhost は応答し、外からはタイムアウトする。Connection refused ではなくタイムアウトになるのは、途中でパケットが捨てられているサイン。
原因は OCI コンソール側のセキュリティリストだった。サブネット → セキュリティリストで Ingress ルールを追加する。
- ソース CIDR:
0.0.0.0/0、プロトコル:TCP - 宛先ポート範囲:
25,443,465,993(ソースポート範囲ではない)
インストールと初期設定
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 に出る。
sudo journalctl -u stalwart -n 200 | grep -A8 'bootstrap mode'セットアップ用のウィザードは 8080 番の HTTP で動き、総当たり攻撃への対策が無い。外には公開せず、SSH トンネルで入った。
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.comStalwart はホスト名に加えて、クライアントの自動設定用などのサブドメイン 4 つも 1 回の申請にまとめて取りに行く。1 つでも検証に失敗すると申請全体が失敗する。
| ホスト名 | 用途 |
|---|---|
autoconfig | Thunderbird などの自動設定 |
autodiscover | Outlook などの自動設定 |
mta-sts | TLS での配送を必須にするポリシー(MTA-STS)の公開 |
ua-auto-config | JMAP や 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 にする。
| 項目 | 値 |
|---|---|
| If | contains(rcpt, '-') |
| Then | split_once(rcpt, '-')[0] |
| Else | rcpt |
rcptは@より前の部分だけ(0.16 以降の仕様)。@を含む正規表現を使う古い例は動かない- 最初の
-より後ろが切り捨てられるので、アカウント名に-を使わない - ルールが間違っていてもエラーが出ないので、必ずテストする
テストは swaks で行った。Stalwart は FQDN でない EHLO を拒否する(550 5.5.0 Invalid EHLO domain)ので、--ehlo を付ける。
swaks --to user-test@poppo-ya.com --server 127.0.0.1 \
--ehlo test.example.com --from test@example.comWebUI のバグで管理者がいなくなった
Management › Directory › Accounts の「+ Create User」を押すと、新規作成ではなく既存アカウントの編集画面が開く(v1.0.1 時点)。 気づかずに一般ユーザーを「新規作成」したつもりが、管理者アカウントが一般ユーザーに置き換わり、管理者がゼロになった。
リカバリー管理者で入る
環境変数 STALWART_RECOVERY_ADMIN を設定すると、データベースのアカウントとは別の緊急用の管理者が有効になる。通常運用のまま使えるので、メールを止めずに復旧できる。
# /etc/stalwart/stalwart.env に追記
STALWART_RECOVERY_ADMIN=<ユーザー名>:<パスワード>sudo systemctl restart stalwartSTALWART_RECOVERY_MODE のほうは有効にしない。8080 番以外のリスナーが止まり、メールを受信できなくなる。
CLI で管理者を作り直す
WebUI を避けて、CLI(Docker イメージ)で作った。コンテナからサーバ自身のパブリック IP への折り返しはうまくいかないことがあるので、ホストのネットワークを使い、ホスト名を 127.0.0.1 に向ける。
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) になる。
$ 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 クライアントとして旧サーバと新サーバの両方にログインし、旧サーバから読み出したメールを新サーバに書き込む。どちらのサーバにも特別なソフトは要らず、今回は新サーバの上で動かした。

ARM では Perl スクリプトを直接動かす
Ubuntu の apt には無く、公式の Docker イメージは amd64 専用で ARM では exec format error になる。imapsync は Perl スクリプト 1 本なので、ライブラリを入れればそのまま動く。
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 や履歴から見えるため)。
umask 077
echo '<旧サーバのパスワード>' > old.pw
echo '<新サーバのパスワード>' > new.pw確認 → ドライラン → 本番
./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 secondsStalwart の 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 を付けると、まとめたメールが新サーバから消える。
切り替え
- Thunderbird の受信サーバを
mail02.poppo-ya.com(993、SSL/TLS)に変更 - MX を
mail02.poppo-ya.comに変更し、imapsync で差分をコピー - Gmail から送り、
Received:ヘッダで Stalwart に TLS 1.3 で届いたことを確認 - 旧 MX の TTL の間は旧サーバにも届くので、旧サーバの
/var/log/mail.logで受信が止まったのを確認してから、最後の差分コピー
旧サーバで使っていた .forward のパイプ処理は、Stalwart の MTA Hook で置き換えた。これは別の記事にまとめた → Stalwart の MTA Hook で .forward のパイプ処理を置き換える
つまずきポイントまとめ
| 症状 | 原因 | 対処 |
|---|---|---|
| ポートを開けたのに届かない | iptables の ACCEPT が REJECT より後ろ | REJECT の前に挿入 |
| 外からはタイムアウト | OCI のセキュリティリスト | Ingress ルールを追加 |
証明書が rcgen self signed cert | autoconfig などが NXDOMAIN で一括申請が失敗 | CNAME を追加するか、SAN を絞る |
swaks が Invalid EHLO domain | EHLO が 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 requests | IMAP のレート制限 | 一時的に緩め、移行後に戻す |
--delete2 を勧められる | フォルダを統合したので不一致 | 従わない |
