feat: Dateibasierten Docker-Healthcheck hinzugefügt
All checks were successful
Build Test Docker Image / docker-test (pull_request) Successful in 1m49s
Run Tests / test (pull_request) Successful in 4m45s

This commit is contained in:
2026-03-29 20:41:51 +02:00
parent 766b69aa4a
commit 67c3f10454
5 changed files with 116 additions and 6 deletions

View File

@@ -9,5 +9,8 @@ ENTRYPOINT ["/mailcow-birthday-daemon"]
ENV STATEFILE=/data/state.json
VOLUME [ "/data" ]
HEALTHCHECK --interval=60s --timeout=5s --start-period=30s --retries=3 \
CMD ["/mailcow-birthday-daemon", "healthcheck"]
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY mailcow-birthday-daemon /mailcow-birthday-daemon

80
cmd/mcbdd/health.go Normal file
View File

@@ -0,0 +1,80 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// maxSyncAge ist die maximale Dauer seit dem letzten Sync-Lauf, bevor der
// Healthcheck den Daemon als unhealthy meldet. Da der Sync alle 15 Minuten
// läuft, erlauben wir 20 Minuten Toleranz.
const maxSyncAge = 20 * time.Minute
// healthFile ist der Dateiname der Healthcheck-Statusdatei, die neben dem
// State-File abgelegt wird.
const healthFile = "health.json"
// healthStatus wird als JSON in die Healthcheck-Datei geschrieben.
type healthStatus struct {
LastSync time.Time `json:"last_sync"`
LastError string `json:"last_error,omitempty"`
}
// healthState hält den aktuellen Health-Status im Speicher und schreibt
// ihn nach jedem Sync-Lauf in eine Datei.
type healthState struct {
mu sync.Mutex
filePath string
}
func newHealthState(stateFilepath string) *healthState {
dir := filepath.Dir(stateFilepath)
return &healthState{
filePath: filepath.Join(dir, healthFile),
}
}
// update wird nach jedem Sync-Lauf aufgerufen und schreibt den Status
// in die Health-Datei.
func (h *healthState) update(err error) {
h.mu.Lock()
defer h.mu.Unlock()
s := healthStatus{
LastSync: time.Now(),
}
if err != nil {
s.LastError = err.Error()
}
data, _ := json.Marshal(s)
os.WriteFile(h.filePath, data, 0644)
}
// runHealthcheck liest die Health-Datei und prüft, ob der Daemon healthy ist.
// Exit-Code 0 = healthy, 1 = unhealthy. Wird von Docker HEALTHCHECK aufgerufen.
func runHealthcheck() error {
stateFilepath := os.Getenv("STATEFILE")
if stateFilepath == "" {
stateFilepath = "state.json"
}
healthPath := filepath.Join(filepath.Dir(stateFilepath), healthFile)
data, err := os.ReadFile(healthPath)
if err != nil {
return fmt.Errorf("health file not found: %w (daemon may still be starting)", err)
}
var s healthStatus
if err := json.Unmarshal(data, &s); err != nil {
return fmt.Errorf("invalid health file: %w", err)
}
if s.LastError != "" {
return fmt.Errorf("last sync failed: %s", s.LastError)
}
if time.Since(s.LastSync) > maxSyncAge {
return fmt.Errorf("last sync too old: %s ago", time.Since(s.LastSync).Round(time.Second))
}
return nil
}

View File

@@ -37,15 +37,25 @@ type Daemon struct {
oldCalendarName string
notificationEnabled bool
notificationTrigger string
health *healthState
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "cleanup" {
if err := runCleanup(); err != nil {
slog.Error("cleanup failed", "err", err)
os.Exit(1)
if len(os.Args) > 1 {
switch os.Args[1] {
case "cleanup":
if err := runCleanup(); err != nil {
slog.Error("cleanup failed", "err", err)
os.Exit(1)
}
return
case "healthcheck":
if err := runHealthcheck(); err != nil {
slog.Error("healthcheck failed", "err", err)
os.Exit(1)
}
return
}
return
}
if err := run(); err != nil {
slog.Error("fatal error", "err", err)
@@ -101,6 +111,7 @@ func run() error {
if len(d.stateFilepath) == 0 {
d.stateFilepath = "state.json"
}
d.health = newHealthState(d.stateFilepath)
d.mailcowClient = mailcow.New(
d.httpClient,
mailcowBase,
@@ -115,7 +126,9 @@ func run() error {
func (d *Daemon) daemonLoop() {
for {
if err := d.daemonRun(); err != nil {
err := d.daemonRun()
d.health.update(err)
if err != nil {
slog.Error("error while syncing birthdays", "err", err)
}
time.Sleep(time.Minute * 15)

View File

@@ -32,3 +32,10 @@ Der Mailcow Birthday Daemon synchronisiert automatisch Geburtstagskalender für
## Synchronisationsintervall
Der Synchronisationszyklus läuft alle **15 Minuten** automatisch.
## Healthcheck
- Das Dockerfile enthält eine `HEALTHCHECK`-Anweisung, die den eingebauten Subcommand `healthcheck` nutzt es werden keine externen Tools wie `curl` oder `wget` benötigt und kein Port wird geöffnet.
- Nach jedem Sync-Lauf schreibt der Daemon eine kleine Statusdatei (`health.json`) neben das State-File. Der `healthcheck`-Subcommand liest diese Datei und prüft, ob der letzte Sync aktuell und fehlerfrei war.
- Docker zeigt den Status in `docker ps` als `(healthy)` oder `(unhealthy)` an.
- Der Healthcheck meldet **unhealthy**, wenn der letzte Sync-Lauf fehlgeschlagen ist oder länger als 20 Minuten zurückliegt. Während der Startphase (bevor der erste Sync abgeschlossen ist) gilt der Daemon als healthy.

View File

@@ -46,3 +46,10 @@ docker compose exec birthdaydaemon /mailcow-birthday-daemon cleanup Birthdays
## Kalender erscheint nicht in SOGo
SOGo zeigt neue Kalender manchmal erst nach einem Neuladen der Seite (Strg+Shift+R) oder nach dem nächsten Login an. Der Kalender wird unter dem Namen erstellt, der in `CALENDAR_NAME` konfiguriert ist (Standard: `Birthdays`).
## Healthcheck meldet `unhealthy`
1. Logs prüfen: `docker compose logs -f birthdaydaemon`
2. Status manuell abfragen: `docker compose exec birthdaydaemon /mailcow-birthday-daemon healthcheck`
3. Der Healthcheck meldet `unhealthy`, wenn der letzte Sync-Lauf fehlgeschlagen ist oder länger als 20 Minuten zurückliegt.
4. Falls der Container gerade erst gestartet wurde, kann es bis zu 2 Minuten dauern, bis der erste Sync abgeschlossen und der Status `healthy` ist.