From 4af3ad2dde19cc1fce4e526ec53727a97d69d78a Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 14 Jun 2026 16:10:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 6 ++++ README.md | 17 +++++++++ cmd/jellybit/healthcheck.go | 51 ++++++++++++++++++++++++++ cmd/jellybit/healthcheck_test.go | 62 ++++++++++++++++++++++++++++++++ cmd/jellybit/main.go | 3 ++ 5 files changed, 139 insertions(+) create mode 100644 cmd/jellybit/healthcheck.go create mode 100644 cmd/jellybit/healthcheck_test.go diff --git a/Dockerfile b/Dockerfile index 94c0cdf..1e82273 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,10 @@ FROM gcr.io/distroless/static-debian12 COPY jellybit /usr/local/bin/jellybit EXPOSE 8080 + +# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из +# /data/config.toml). compose может переопределить параметры healthcheck. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/usr/local/bin/jellybit", "healthcheck"] + ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"] diff --git a/README.md b/README.md index 5f185ea..c98fa00 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,20 @@ task image # docker-образ из готового Сборка здесь → готовый бинарь копируется на медиа-сервер umbar (`/home/av/projects/private/umbar`). Деплой-обвязка живёт в umbar. + +Артефакты этого репозитория: статический бинарь (`task build`) и `Dockerfile` +(упаковка в `distroless/static`). Образ собирается **на сервере** из +доставленного бинаря — Go-тулчейн на сервере не нужен. В distroless нет +shell/curl, поэтому HEALTHCHECK зовёт сам бинарь: `jellybit healthcheck` +(GET `/healthz` по порту из конфига, exit 0/1). + +Деплой одной командой из umbar (собирает бинарь локально, доставляет, строит +образ на сервере, рендерит конфиг с секретами из vault, поднимает compose): + +```sh +inv pl -- jellybit # umbar/playbook-jellybit.yml +``` + +Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая +песочница для хардлинков) + data-том с `config.toml`/SQLite, к qBittorrent — +через `host.docker.internal`. diff --git a/cmd/jellybit/healthcheck.go b/cmd/jellybit/healthcheck.go new file mode 100644 index 0000000..3cd9732 --- /dev/null +++ b/cmd/jellybit/healthcheck.go @@ -0,0 +1,51 @@ +package main + +import ( + "context" + "flag" + "fmt" + "net" + "net/http" + "time" + + "git.vakhrushev.me/av/jellybit/internal/config" +) + +// runHealthcheck дёргает /healthz локального сервиса и завершается с кодом 0 +// при 200, иначе ненулевым. Нужен для HEALTHCHECK в distroless-образе, где +// нет shell/curl: docker зовёт сам бинарь. +func runHealthcheck(args []string) error { + fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError) + configPath := fs.String("config", "/data/config.toml", "путь к config.toml") + if err := fs.Parse(args); err != nil { + return err + } + + cfg, err := config.Load(*configPath) + if err != nil { + return err + } + + // listen вида ":8080" или "127.0.0.1:8080" → стучимся на localhost:. + _, port, err := net.SplitHostPort(cfg.HTTP.Listen) + if err != nil { + return fmt.Errorf("parse http.listen %q: %w", cfg.HTTP.Listen, err) + } + url := "http://127.0.0.1:" + port + "/healthz" + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("healthcheck request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("healthcheck: status %d", resp.StatusCode) + } + return nil +} diff --git a/cmd/jellybit/healthcheck_test.go b/cmd/jellybit/healthcheck_test.go new file mode 100644 index 0000000..848a315 --- /dev/null +++ b/cmd/jellybit/healthcheck_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "testing" +) + +// serveHealthz поднимает http-сервер на свободном порту, отдавая указанный +// статус на /healthz. Возвращает порт и стоп-функцию. +func serveHealthz(t *testing.T, status int) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + }) + srv := &http.Server{Handler: mux} + go func() { _ = srv.Serve(ln) }() + t.Cleanup(func() { _ = srv.Close() }) + return ln.Addr().(*net.TCPAddr).Port +} + +func writeConfig(t *testing.T, port int) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + content := "[http]\nlisten = \"127.0.0.1:" + strconv.Itoa(port) + "\"\n" + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestHealthcheck_OK(t *testing.T) { + port := serveHealthz(t, http.StatusOK) + if err := runHealthcheck([]string{"--config", writeConfig(t, port)}); err != nil { + t.Errorf("healthcheck: %v", err) + } +} + +func TestHealthcheck_BadStatus(t *testing.T) { + port := serveHealthz(t, http.StatusServiceUnavailable) + if err := runHealthcheck([]string{"--config", writeConfig(t, port)}); err == nil { + t.Error("ожидалась ошибка при 503") + } +} + +func TestHealthcheck_NoServer(t *testing.T) { + // Порт, на котором никто не слушает (берём свободный и закрываем). + ln, _ := net.Listen("tcp", "127.0.0.1:0") + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + if err := runHealthcheck([]string{"--config", writeConfig(t, port)}); err == nil { + t.Error("ожидалась ошибка без сервера") + } +} diff --git a/cmd/jellybit/main.go b/cmd/jellybit/main.go index 2657fe8..24008eb 100644 --- a/cmd/jellybit/main.go +++ b/cmd/jellybit/main.go @@ -4,6 +4,7 @@ // // jellybit [serve] --config запустить сервис (по умолчанию) // jellybit add [--context] добавить загрузку через REST API сервиса +// jellybit healthcheck --config

проверить /healthz (для docker HEALTHCHECK) package main import ( @@ -27,6 +28,8 @@ func main() { err = runServe(args) case "add": err = runAdd(args) + case "healthcheck": + err = runHealthcheck(args) default: _, _ = os.Stderr.WriteString("unknown command: " + cmd + "\n") os.Exit(2)