Cyberduck や scpsftp で Linux サーバーへファイルをアップロードするとき、転送が終わるまで対象ディレクトリには何も見えないと思っていた。

でも実際には、アップロード開始直後から転送先にファイルが作られ、そこへ少しずつデータが書き込まれていくことがある。大きなファイルを送ると、サーバー側で ls -lh したときにサイズが増えていく様子が見える。

これ自体は普通の挙動だが、知らないと少し危ない。

たとえば、アップロード先を別のバッチ処理やファイル監視デーモンが見ている場合、まだ途中のファイルを「完成したファイル」として読み込んでしまう可能性がある。jar、画像、CSV、バックアップファイルなど、読み込み側がファイル名だけで判断していると起きやすい。

PCがscpでアップロードすると転送中からファイルがサーバーに存在し、バッチ処理が未完成ファイルを読んでしまう図

WinSCP(Windows 向けの SFTP/SCP クライアント)はデフォルトでこの問題を回避している。転送中は内部で一時ファイル名を使い、完了後に正式名へリネームする仕組みが組み込まれているためだ。一方、macOS の Cyberduck は有料課金していてもこの挙動にはなっておらず、普通にアップロードすると転送中から正式名でファイルが見える。

WinSCPは一時ファイル経由で安全に転送するが、scpはすぐに正式ファイル名で出現する比較図

アップロード中のファイルは見える

scpsftpput は、基本的には転送先のファイルを開いて、そこへデータを書いていく操作だ。

そのため、転送中は次の状態になりうる。

  • 転送完了前のファイルが、不完全なサイズで存在する
  • 転送に失敗すると、中途半端なファイルとして残ることがある
  • 監視処理やバッチ処理が、そのファイルを先に読んでしまうことがある

確認してみる

挙動は、大きめのダミーファイルを送るとすぐ確認できる。

まず手元の Mac などで、500MB のファイルを作る。

dd if=/dev/urandom of=testfile.bin bs=1M count=500

サーバー側では、転送先ディレクトリを監視しておく。

watch -n 0.5 'ls -lh /path/to/target/'

別ターミナルから scp でアップロードする。

scp testfile.bin user@hostname:/path/to/target/

サーバー側の watchtestfile.bin が現れ、サイズが少しずつ増えていけば、転送中のファイルが見えている。

確認が終わったら、ダミーファイルを削除する。

# 手元
rm testfile.bin

# サーバー側
rm /path/to/target/testfile.bin

supコマンドで安全に転送する

対策の考え方はシンプルで、最初から正式名で置かないことだ。一時ファイル名でアップロードし、完了後にアトミックな mv で正式名に切り替える。

scp file.jar user@host:/app/file.jar.tmp
ssh user@host 'mv /app/file.jar.tmp /app/file.jar'

mv は同じファイルシステム内であれば基本的にアトミックに名前を差し替える。読み込み側から見ると、file.jar は古い完全なファイルか新しい完全なファイルのどちらかになり、途中の状態は見えない。

一時ファイルと正式ファイルは同じファイルシステム上に置く。/tmp/app が別ファイルシステムの場合、mv がコピー相当の動きになることがある。正式ファイルと同じディレクトリで一時名を使うのが無難だ。

ただし、このままだと SSH のログインが2回発生する。SSH ControlMaster を使うと最初の1接続を使い回せる。この一連の処理を supsafe upload の略)というスクリプトにまとめた。

supの動き:scpで.tmpにアップロードしてからmvでアトミックにリネームする図

aragig/shell-toolbox — sup

# インストール(~/bin に置く場合)
mkdir -p ~/bin
curl -fsSL https://raw.githubusercontent.com/aragig/shell-toolbox/main/sup/sup.sh \
  -o ~/bin/sup
# 中身を確認してから実行可能にする
chmod +x ~/bin/sup
sup file.jar user@host:/path/to/file.jar

サーバーに追加のソフトウェアは不要で、scpssh があれば動く。

制約:1ファイル専用

正直なところ、ワイルドカード展開やディレクトリのアップロードまで対応しようとしたが、自作レベルでは1ファイルのアップロードが精一杯だった。

複数ファイルをループで回すことはできる。

for f in *.jar; do sup "$f" user@host:/path/to/; done

ただしワイルドカードをそのまま渡すのは非対応で、ディレクトリのアップロードも sup では扱えない。そういう場合は rsync か手動対応になる。

複数ファイル・ディレクトリの場合

rsyncが使える場合

サーバーに rsync がインストールされているなら素直にそちらを使う。rsync はデフォルトで転送中のデータを一時ファイルへ書き込み、完了後に正式名へ rename するため、scp より安全に扱いやすい。

# サーバー側に rsync があるか確認
ssh user@host "which rsync"
rsync -avz --progress --partial-dir=.rsync-partial localfile.jar user@host:/path/to/target/

--delete は付けない。転送元にないファイルを転送先から削除するオプションで、単純なアップロード用途では不要だ。

rsyncが使えない場合

rsync がない環境では、手元でリネームしてから転送し、サーバー側で mv する方法が使える。

cp deploy.tar.gz deploy.tar.gz.tmp
scp deploy.tar.gz.tmp user@host:/path/to/deploy.tar.gz.tmp
ssh user@host 'mv /path/to/deploy.tar.gz.tmp /path/to/deploy.tar.gz'
rm deploy.tar.gz.tmp

sup と同じ考え方を手動でやっているだけだ。転送中は .tmp ファイルがサーバーに現れるが、正式ファイル名では未完成のファイルが見えない点は変わらない。ファイルの存在を監視する処理が .tmp を拾わない設定になっていることが前提になる。

まとめ

scp でそのままアップロードすると、転送中の正式ファイル名がサーバー上に出現する。ファイルの存在をトリガーにする処理がある環境では、未完成のファイルを読まれる可能性がある。

sup を使うと、正式ファイル名では未完成のファイルが見えなくなる。サーバーに追加のソフトウェアは不要で、1ファイルの転送であれば scpssh だけで動く。複数ファイルやディレクトリが必要なら rsync か手動リネームで対応する。

方法転送中のファイルサーバー要件複数ファイル
scp そのまま正式名で存在する(危険)なし
sup(scp + ControlMaster)一時名のみ(安全)なし1ファイルのみ
rsync一時名のみ(安全)rsync 必要
手動リネーム→scp→mv一時名のみ(安全)なし手動

ファイルを置いた瞬間に別の処理が読む場所では、「アップロードできたか」だけでなく、「アップロード中に何が見えているか」も確認しておくと事故を減らせる。

参考