はじめに
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さんの記事となります。