Deploying Syncthing on a Fly.io Cloud Server

Michael Lynch

Fly.ioのクラウドサーバーにSyncthingをデプロイする

最近、複数マシン間でファイルを同期するためのオープンソースツールであるSyncthingを知りました。

個人のデバイスへのSyncthingのセットアップは簡単でしたが、クラウドサーバーへのデプロイでは興味深い紆余曲折を経験しました。

なぜクラウドでSyncthingを動かすのか

Syncthingはピアツーピアでファイルを同期します。つまり、同期を維持するには少なくとも2台のデバイスが同時にオンラインでSyncthingを実行している必要があります。デスクトップでファイルを変更してシャットダウンし、その後ノートPCを持って出張に出かけた場合、デスクトップで行った変更はノートPCに反映されません。

常にオンラインで利用可能なクラウドサーバーが1台あり、そこでSyncthingを動かしておけば、デバイス同士が同期から外れてしまうのを防げます。

前置きはいいからデプロイ方法だけ教えてくれ

うまくいかなかったデプロイ方法についても紹介します。すぐに解決策を知りたい方は、「Fly.ioにSyncthingをデプロイする方法」のセクションまでスキップしてください。

先行事例:Fly.io上のSyncthing + Tailscale

ここ2年ほど、Fly.ioを愛用するクラウドホスティングプロバイダーとして使ってきたので、Fly.ioでSyncthingを動かした事例がないか調べてみました。すると、1年も経たないうちにAndrew Katz氏が素晴らしいチュートリアルを公開していることがわかりました。

『Running Syncthing on Fly.io with Tailscale』という投稿のスクリーンショット

Andrew氏のチュートリアルは、私のアイデアが実現可能であることを証明してくれたという点で朗報でした。ただ1つ気になったのは、人気のVPNソリューションであるTailscaleに依存している点です。私はTailscaleが大好きですが、この文脈ではいくつか大きな欠点があります。

SyncthingとTailscaleを組み合わせるには、カスタムDockerイメージをビルドする必要があります。両方のツールが進化していく中で、そのイメージを更新し続けるのは決して軽くない保守負担になります。さらに、1つのコンテナに2つのアプリケーションを詰め込むのはDockerのベストプラクティス的にはあまり推奨されません

すべてのFly.ioサーバーにはデフォルトでWireGuard VPNが組み込まれています。Tailscaleを混ぜ込むのではなく、すでにそこにあるWireGuard接続を活用すれば、Andrew氏のソリューションを改善できるのではないかと考えました。

linuxserverのDockerイメージはFly.ioでは動かない

SyncthingのDockerイメージを探したとき、なぜか公式のDockerイメージを見落としてしまい、代わりに非公式のLinuxServer.io版を見つけてしまいました。

linuxserver.io版をFly.ioにデプロイしてみましたが、サーバーはすぐにクラッシュループに陥りました。

2023-05-23T12:44:17.247 [info] Preparing to run: `/init` as root
2023-05-23T12:44:17.258 [info] 2023/05/23 12:44:17 listening on [fdaa:0:20ad:a7b:cb:a9e9:30cd:2]:22 (DNS: [fdaa::3]:53)
2023-05-23T12:44:17.261 [info] s6-overlay-suexec: fatal: can only run as pid 1

同じ問題を扱ったFly.ioのサポートスレッドを見つけました。

いろいろ調べてみたところ、原因がわかったと思います! エラーs6-overlay-suexec: fatal: can only run as pid 1に関する最後のメッセージのおかげで

調べてみたところ、私が使っているイメージはpid 1として実行されることを求めるプロセスマネージャーを使っていることがわかりました。これはRunning Multiple Processes Inside A Fly.io Appによると、不可能だとのことです。

-@mpaupulaire

linuxserverのDockerのソースを見ると、ランタイムイメージはlinuxserver/baseimage-alpineに依存していますそのイメージのソースを確認してみると、initプロセスのオーバーライドについて詳しくはないのですが、ファイル内にinit関連の記述が何行かあったので、@mpaupulaire氏が指摘した問題が私のクラッシュループの原因を説明しているように思えました。

公式のSyncthing Dockerイメージをもっと慎重に探す代わりに、3時間かけて自分で作ってしまいました。このチュートリアルを書くために腰を据えたときに、公式イメージを見落としていたことに気づいたので、ここではその話は省略します。

Fly.ioへの基本的なSyncthingデプロイ

公式のSyncthing Dockerイメージを手に入れたので、Fly.ioへのデプロイの準備が整いました。まずは新しいFly.ioアプリを作成します。

$ fly apps create --name syncthing-mtlynch
? Select Organization: Michael Lynch (personal)
New app created: syncthing-mtlynch

Syncthingはデータを保存する場所を必要とするので、syncthing_dataという名前のFly.io永続ボリュームを作成しました。

SIZE_IN_GB=3 # This is the limit of fly.io's free tier as of 2023-05-29

fly volumes create syncthing_data \
  --size "${SIZE_IN_GB}" \
  --yes

次に、Syncthing用の最小限のFly.io設定を作成しました。

app = "syncthing-mtlynch"

[build]
  image = "syncthing/syncthing:1.23.4"

[mounts]
  source="syncthing_data"
  destination="/var/syncthing"

さあ、いよいよです。アプリを起動します。

$ fly deploy
==> Verifying app config
Validating /tmp/tmp.mezhLZdpSv/fly.toml
Platform: machines
✓ Configuration is valid
--> Verified app config
==> Building image
Searching for image 'syncthing/syncthing:1.23.4' remotely...
image found: img_98dgp8mlx504xw05

Watch your app at https://fly.io/apps/syncthing-mtlynch/monitoring

Updating existing machines in 'syncthing-mtlynch' with rolling strategy
  [1/1] Replacing 6e82ddd3ae5698 [app] by new machine
  [1/1] Machine 918570e1f96283 [app] update finished: success
  Finished deploying

そして、うまくいきました!ログを見ると、Syncthingが起動して実行されています。

2023/05/25 12:09:52 INFO: My ID: YERKMWG-WMUKYOR-J57TFK7-LQ3NHPX-6TI5AFU-IX7SEEW-GX7QO3C-NPYATQT
2023/05/25 12:09:53 INFO: GUI and API listening on [::]:8384
2023/05/25 12:09:53 INFO: Access the GUI via the following URL: http://127.0.0.1:8384/
2023/05/25 12:09:53 INFO: My name is "918570e1f96283"
2023/05/25 12:09:53 INFO: Completed initial scan of sendreceive folder "Default Folder" (default)
2023/05/25 12:10:12 INFO: quic://0.0.0.0:22000 detected NAT type: Port restricted NAT
2023/05/25 12:10:12 INFO: quic://0.0.0.0:22000 resolved external address quic://66.225.222.75:22000 (2023/05/25 12:10:32 INFO: Joined relay relay://54.175.93.212:443

ログにはSyncthingサーバーのデバイスIDが表示されていたので、ローカルのSyncthingサーバーからピアとして追加できました。

デバイスID GHLLBWT-QJ4LGHJ-RT43QUV-DWFRMGS-5OTXHGH-LAZAIMG-HQ3TVAE-UUC2SA5 でデバイスを追加する画面のスクリーンショット

残念ながら、ローカルのSyncthingインスタンスはクラウドサーバーへの接続に失敗しました。

syncthing-mtlynchが切断状態になっていることを示すスクリーンショット

これは想定通りです。Fly.ioサーバーで受信トラフィックを一切許可するように設定していないからです。

サーバーをしばらく放置しておけば、リレーを経由して最終的には接続されるでしょうが、受信用ポートを正しく設定しておいた方がSyncthingはより快適に動作します。

Syncthingのファイアウォールポートを設定する

この時点でSyncthingはFly.io上で起動していましたが、他のどのデバイスからのトラフィックも受け付けられない状態でした。

Syncthingには、ピアと通信するために開放すべきポートについて、非常にわかりやすいドキュメントがあります。

ポート 22000/TCP:TCPベースの同期プロトコルトラフィック

ポート 22000/UDP:QUICベースの同期プロトコルトラフィック

ポート 21027/UDP:IPv4でのディスカバリーブロードキャストおよびIPv6でのマルチキャスト用

これをFly.ioの設定に次のように落とし込みました。

[[services]]
  internal_port = 22000
  protocol = "tcp"

  [[services.ports]]
    port = 22000

  [[services.tcp_checks]]
    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

[[services]]
  internal_port = 22000
  protocol = "udp"

  [[services.ports]]
    port = 22000

[[services]]
  internal_port = 21027
  protocol = "udp"

  [[services.ports]]
    port = 21027

ポート22000をヘルスチェックポートとして使いました。Fly.ioが定期的にこのポートをポーリングし、接続できなければSyncthingが異常な状態であると判断するということです。

Syncthing Dockerイメージの管理インターフェースはデフォルトで0.0.0.0:8384になっており、プライベートとパブリックの両方のネットワークインターフェースで接続を受け付けます。Fly.ioの設定でポート8384を公開していないので、本来は問題にならないはずです。ただ、多層防御の観点から、Syncthingがループバックインターフェースでのみリッスンするように設定しました。

Syncthingのドキュメントによると、STGUIADDRESS環境変数を空にすることで管理UIへのアクセスを制限できるとのことです。

[env]
  # Only listen for connections to admin GUI through localhost.
  STGUIADDRESS = ""

すべてをまとめると、fly.tomlファイルは次のようになりました。

app = "syncthing-mtlynch"

[build]
  image = "syncthing/syncthing:1.23.4"

[env]
  # Only listen for connections to admin GUI through localhost.
  STGUIADDRESS = ""

[mounts]
  source="syncthing_data"
  destination="/var/syncthing"

[[services]]
  internal_port = 22000
  protocol = "tcp"

  [[services.ports]]
    port = 22000

  [[services.tcp_checks]]
    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

[[services]]
  internal_port = 22000
  protocol = "udp"

  [[services.ports]]
    port = 22000

[[services]]
  internal_port = 21027
  protocol = "udp"

  [[services.ports]]
    port = 21027

TailscaleなしでSyncthingを設定する

これでFly.io上でSyncthingが動くようになりました!ログからデバイスIDを取得して、Fly.io上のSyncthingノードをピアとして追加できます。

まだ問題が残っています。Syncthingにおけるピア関係は相互の同意が必要です。クラウドサーバーが自分のデバイスからの関係を承認するためには、クラウドサーバーの管理ダッシュボードにアクセスする必要があります。

Syncthingの管理インターフェースをインターネット全体に公開したくはありませんでした。理論上は強力なパスワードで保護することも可能ですが、ネットワークレベルの保護の方がより強力で信頼性が高いのです。

Andrew Katz氏は、Fly.io上のSyncthingサーバーを個人のTailscale VPNに参加させることでこの問題を解決しました。これにより、Andrew氏はVPN内から管理インターフェースにアクセスしつつ、他の誰も接続できないようにすることができました。

前述の通り、すべてのFly.ioサーバーにはWireGuard VPNが組み込まれているので、その方法で管理インターフェースにアクセスできないでしょうか?

まずはサーバーにSSHで接続してみました。

$ fly ssh console
Connecting to fdaa:0:20ad:a7b:15f:92b0:4091:2... complete
32874e1dc76685:/#

簡単でした。これでSyncthingサーバーのコンソールにアクセスでき、好きなコマンドを実行できるようになりました。

通常のsshユーティリティでは、ローカルポートをSSH接続の向こう側にトンネリングできます。ローカルのポート8384をSyncthingサーバー上のポート8384にトンネリングできれば、Syncthingサーバーの管理ダッシュボードにアクセスできるはずです。

残念ながら、fly sshコマンドはポートフォワーディングをサポートしていないため、この方法は使えませんでした。しかし、flyユーティリティにはproxyコマンドがあることを発見したので、試してみました。

$ fly proxy 8384:8384
Proxying local port 8384 to remote [syncthing-mtlynch.internal]:8384

何か動きがありそうに見えました。しかし、接続を試してみてもダメでした。

$ curl http://localhost:8384
curl: (56) Recv failure: Connection reset by peer

ポート8000でnetcatを起動し、そこへプロキシしてみるという、よりシンプルなテストを試しました。

32874e1dc76685:/# nc -l 8000
$ fly proxy 8000:8000
Proxying local port 8000 to remote [syncthing-mtlynch.internal]:8000
$ curl http://localhost:8000
curl: (56) Recv failure: Connection reset by peer

Fly.ioサーバー側では、netcatに接続の試みすら表示されませんでした。一体どうなっているのでしょうか?

「Fly proxy seemingly doesn't work」というタイトルのFly.ioフォーラムの投稿を見つけました。まさにその時の私の気持ちを代弁していました。

ローカルコンピューターをfly proxy 8080で接続しようとしていますが、次のように表示されます。

Proxying local port 8080 to remote [notion-to-calendar.internal]:8080

しかし、curl localhost:8080curl 0.0.0.0:8080を実行しても、プロキシを閉じるまでただハングするだけです。

-@bram-dingelstad

Fly.ioチームの@jerome氏が、何が起きているのかを説明してくれていました。

only listeners bound on ipv6 are accessible via the fly proxy command.

-@jerome

なるほど、IPv6ですか!それなら説明がつきます。SyncthingがIPv4インターフェースでリッスンしていたら、Fly.ioのプロキシからの接続は届かないはずです。

似たような問題にSiaのDockerイメージをメンテナンスしていたときにも遭遇しました。そのときの解決策は、socatというツールを使って接続をプロキシすることでした。そこで今回も、IPv6のポート8386でリッスンし、IPv4のポート8384へ接続をプロキシするようにしてみました。

apk add socat && \
  socat TCP6-LISTEN:8386,fork,su=nobody TCP4:localhost:8384

そして、fly proxyコマンドをIPv6ポートへトラフィックを送るように更新しました。

fly proxy 8384:8386

そして、なんと!うまくいきました。ローカルデバイスからクラウドサーバー上のSyncthing管理ダッシュボードにアクセスできるようになりました。

fly.io上のSyncthingダッシュボードのスクリーンショット

Andrew Katz氏のソリューションは、Fly.ioサーバーの管理インターフェースにいつでもアクセスできるという利点があります。私の場合はFly.ioサーバーに管理上の変更を加えたいとき、その場しのぎのプロキシをセットアップするという少々面倒な手順を踏む必要がありますが、実際にはそれで問題ありません。メンテナンスはそう頻繁にあるわけではないので、多少の不格好さは気になりません。

socatを使わずに済ませる方法はないのか

IPv6をsocatでプロキシする方法は機能しましたが、見た目も悪く、ややこしいものです。もっとすっきりした方法はないのでしょうか?

SyncthingはネイティブでIPv6をサポートしているように見えたので、Fly.ioサーバーのIPv6ループバックインターフェースである::1でリッスンするように指示してみました。

[env]
  STGUIADDRESS = "[::1]:8384"

fly deployで再デプロイすると、すべて問題なく起動しました。ログにはSyncthingが::1でリッスンしていることが表示されています。

INFO: Access the GUI via the following URL: http://[::1]:8384/

今のところ順調です。もう一度プロキシコマンドを試してみます。

$ fly proxy 8384:8384
Proxying local port 8384 to remote [syncthing-mtlynch.internal]:8384

そして、8384経由での接続を試します。

$ curl http://localhost:8384/
curl: (56) Recv failure: Connection reset by peer

残念!あと一歩でした。

Syncthingサーバーのログには、次のようなメッセージが何十行も表示されていました。

[UPMD6] 2023/05/25 11:41:16 INFO: Listen (BEP/tcp): TLS handshake: EOF

TLS?はて?Syncthingのログでは、平文のhttp://接続をリッスンしていると表示されていました。念のため、HTTPSプロトコルでも試してみました。

$ curl https://localhost:8384/
curl: (35) OpenSSL SSL_connect: Connection reset by peer in connection to localhost:8384

やはりダメでした。解決策は近い気がするので、もしアイデアをお持ちの読者の方がいれば、ぜひ教えてください。

検討したもう一つの方法は、Web GUIを完全にスキップして、真のハッカーのようにすべてをCLIで操作することでした。

$ syncthing cli --home /var/syncthing/config config devices
NAME:
   syncthing cli config devices -

USAGE:
   syncthing cli config devices command [command options] [arguments...]

COMMANDS:

   ACTIONS:
     list      List item keys in the collection
     add       Add a new item to collection
     add-json  Add a new item to collection deserialised from JSON

しかし、SyncthingのCLIはかなり複雑そうだったので、Web GUIで十分だと判断しました。

追記(2023-06-29):解決策が見つかりました!

読者の方々からfly-local-6pnアドレスについて教えていただいたおかげで、fly.tomlを次の環境変数で更新できます。

STGUIADDRESS = "fly-local-6pn:8384"

これで、flyctl proxy 8384:8384を使って通常通りに接続できます。以下のチュートリアルも、この改善を反映して更新しました。

Fly.ioにSyncthingをデプロイする方法

何度も試行錯誤しながらSyncthingとFly.ioをいじくり回した結果、Fly.ioにSyncthingをデプロイするすっきりとした方法をご紹介できる準備が整いました。開始から終了まで5分程度で完了するはずです。

前提条件

始める前に、以下のものが必要です。

  • Fly.ioアカウント(課金が有効になっていること)
  • fly CLIがインストールされ、マシン上で認証済みであること

アプリを作成する

まずは新しいFly.ioアプリを作成します。

以下のスニペットでは、アプリ名をsyncthing-にランダムなサフィックスを付けたものにしていますが、Fly.ioでまだ取得されていない名前であれば、どんなアプリ名でも構いません。

RANDOM_SUFFIX="$(head /dev/urandom | tr -dc 'a-z0-9' | head -c 6 ; echo '')"
APP_NAME="syncthing-${RANDOM_SUFFIX}"

fly apps create --name "${APP_NAME}"

Fly.ioの設定ファイルを作成する

次に、デプロイ用のFly.io設定ファイルを作成します。

実行しているバージョンを正確に把握しておきたいので、SYNCTHING_VERSIONを明示的な1.23.4バージョンのイメージに設定しています。新しいサーバーをデプロイするたびに最新の安定版を実行したい場合は、バージョンをlatestに設定してください。もっと攻めたい方は、不安定版ですが最先端の機能が入ったedgenightlyを選ぶこともできます。

REGIONについては、お近くのFly.ioリージョンを選んでください。

VOLUME_NAMEは何でも構いませんが、好きな名前に変更できます。

SYNCTHING_VERSION="1.23.4"
REGION="ewr" # Deploy to Fly.io's Newark, NJ, USA data center.
VOLUME_NAME="syncthing_data"

cat <<EOF > fly.toml
app = "${APP_NAME}"
primary_region = "${REGION}"

[build]
  image = "syncthing/syncthing:${SYNCTHING_VERSION}"

[env]
  # Only listen for connections to admin GUI through fly.io's private Wireguard
  # network.
  STGUIADDRESS = "fly-local-6pn:8384"

[mounts]
  source="${VOLUME_NAME}"
  destination="/var/syncthing"

[[services]]
  internal_port = 22000
  protocol = "tcp"

  [[services.ports]]
    port = 22000

  [[services.tcp_checks]]
    grace_period = "1s"
    interval = "15s"
    restart_limit = 0
    timeout = "2s"

[[services]]
  internal_port = 22000
  protocol = "udp"

  [[services.ports]]
    port = 22000

[[services]]
  internal_port = 21027
  protocol = "udp"

  [[services.ports]]
    port = 21027
EOF

永続ボリュームを作成する

サーバーの再起動のたびにSyncthingが設定やデータを失わないように、永続ボリュームが必要です。

ボリュームサイズは自由に選べますが、執筆時点ではFly.ioが無料枠で3 GBを提供しています

SIZE_IN_GB="3" # This is the limit of fly.io's free tier as of 2023-05-24

fly volumes create "${VOLUME_NAME}" \
  --region "${REGION}" \
  --size "${SIZE_IN_GB}" \
  --yes

サーバーをデプロイする

最後に、アプリをデプロイします。SyncthingのためにIPv4アドレスを購入する必要はないので、--no-public-ipsフラグを追加できます。

fly deploy --no-public-ips

すべてがうまくいけば、次のようなメッセージが表示されるはずです。

No machines in group app, launching a new machine
  Machine e286537dbd3586 [app] update finished: success
Finished launching new machines
Updating existing machines in 'syncthing-ccdb2x' with rolling strategy
  Finished deploying

クラウド上のSyncthingサーバーをピアとして追加する

Syncthingサーバーが起動したら、接続するためにデバイスIDが必要になります。サーバーのログを確認することで見つけられます。

$ fly logs | grep "My ID: "
2023-05-26T04:20:28Z app[e784e736c90283] ewr [info][GHLLB] 2023/05/26 04:20:28 INFO: My ID: GHLLBWT-QJ4LGHJ-RT43QUV-DWFRMGS-5OTXHGH-LAZAIMG-HQ3TVAE-UUC2SA5

ローカルのSyncthingデバイスでは、「Add Device(デバイスを追加)」をクリックしてデバイスIDを入力することで、クラウドのSyncthingサーバーを追加します。サーバーには任意のデバイス名を付けられます。私は、実に創造性に富んだcloud-syncthingという名前にしました。

デバイスID GHLLBWT-QJ4LGHJ-RT43QUV-DWFRMGS-5OTXHGH-LAZAIMG-HQ3TVAE-UUC2SA5 でデバイスを追加する画面を示すスクリーンショット

クラウドのSyncthingサーバーを「Untrusted(信頼できない)」として扱うことで、セキュリティを向上させることができます。これにより、他のデバイスはデータを暗号化してからサーバーに送信するようになります。万が一Fly.ioサーバーが攻撃者に侵害されても、入手されるのは判読不能な暗号化されたデータだけになります。

デバイス追加 > 詳細設定でUntrustedチェックボックスをオンにする画面を示すスクリーンショット

最後に、フォルダーの1つを新しいSyncthingサーバーと共有します。フォルダーの編集 > 共有に移動し、新しいピアのチェックボックスをオンにします。信頼できないデバイスとしてマークした場合は、データを暗号化するための強力なパスフレーズを設定してください。

フォルダーの編集 > 共有でcloud-syncthingのチェックボックスをオンにし、パスワードを追加する画面を示すスクリーンショット

Web UIにアクセスする

Fly.ioサーバー上のSyncthing管理ダッシュボードにアクセスするには、ローカルのポート8388をFly.ioサーバーのポート8384に接続するプロキシを開きます。

fly proxy 8388:8384

プロキシを設置すれば、ローカルデバイスからlocalhostのURL経由でクラウドサーバー上のSyncthingダッシュボードにアクセスできるようになります。

次のような管理ダッシュボードが表示されるはずです。

fly.io上のSyncthingダッシュボードのスクリーンショット

おめでとうございます!Syncthingサーバーをクラウドにデプロイし、完全にアクセスできるようになりました。ここからは、Syncthingを実行している他のデバイスと同じように設定できます。

Fly.ioサーバー上でのSyncthingの設定が完了したら、Ctrl+Cfly proxyコマンドを終了してください。

原文は Michael Lynch により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。