Dockerを使わずに SFTP/FTPS クライアントを E2E テストする(Go)
ある CLI ツールに、ビルド成果物をサーバへアップロードする機能を足しました。転送方式は SFTP(SSH 経由)と FTPS(TLS 付きの FTP)の2つ。動いてほしいのは「接続 → 認証 → ディレクトリ作成 → ファイル転送」という一連の流れなので、関数単体のテストだけでは心もとない。実際にサーバへつないで往復させる、いわゆる E2E テストが欲しくなりました。
素直に考えると Docker で vsftpd や sftpgo を立てたくなります。でも、やってみる前から気が重い理由がいくつかありました。
- テスト実行に Docker デーモンが前提になる(手元でも CI でも)。
- FTP の パッシブモード はデータ転送用に別ポートを開く。これがコンテナのポートマッピングと相性が悪く、設定が一気に面倒になる。
- そもそもテスト1つのためにインフラが散らかる。
結論を先に書くと、サーバ自体を Go のテストプロセス内(in-process)に立てることで、Docker なしで両方とも E2E テストできました。しかも片方は新しい依存すら増えません。この記事はそのやり方と、途中で踏んだデッドロックの話です。
Docker と in-process の比較
| Docker(vsftpd 等) | in-process Go サーバ | |
|---|---|---|
| デーモン | 必要 | 不要 |
| 起動の速さ | コンテナ起動待ち | プロセス内で即時 |
| FTP パッシブポート | マッピング設定が必要 | localhost で無痛 |
| 実装の手間 | compose を書く | サーバを数十行 |
| 実サーバ固有の挙動 | 踏める | 踏めない(後述) |
最後の行が唯一の弱点で、ここは後ろで触れます。それ以外は in-process が素直でした。
SFTP:SSH サーバを立てて pkg/sftp に話させる
SFTP は SSH の上で動くサブシステムです。Go なら、クライアント側で普段使う golang.org/x/crypto/ssh と github.com/pkg/sftp を、そのまま サーバ側にも 使えます。つまりテスト用に新しい依存は増えません。
127.0.0.1:0(ポート0=OS に空きを選ばせる)で待ち受け、パスワード認証だけ通します。
// テスト用の SSH サーバを立て、待ち受けアドレスを返す
func newSSHServer(t *testing.T, user, pass string) string {
config := &ssh.ServerConfig{
PasswordCallback: func(c ssh.ConnMetadata, p []byte) (*ssh.Permissions, error) {
if c.User() == user && string(p) == pass {
return nil, nil
}
return nil, fmt.Errorf("auth failed")
},
}
config.AddHostKey(testHostKey(t)) // ed25519 をその場で生成
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go serve(conn, config)
}
}()
t.Cleanup(func() { _ = ln.Close() })
return ln.Addr().String()
}
接続が来たら SSH ハンドシェイクを済ませ、session チャネルの sftp サブシステム要求にだけ「OK」を返して、あとは pkg/sftp のサーバにファイルシステムを喋らせます。
func serve(conn net.Conn, config *ssh.ServerConfig) {
sconn, chans, reqs, err := ssh.NewServerConn(conn, config)
if err != nil {
return
}
defer sconn.Close()
go ssh.DiscardRequests(reqs)
for ch := range chans {
if ch.ChannelType() != "session" {
_ = ch.Reject(ssh.UnknownChannelType, "")
continue
}
channel, requests, _ := ch.Accept()
go func() {
for req := range requests {
_ = req.Reply(req.Type == "subsystem", nil) // sftp 要求に OK
}
}()
server, _ := sftp.NewServer(channel)
go func() {
_ = server.Serve() // クライアントが切るまでブロック
_ = channel.Close() // ★ これが無いとクライアントがハングする(後述)
}()
}
}
sftp.NewServer は実ファイルシステムを提供するので、リモートのアップロード先には t.TempDir() の絶対パスを渡せば、転送結果をそのままディスク上で検証できます。
テスト本体は、本番コードのクライアント(ここでは sftpDeployer)を丸ごと叩きます。ssh.Dial → パスワード認証 → アップロードまで、本番と同じ経路が通ります。
d := &sftpDeployer{
addr: addr,
sshConfig: &ssh.ClientConfig{
User: "deploy",
Auth: []ssh.AuthMethod{ssh.Password("secret")},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // テストだけ。実運用は検証する
},
remoteDir: t.TempDir(),
}
if _, err := d.Deploy(srcDir, func(string) {}); err != nil {
t.Fatal(err)
}
// srcDir のツリーが remoteDir に再現されたかを検証
ホスト鍵の検証はテストでは ssh.InsecureIgnoreHostKey() で省きました。鍵ピンニングのロジックは別途、純粋な関数として単体テストするほうが扱いやすいです。
ハマりどころ:アップロードは成功、なのにテストがハングする
最初に書いたとき、テストが 60 秒のタイムアウトで落ちました。デッドロックです。go test -timeout が吐く goroutine ダンプを読むと、面白いことが分かりました。
アップロード自体は成功していた。 止まっていたのは後片付け、t.Cleanup の中で呼ぶ sftp.Client.Close() でした。
Close() は内部の受信ゴルーチン(サーバからの応答を読み続ける係)が終わるのを WaitGroup で待ちます。ところがその受信ゴルーチンは、チャネルから EOF(終端) を受け取れず、永遠に読み続けていました。
原因は pkg/sftp のサーバ側にありました。Server.Serve() は、渡された接続(io.ReadWriteCloser)を 自分では閉じません。「接続の所有者は呼び出し側」という設計だからです。私の最初のコードは server.Serve() を呼びっぱなしで、終わってもチャネルを閉じていなかった。結果、こうなります。
| 待っている側 | 待っている相手 | 解放される条件 |
|---|---|---|
クライアントの Close() |
自分の受信ゴルーチン | 受信が EOF を受け取る |
| 受信ゴルーチン | サーバがチャネルを閉じる | サーバが Serve() 後に Close する |
サーバの Serve() |
クライアントが切断する | クライアントの Close() が完了する |
三者が輪になって互いを待っています。クライアントはサーバの切断を待ち、サーバはクライアントの切断を待つ——典型的なデッドロックでした。
直し方は一行です。サーバ側で Serve() が戻った 直後にチャネルを閉じる。
go func() {
_ = server.Serve()
_ = channel.Close() // 受信側に EOF が届き、クライアントの Close() が返る
}()
恥ずかしながら、最初は配線(パイプの繋ぎ方)を疑って io.Pipe を os.Pipe に変えたりしました。が、まったく直らない。落ち着いて pkg/sftp 自身のテストを読むと、向きこそ同じ io.Pipe 配線で動いていて、違いは後片付けの作法だけでした。問題は配線ではなく、サーバが転送終了後に接続を閉じていないことだったわけです。ライブラリの「誰が接続を閉じる責任を持つか」を読み違えると、こういう静かなハングになります。
FTPS:ftpserverlib を立て、証明書はその場で作る
FTPS には標準ライブラリだけのサーバはないので、テスト用に github.com/fclairamb/ftpserverlib を使いました。ドライバのインターフェースを最小実装すれば、in-process で立ちます。
type driver struct {
user, pass string
fs afero.Fs
tls *tls.Config
}
func (d *driver) GetSettings() (*ftpserver.Settings, error) {
return &ftpserver.Settings{
ListenAddr: "127.0.0.1:0",
TLSRequired: ftpserver.ClearOrEncrypted, // AUTH TLS への昇格を許可
// PassiveTransferPortRange を nil にすると、エフェメラルポートが使われる
}, nil
}
func (d *driver) GetTLSConfig() (*tls.Config, error) { return d.tls, nil }
func (d *driver) AuthUser(_ ftpserver.ClientContext, u, p string) (ftpserver.ClientDriver, error) {
if u == d.user && p == d.pass {
return d.fs, nil // ClientDriver は afero.Fs そのもの
}
return nil, errors.New("bad credentials")
}
func (d *driver) ClientConnected(ftpserver.ClientContext) (string, error) { return "ok", nil }
func (d *driver) ClientDisconnected(ftpserver.ClientContext) {}
ポイントが2つあります。
ひとつは 証明書。テストごとに ed25519 の自己署名証明書をその場で生成し、サーバに持たせます。クライアント側は InsecureSkipVerify で受け入れる(自己署名の検証パスは別テストで扱う)。固定の証明書ファイルをリポジトリに置かずに済みます。
もうひとつが、さっき名前を出した パッシブモードのポート。PassiveTransferPortRange を nil にすると、ftpserverlib はデータ転送のたびに OS の空きポート(エフェメラルポート)を使います。そしてクライアント(github.com/jlaffaye/ftp)は EPSV を使うので、データ接続先のホストには制御接続と同じ IP を流用します。両者が噛み合って、localhost ではポート設定がまったく要りません。Docker でいちばん面倒だったところが、in-process だと何もしなくていい——これが効きました。
ファイルシステムは afero.NewBasePathFs(afero.NewOsFs(), t.TempDir()) で一時ディレクトリに根を張れば、転送結果をそのまま検証できます。
テスト専用ライブラリは配布物から外す
FTPS のサーバライブラリはテストでしか使いません。*_test.go からだけ import していれば製品バイナリには入りませんが、念のため確認しておきます。
# 非テストビルドの依存を列挙。サーバ lib が出てこなければ配布物に入っていない
go list -deps . | grep -E 'ftpserverlib|afero'
何も出力されなければ意図どおりです。
in-process で届かないところ
in-process サーバが保証してくれるのは、クライアントとサーバーがプロトコルレベルで正しく通信できること までです。実在のサーバ実装(vsftpd や ProFTPD など)固有のクセや、FTPS のデータ接続での TLS セッション再利用のような検証までは出来ません。
つまり in-process サーバはテスト環境の構築を軽くしてくれますが、検証できるのは境界面——両者の通信が噛み合うところ——までです。実サーバ固有の挙動までは届かないので、最終的には手動でのテストがどうしても必要になります。日々の回帰は in-process で軽く回し、実機での確認は最後に一度、という分担に落ち着きました。
同じように「ファイル転送クライアントをどうテストしよう」で手が止まっている方の、回り道を一つ省けたら幸いです。とくにあのデッドロック——接続を閉じる責任が誰にあるかは、ハングしてから気づくより、先に知っておくほうがずっと楽です。