nato243 weblog.n-jitter brand iconweblog.n-jitter
テクノロジー

SFTPサーバー外形監視をPure Goでデーモン化する (pkg/sftp , go-co-op/gocron)

2024.12.03
Golang
SFTPサーバー外形監視をPure Goでデーモン化する (pkg/sftp , go-co-op/gocron) アイキャッチ

はじめに

naoto243と申します。この記事はGo Advent Calendar 2024 の4日目の記事となります。
表題通り、この記事ではSFTPサーバーへのアクセスとフォルダの存在確認、gocronによるデーモン化を行います。

言ってみればやっていることはそれだけなのですが、pure goの事例としてお読みください。

事象: 節約型SFTPサーバーでS3マウントが切れる

仕事で、AWS Lightsail上でSFTPコンテナを運用しているサーバーがあります。
さらにこのコンテナは Mountpoint for Amazon S3 というマウントツールでS3マウントしています。

本来ならSFTP+S3に向いたスケーラブルな専用ソリューションがAWSにはあるのですが、運用するには割と高いのです...。
そこで苦肉の策として、自前のSFTPサーバーとS3マウントツールを用いているわけですね。
さらにLightsailで運用しているのは、データ転送の下り料金がある程度無視できる、というこれまたケチった事情があったりします。

さて、このサーバーですが、ある日突然に繋がらない、という報告がありました。


調査すると、正確にはSFTPサーバーにはつながるが目的のフォルダが見当たらない、ということでした。

こちらの原因そのものはMountpint for AmazonS3 のOOMエラーでした。
大量のDLリクエストがあるとスパイクするようです。
ただしデータ量というよりは、探査するS3オブジェクト数に起因するツールのバグのようです。(なんか他のS3マウントツールの時代もこんなことあったきがしますね)


ツールの更新 とswapの確保、いらないS3オブジェクトの削除でとりあえず対応しました。


この件を受けて、とりあえず監視は増やさなきゃね、ということになりました。
ただし前述の通りいろいろケチった温かみのあるサーバーですので、監視周りのロジックに各種マネージドサービスのアラームが利用できません。
そこで愚直に外形監視をECSのアプリとしてGoで書くことにしました。

SFTPサーバーへのアクセス本体

まずはSFTPサーバーへのアクセス部分です。

package api_monitor

// ApiMonitor
// api監視したり、ヘルスチェックを行うインターフェース
type ApiMonitor interface {

	// ...他の監視項目

	// CheckSFTPFolderAccess
	// SFTPサーバーのヘルスチェック
	// 接続およびちゃんと特定のフォルダが有るかチェックします
	CheckSFTPFolderAccess() error
}

サーバーへのアクセス可否だけでなく、特定のフォルダが確認できない(マウントできていない)とエラーを返しつつ、Slackにも報告します。
github.com/pkg/sftp が定石のようですので、これをつかってアクセスしました。

package api_monitor

import (
	"fmt"
	"github.com/pkg/sftp"
	"github.com/wano/contextlog/clog"
	"golang.org/x/crypto/ssh"
	"golang.org/x/xerrors"
	"runtime/debug"
	"time"
)

// SftpAccessCheckConfig はSFTP接続に必要な設定を保持する構造体
type SftpAccessCheckConfig struct {
	Host     string
	Port     string
	User     string
	Password string
	Folder   string // 存在確認対象のフォルダ
}

func (self implApiMonitor) CheckSFTPFolderAccess() error {

	defer func() {
		if onPanic := recover(); onPanic != nil {
			err := xerrors.Errorf("recover: %v \n %v", onPanic, string(debug.Stack()))
			clog.Error(err)
		}
	}()

	sftpConf := self.sftpConfig
	err := self.checkSFTPFolderAccess(sftpConf)
	if err != nil {
		// 自前ツールによる Slack通知

		message := fmt.Sprintf(`
SFTP Serverにアクセスできない/ もしくは必要なフォルダがマウントされていません
`)

		// Slack通知
		_ = self.slackReporter.Fatal("SFTP folder check failed", message)

	}

	return nil

}

// checkSFTPFolderAccess は指定されたSFTPサーバーにアクセスし、特定フォルダの存在を確認します
// フォルダが存在しない場合やエラーが発生した場合はエラーを返します
func (self implApiMonitor) checkSFTPFolderAccess(config SftpAccessCheckConfig) error {
	// SSHクライアントの設定
	sshConfig := &ssh.ClientConfig{
		User: config.User,
		Auth: []ssh.AuthMethod{
			ssh.Password(config.Password),
		},
		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
		Timeout:         30 * time.Second,
	}

	// SSH接続を確立
	conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", config.Host, config.Port), sshConfig)
	if err != nil {
		return fmt.Errorf("SSH connection failed: %v", err)
	}
	defer conn.Close()

	// SFTPクライアントを作成
	client, err := sftp.NewClient(conn)
	if err != nil {
		return fmt.Errorf("SFTP client creation failed: %v", err)
	}
	defer client.Close()

	// フォルダの存在確認
	info, err := client.Stat(config.Folder)
	if err != nil {
		return fmt.Errorf("folder check failed: %v", err)
	}

	if !info.IsDir() {
		return fmt.Errorf("%s exists but is not a directory", config.Folder)
	}

	return nil
}

cron化 (gocron)

main.goでは以上のアプリのDIとgo-co-op/gocron によるデーモン化を行っています。

ここは別に通常のcronでもAWS の Eventbridgeからのイベント発火での動作でも構いません。(そのほうがスマートかも知れません)

ですが、

  • あまり外部の要件を増やさずにAWS ECSで雑に動かしたい
  • graceful shutdownにうまく対応したい

というデーモン化自体もGoに閉じ込めてみたいという目的があり、利用に至りました。

package main

import (
	"fmt"
	"github.com/aws/aws-sdk-go/service/lambda"
	"github.com/go-co-op/gocron/v2"
	"github.com/wano/contextlog/clog"
	"github.com/xxxxxx/src/app/api_monitor"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {

	clog.Info(`Start Cron!`)
	jst, err := time.LoadLocation("Asia/Tokyo")
	if err != nil {
		panic(err)
	}

	// gocronのインスタンスを生成
	s, err := gocron.NewScheduler(
		gocron.WithLocation(jst),
	)
	if err != nil {
		fmt.Printf("onFailed to create scheduler: %s", err)
		log.Panic(err)
	}

	// SFTPサーバーのヘルスチェック設定
	{
		sftpServerLivingCheck := func() {

			apiMonitor := inject() // 先程のアプリをDIする関数だと思ってください
			err := apiMonitor.CheckSFTPFolderAccess()
			if err != nil {
				fmt.Println(err.Error())
			}

		}

		_, _ = s.NewJob(
			gocron.DurationJob(
				30*time.Minute,
			),
			gocron.NewTask(
				func(a string, b int) {
					sftpServerLivingCheck()

				},
				"SFTP Living Check",
				1,
			),
			gocron.WithStartAt(gocron.WithStartImmediately()), // gocronを実行してから すぐに1回実行
		)

	}

	// SIGTERMとSIGINTのシグナルを待機するチャネルを作成
	signals := make(chan os.Signal, 1)
	signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)

	// スケジュールを開始
	go s.Start()

	// シグナルを待機
	<-signals
	fmt.Println(`プロセス終了シグナルを受信しました。終了処理を開始します。`)
	// スケジュールを停止 & ジョブ完了待ち
	//  It waits for all running jobs to finish before returning, so it is safe to assume that running jobs will finish when calling this.
	stopErr := s.StopJobs()
	if stopErr != nil {
		fmt.Printf("onFailed to stop jobs: %s", stopErr)
	}
	err = s.Shutdown()
	if err != nil {
		fmt.Printf("onFailed to shutdown scheduler: %s", err)
		panic(err)
	}

	fmt.Println("プロセス正常終了")
	os.Exit(0)

}

gocronはSchedulerにJob/Taskという形でいくつも独立した処理を追加できます。
他に今回のようなcron化したいものがあれば同じスケジューラーにこれらを追加していくことになるかと思います。


時間設定の機能としては、crontabの記法DaiyJobみたいなのも利用可能ですし、JST設定もできます!
閉じるときはStopJobs関数で、現行ちょうど並列稼働してるジョブの終了待受も可能です。つまり、graceful shutdownにも優しいです。

最後に

最終的にgocronのススメみたいになってしまいましたが、これで監視ツールの完成です。
簡単ですが本稿を終わりにさせていただきます。

明日はHidekazu-Karinoさんの記事となります。