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() は内部の受信ゴルーチン(サーバからの応答を読み続ける係)が終わるのを 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.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 で軽く回し、実機での確認は最後に一度、という分担に落ち着きました。

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