curl の FTPS デプロイが通らない|530・451・反映なしの実録

Windows の curl で共用ホスティングへ FTPS デプロイすると、詰まる場所はだいたい決まっている。530 Login authentication failed でログインできない、一部のファイルだけ 451 Error during read from data connection で落ちる、転送は全件成功したのに本番が古いまま、自動応答が無音で終わる。原因は FTP そのものではなく、cmd.exe の変数展開・TLS のバージョン・FTP アカウントのルートにあった。4件目だけは原因が記録に残っていない。デプロイスクリプトに残る実録である。

そもそも FTPS は主経路ではない

deploy.bat の主経路は SSH で、コメントにも「no flaky FTPS data channel」とある。FTP 経路を書いたのは 2026-07-17、deploy.bat uploadConnection refused で通らなくなった日だ。ホスト・ポート・鍵・IP 制限を確認しても解消しない。原因はクライアント側になく、ConoHa WING が CVE-2026-43499(Linux カーネルの権限昇格脆弱性)対応として SSH 接続を一時的に禁止していたのだった。

FTPS はこのときのフォールバックで、Windows 10/11 組み込みの curl に --ftp-create-dirs を付け1ファイルずつ上げる。以下の3件は同じ日に踏んだものだ。

530 Login authentication failed — パスワードの ! が壊れる

認証に失敗すると、サーバは 530 Login authentication failed を返す(2026-09-04 に確認)。パスワードは合っているはずなのにこれが返るなら、疑うのは cmd.exe だ。アップロードは1ファイルずつのループで、ループ内の変数書き換えのため遅延展開(setlocal enabledelayedexpansion)を有効にしている。遅延展開は ! を特別扱いするので、パスワードに ! を含むと壊れる。

REM deploy.bat L158-164 から抜粋
REM Write credentials to a temp .netrc file instead of passing them on the
REM curl command line. This sidesteps two cmd.exe pitfalls: (1) delayed
REM expansion (needed later for the per-file upload loop) mangles any "!"
REM in FTP_PASS, and (2) plaintext creds would otherwise show up in the
REM process argument list. The file is deleted right after upload.
set "FTP_NETRC=%TEMP%\doand_deploy_netrc_%RANDOM%.txt"
> "%FTP_NETRC%" echo machine %FTP_HOST% login %FTP_USER% password %FTP_PASS%

対処は遅延展開を切ることではない。ループのほうがそれを必要としている。認証情報をコマンドラインから外し、一時的な netrc ファイルに書いて --netrc-file で読ませる。作るのは遅延展開が有効になる前のスコープで、アップロード後すぐ削除する。コメントの二つ目の理由も効く。コマンドラインの認証情報は引数一覧に平文で並ぶからだ。

451 Error during read from data connection — TLS 1.2 に固定する

2件目が厄介だった。全ファイルではなく一部だけ、しかも断続的に落ちる。サーバは Pure-FTPd で、接続時のバナーの1行目が 220---------- Welcome to Pure-FTPd [privsep] [TLS] ---------- である(2026-09-04 に確認。続く行は省略)。

REM deploy.bat L192-199 から抜粋
setlocal enabledelayedexpansion
set "CURL_OPTS=--ftp-create-dirs -sS --show-error --netrc-file "%FTP_NETRC%""
REM Force TLS 1.2: Pure-FTPd validates that the data-channel TLS session
REM matches the control channel via the (pre-1.3) session-ID mechanism,
REM which doesn't map onto TLS 1.3's ticket-based resumption. Left at 1.3
REM this intermittently fails with "451 Error during read from data
REM connection" on some (not all) files, depending on session reuse timing.
if "%FTP_USE_SSL%"=="1" set "CURL_OPTS=%CURL_OPTS% --ssl-reqd --tlsv1.2 --tls-max 1.2"

コメントは原因を Pure-FTPd の検証方式に求めている。データチャネルの TLS セッションが制御チャネルと一致するかの検証が TLS 1.3 より前のセッション ID 方式に依るため、1.3 のチケットによる再開と噛み合わない——というのが、コメントに残した理解である。対処は --tlsv1.2 --tls-max 1.2 を付けて TLS 1.2 に固定すること。コメントの「Force TLS 1.2」がそれである。

転送は全件成功、サイトは古いまま

3件目はエラーが1件も出ない。ループは全ファイルを上げきり [ftp-upload] done. と表示する。それでも本番は古いままだった。原因は、FTP のパスと SSH のパスを同じものとして扱っていたこと。SSH 経路の REMOTE_DIR はサーバ上の絶対パスだが、FTP のアップロード先は FTP アカウント自身のルートからの相対パスである。

REM tools\deploy.config.example.bat L35-44 から抜粋
REM  FTP_DIR = upload path *relative to the FTP account's own root* -
REM  this is NOT the same as REMOTE_DIR above. Many hosts land an FTP login
REM  inside what is, over SSH, /home/<user>/ - so if you reuse the SSH
REM  absolute path here, curl double-nests into a bogus home/<user>/...
REM  tree instead of the real docroot. Verify with an anonymous listing:
REM      curl --ssl-reqd --user "USER:PASS" "ftp://HOST:21/"
REM      curl --ssl-reqd --user "USER:PASS" "ftp://HOST:21/public_html/"
REM  ... and find the folder that actually holds index.php for your site.
REM  Leave FTP_DIR empty ("set "FTP_DIR="") if the FTP login already lands
REM  inside the docroot.

このホストでは FTP ログインが、SSH から見ると /home/<user>/ にあたる位置に着地する。そこへ SSH の絶対パスを渡せば、home/<user>/... が二重にネストした偽のツリーができる。しかも --ftp-create-dirs が付いているので、存在しないディレクトリは黙って作られ転送は成功する。本物のドキュメントルートには何も届かない。FTP_DIRREMOTE_DIR と別のキーに分けてあるのはこのためだ。確認用の一覧取得は手作業の一回限りで、--user を直接渡している。

確認プロンプトの自動応答が無音で終わる

最後の1件は、先の3件より1か月以上あとの 2026-09-04 に追記された。deploy.bat は上書き前に YES の入力を求める。これを Git Bash から echo YES | cmd.exe /c deploy.bat ftp で自動応答させると、無音で終わる。ハングではない。

REM deploy.bat L23-29 から抜粋
REM  Auto-confirm from Git Bash: `echo YES | cmd.exe /c deploy.bat ftp` is UNRELIABLE
REM  (observed 2026-09-04: exits 0 after ~1s printing only repeated "y", :build never
REM  runs, dist\ stays stale - a silent no-op, not a hang). Use PowerShell with stdin
REM  redirected from a file instead:
REM    cmd.exe /c "C:\full\path\deploy.bat ftp < C:\full\path\confirm.txt"
REM  where confirm.txt contains a single line "YES" (CRLF). Call deploy.bat by its
REM  absolute path (it cd's via %~dp0 itself) rather than chaining "cd /d ... &&".

コメントは原因までは書いていない。あるのは観測と回避策である。回避策は、PowerShell から stdin をファイルにリダイレクトし、confirm.txtYES の1行を CRLF で置き、絶対パスで呼ぶこと。終了コード 0 は成功に見えるので、これも3件目と同じ「デプロイしたのに変わらない」に化ける。

よくある質問

平文 FTP にすれば早いのでは。 設定テンプレートは FTP_USE_SSL=1 の明示的 FTPS を推奨とし、0 にしてよいのはホストに TLS が本当に無い場合だけとしている。手間のために平文の経路を選ぶ理由はない。

パスワードから ! を消せば netrc は要らないのでは。 1件目だけなら消せる。ただし認証情報が引数一覧に平文で現れる問題は、記号の選び方では消えない。

SSH が復旧したら。 主経路は SSH のままで、制限が解ければ deploy.bat upload に戻す。Connection refused に対し鍵の作り直しやネットワーク診断を繰り返す前に、まず事業者の障害情報を見る——これが 2026-07-17 の教訓である。

まとめ

4件のうち、エラーメッセージが出たのは2件だけだった。残る2件——本番が古いまま、無音の no-op——はどちらも「成功」を名乗る。FTPS デプロイで怖いのは 451 ではなく、成功に見える失敗のほうである。3件は対処だけでなく理由がコメントに残り、4件目は観測と回避策が残っている。判断の根拠を成果物に残す運用は検証ログの記事で別途扱った。