From 1dff1a9f135d54a466aff0ba41c56ca2807513c0 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 6 Oct 2020 21:53:18 +0200 Subject: [PATCH] Add improved health check and expose it for docker (#373) --- Dockerfile | 2 ++ cmd/health.go | 59 ++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 3 +++ core/health.go | 49 +++++++++++++++++++++++++++++++++++ core/site.go | 6 ++++- docker/tmpl.Dockerfile | 2 ++ go.mod | 1 + go.sum | 2 ++ server/http.go | 14 +++++++--- server/uds.go | 41 +++++++++++++++++++++++++++++ server/uds_windows.go | 8 ++++++ 11 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 cmd/health.go create mode 100644 core/health.go create mode 100644 server/uds.go create mode 100644 server/uds_windows.go diff --git a/Dockerfile b/Dockerfile index b14c93dc6..bdbc8b746 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,5 +43,7 @@ EXPOSE 7090/udp # SMA Energy Manager EXPOSE 9522/udp +HEALTHCHECK --interval=60s --start-period=60s --timeout=30s --retries=3 CMD [ "evcc", "health" ] + ENTRYPOINT [ "/evcc/entrypoint.sh" ] CMD [ "evcc" ] diff --git a/cmd/health.go b/cmd/health.go new file mode 100644 index 000000000..4f076a71a --- /dev/null +++ b/cmd/health.go @@ -0,0 +1,59 @@ +// +build !windows + +package cmd + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/andig/evcc/server" + "github.com/andig/evcc/util" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/tv42/httpunix" +) + +const serviceName = "evcc" + +// healthCmd represents the meter command +var healthCmd = &cobra.Command{ + Use: "health", + Short: "Check application health", + Run: runHealth, +} + +func init() { + rootCmd.AddCommand(healthCmd) +} + +func runHealth(cmd *cobra.Command, args []string) { + util.LogLevel(viper.GetString("log"), viper.GetStringMapString("levels")) + log.INFO.Printf("evcc %s (%s)", server.Version, server.Commit) + + u := &httpunix.Transport{ + DialTimeout: 100 * time.Millisecond, + RequestTimeout: 1 * time.Second, + ResponseHeaderTimeout: 1 * time.Second, + } + + u.RegisterLocation(serviceName, server.SocketPath) + + var client = http.Client{ + Transport: u, + } + + var ok bool + resp, err := client.Get(fmt.Sprintf("http+unix://%s/health", serviceName)) + + if err == nil && resp.StatusCode == http.StatusOK { + log.INFO.Printf("health check ok") + ok = true + } + + if !ok { + log.ERROR.Printf("health check failed") + os.Exit(1) + } +} diff --git a/cmd/root.go b/cmd/root.go index 78041cddf..368360630 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -184,5 +184,8 @@ func run(cmd *cobra.Command, args []string) { site.DumpConfig() go site.Run(conf.Interval) + // uds health check listener + go server.HealthListener(site) + log.FATAL.Println(httpd.ListenAndServe()) } diff --git a/core/health.go b/core/health.go new file mode 100644 index 000000000..40b9381d1 --- /dev/null +++ b/core/health.go @@ -0,0 +1,49 @@ +package core + +import ( + "sync/atomic" + "time" +) + +// Health is a health checker that needs regular updates to stay healthy +type Health struct { + locker uint32 // mutex + updated time.Time + timeout time.Duration +} + +// NewHealth creates new health checker +func NewHealth(timeout time.Duration) (health *Health) { + return &Health{timeout: timeout} +} + +// Healthy returns health status based on last update timestamp +func (health *Health) Healthy() bool { + start := time.Now() + + for time.Since(start) < time.Second { + if atomic.CompareAndSwapUint32(&health.locker, 0, 1) { + defer atomic.StoreUint32(&health.locker, 0) + return time.Since(health.updated) < health.timeout + } + + time.Sleep(50 * time.Millisecond) + } + + return false +} + +// Update updates the health timer on each loadpoint update +func (health *Health) Update() { + start := time.Now() + + for time.Since(start) < time.Second { + if atomic.CompareAndSwapUint32(&health.locker, 0, 1) { + health.updated = time.Now() + atomic.StoreUint32(&health.locker, 0) + return + } + + time.Sleep(50 * time.Millisecond) + } +} diff --git a/core/site.go b/core/site.go index 5509dc20a..a9d7f289a 100644 --- a/core/site.go +++ b/core/site.go @@ -24,6 +24,8 @@ type Site struct { uiChan chan<- util.Param // client push messages lpUpdateChan chan *LoadPoint + *Health + log *util.Logger // configuration @@ -92,6 +94,7 @@ func NewSiteFromConfig( func NewSite() *Site { lp := &Site{ log: util.NewLogger("core"), + Health: NewHealth(60 * time.Second), Voltage: 230, // V } @@ -122,7 +125,7 @@ type LoadpointConfiguration struct { TargetSoC int `json:"targetSoC"` } -// GetMode Gets loadpoint charge mode +// GetMode gets loadpoint charge mode func (site *Site) GetMode() api.ChargeMode { return site.loadpoints[0].GetMode() } @@ -340,6 +343,7 @@ func (site *Site) update(lp Updater) { if sitePower, err := site.sitePower(); err == nil { lp.Update(sitePower) + site.Health.Update() } } diff --git a/docker/tmpl.Dockerfile b/docker/tmpl.Dockerfile index 53ef1d072..08b10e4a5 100644 --- a/docker/tmpl.Dockerfile +++ b/docker/tmpl.Dockerfile @@ -35,5 +35,7 @@ COPY docker/bin/* /evcc/ EXPOSE 7070 +HEALTHCHECK --interval=60s --start-period=60s --timeout=30s --retries=3 CMD [ "evcc", "health" ] + ENTRYPOINT [ "/evcc/entrypoint.sh" ] CMD [ "evcc" ] diff --git a/go.mod b/go.mod index f3aba0119..e9d21930d 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/spf13/viper v1.7.1 github.com/stretchr/testify v1.6.1 // indirect github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e + github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c github.com/volkszaehler/mbmd v0.0.0-20200831092453-b235d6a65b21 golang.org/x/net v0.0.0-20200707034311-ab3426394381 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect diff --git a/go.sum b/go.sum index 17fdf924d..ce462817b 100644 --- a/go.sum +++ b/go.sum @@ -389,6 +389,8 @@ github.com/tebeka/strftime v0.1.3/go.mod h1:7wJm3dZlpr4l/oVK0t1HYIc4rMzQ2XJlOMIU github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ= +github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= diff --git a/server/http.go b/server/http.go index 69dd7edae..1699f5f23 100644 --- a/server/http.go +++ b/server/http.go @@ -32,6 +32,7 @@ type route struct { // site is the minimal interface for accessing site methods type site interface { + Healthy() bool Configuration() core.SiteConfiguration LoadPoints() []*core.LoadPoint loadpoint @@ -100,10 +101,15 @@ func jsonResponse(w http.ResponseWriter, r *http.Request, content interface{}) { } // HealthHandler returns current charge mode -func HealthHandler() http.HandlerFunc { +func HealthHandler(site site) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - res := struct{ OK bool }{OK: true} - jsonResponse(w, r, res) + if !site.Healthy() { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "OK") } } @@ -231,7 +237,7 @@ type HTTPd struct { // NewHTTPd creates HTTP server with configured routes for loadpoint func NewHTTPd(url string, site site, hub *SocketHub, cache *util.Cache) *HTTPd { var routes = map[string]route{ - "health": {[]string{"GET"}, "/health", HealthHandler()}, + "health": {[]string{"GET"}, "/health", HealthHandler(site)}, "config": {[]string{"GET"}, "/config", ConfigHandler(site)}, "templates": {[]string{"GET"}, "/config/templates/{class:[a-z]+}", TemplatesHandler()}, "state": {[]string{"GET"}, "/state", StateHandler(cache)}, diff --git a/server/uds.go b/server/uds.go new file mode 100644 index 000000000..0efe0d42f --- /dev/null +++ b/server/uds.go @@ -0,0 +1,41 @@ +// +build !windows + +package server + +import ( + "net" + "net/http" + "os" +) + +// SocketPath is the unix domain socket path +const SocketPath = "/tmp/evcc" + +// remoteIfExists deletes file if it exists or fails +func remoteIfExists(file string) { + _, err := os.Stat(file) + if err == nil { + err = os.Remove(file) + } + + if err != nil && !os.IsNotExist(err) { + log.FATAL.Fatal(err) + } +} + +// HealthListener attaches listener to unix domain socket and runs listener +func HealthListener(site site) { + remoteIfExists(SocketPath) + + l, err := net.Listen("unix", SocketPath) + if err != nil { + log.FATAL.Fatal(err) + } + defer l.Close() + + mux := http.NewServeMux() + httpd := http.Server{Handler: mux} + mux.HandleFunc("/health", HealthHandler(site)) + + _ = httpd.Serve(l) +} diff --git a/server/uds_windows.go b/server/uds_windows.go new file mode 100644 index 000000000..8eab0878d --- /dev/null +++ b/server/uds_windows.go @@ -0,0 +1,8 @@ +// +build windows + +package server + +// HealthListener attaches listener to unix domain socket +func HealthListener(site site) { + // nop +}