Dockerを使わずに SFTP/FTPS クライアントを E2E テストする(Go)

ある CLI ツールに、ビルド成果物をサーバーへアップロードする機能を足しました。転送方式は SFTP(SSH 経由)と FTPS(TLS 付きの FTP)の2つ。動いてほしいのは「接続 → 認証 → ディレクトリ作成 → ファイル転送」という一連の流れなので、関数単体のテストだけでは心もとない。実際にサーバーへつないで往復させる、いわゆる E2E テストが欲しくなりました。

素直に考えると Docker で vsftpdsftpgo を立てたくなります。でも、やってみる前から気が重い理由がいくつかありました。

結論を先に書くと、サーバー自体を 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/sshgithub.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() は内部の受信 goroutine(サーバーからの応答を読み続ける係)が終わるのを WaitGroup で待ちます。ところがその受信 goroutine は、チャネルから EOF(終端) を受け取れず、永遠に読み続けていました。

原因は pkg/sftp のサーバー側にありました。Server.Serve() は、渡された接続(io.ReadWriteCloser)を 自分では閉じません。「接続の所有者は呼び出し側」という設計だからです。私の最初のコードは server.Serve() を呼びっぱなしで、終わってもチャネルを閉じていなかった。結果、こうなります。

待っている側 待っている相手 解放される条件
クライアントの Close() 自分の受信 goroutine 受信が EOF を受け取る
受信 goroutine サーバーがチャネルを閉じる サーバーが Serve() 後に Close する
サーバーの Serve() クライアントが切断する クライアントの Close() が完了する

三者が輪になって互いを待っています。クライアントはサーバーの切断を待ち、サーバーはクライアントの切断を待つ——典型的なデッドロックでした。

直し方は一行です。サーバー側で Serve() が戻った 直後にチャネルを閉じる

go func() {
    _ = server.Serve()
    _ = channel.Close() // 受信側に EOF が届き、クライアントの Close() が返る
}()

恥ずかしながら、最初は配線(パイプの繋ぎ方)を疑って io.Pipeos.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 で受け入れる(自己署名の検証パスは別テストで扱う)。固定の証明書ファイルをリポジトリに置かずに済みます。

もうひとつが、さっき名前を出した パッシブモードのポートPassiveTransferPortRangenil にすると、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 で軽く回し、実機での確認は最後に一度、という分担に落ち着きました。

同じように「ファイル転送クライアントをどうテストしよう」で手が止まっている方の、回り道を一つ省けたら幸いです。とくにあのデッドロック——接続を閉じる責任が誰にあるかは、ハングしてから気づくより、先に知っておくほうがずっと楽です。

☕ チップを送る