Skip to content

Commit 1dff1a9

Browse files
authored
Add improved health check and expose it for docker (#373)
1 parent c6c2491 commit 1dff1a9

File tree

11 files changed

+182
-5
lines changed

11 files changed

+182
-5
lines changed

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,7 @@ EXPOSE 7090/udp
4343
# SMA Energy Manager
4444
EXPOSE 9522/udp
4545

46+
HEALTHCHECK --interval=60s --start-period=60s --timeout=30s --retries=3 CMD [ "evcc", "health" ]
47+
4648
ENTRYPOINT [ "/evcc/entrypoint.sh" ]
4749
CMD [ "evcc" ]

cmd/health.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// +build !windows
2+
3+
package cmd
4+
5+
import (
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"time"
10+
11+
"github.com/andig/evcc/server"
12+
"github.com/andig/evcc/util"
13+
"github.com/spf13/cobra"
14+
"github.com/spf13/viper"
15+
"github.com/tv42/httpunix"
16+
)
17+
18+
const serviceName = "evcc"
19+
20+
// healthCmd represents the meter command
21+
var healthCmd = &cobra.Command{
22+
Use: "health",
23+
Short: "Check application health",
24+
Run: runHealth,
25+
}
26+
27+
func init() {
28+
rootCmd.AddCommand(healthCmd)
29+
}
30+
31+
func runHealth(cmd *cobra.Command, args []string) {
32+
util.LogLevel(viper.GetString("log"), viper.GetStringMapString("levels"))
33+
log.INFO.Printf("evcc %s (%s)", server.Version, server.Commit)
34+
35+
u := &httpunix.Transport{
36+
DialTimeout: 100 * time.Millisecond,
37+
RequestTimeout: 1 * time.Second,
38+
ResponseHeaderTimeout: 1 * time.Second,
39+
}
40+
41+
u.RegisterLocation(serviceName, server.SocketPath)
42+
43+
var client = http.Client{
44+
Transport: u,
45+
}
46+
47+
var ok bool
48+
resp, err := client.Get(fmt.Sprintf("http+unix://%s/health", serviceName))
49+
50+
if err == nil && resp.StatusCode == http.StatusOK {
51+
log.INFO.Printf("health check ok")
52+
ok = true
53+
}
54+
55+
if !ok {
56+
log.ERROR.Printf("health check failed")
57+
os.Exit(1)
58+
}
59+
}

cmd/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,5 +184,8 @@ func run(cmd *cobra.Command, args []string) {
184184
site.DumpConfig()
185185
go site.Run(conf.Interval)
186186

187+
// uds health check listener
188+
go server.HealthListener(site)
189+
187190
log.FATAL.Println(httpd.ListenAndServe())
188191
}

core/health.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package core
2+
3+
import (
4+
"sync/atomic"
5+
"time"
6+
)
7+
8+
// Health is a health checker that needs regular updates to stay healthy
9+
type Health struct {
10+
locker uint32 // mutex
11+
updated time.Time
12+
timeout time.Duration
13+
}
14+
15+
// NewHealth creates new health checker
16+
func NewHealth(timeout time.Duration) (health *Health) {
17+
return &Health{timeout: timeout}
18+
}
19+
20+
// Healthy returns health status based on last update timestamp
21+
func (health *Health) Healthy() bool {
22+
start := time.Now()
23+
24+
for time.Since(start) < time.Second {
25+
if atomic.CompareAndSwapUint32(&health.locker, 0, 1) {
26+
defer atomic.StoreUint32(&health.locker, 0)
27+
return time.Since(health.updated) < health.timeout
28+
}
29+
30+
time.Sleep(50 * time.Millisecond)
31+
}
32+
33+
return false
34+
}
35+
36+
// Update updates the health timer on each loadpoint update
37+
func (health *Health) Update() {
38+
start := time.Now()
39+
40+
for time.Since(start) < time.Second {
41+
if atomic.CompareAndSwapUint32(&health.locker, 0, 1) {
42+
health.updated = time.Now()
43+
atomic.StoreUint32(&health.locker, 0)
44+
return
45+
}
46+
47+
time.Sleep(50 * time.Millisecond)
48+
}
49+
}

core/site.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ type Site struct {
2424
uiChan chan<- util.Param // client push messages
2525
lpUpdateChan chan *LoadPoint
2626

27+
*Health
28+
2729
log *util.Logger
2830

2931
// configuration
@@ -92,6 +94,7 @@ func NewSiteFromConfig(
9294
func NewSite() *Site {
9395
lp := &Site{
9496
log: util.NewLogger("core"),
97+
Health: NewHealth(60 * time.Second),
9598
Voltage: 230, // V
9699
}
97100

@@ -122,7 +125,7 @@ type LoadpointConfiguration struct {
122125
TargetSoC int `json:"targetSoC"`
123126
}
124127

125-
// GetMode Gets loadpoint charge mode
128+
// GetMode gets loadpoint charge mode
126129
func (site *Site) GetMode() api.ChargeMode {
127130
return site.loadpoints[0].GetMode()
128131
}
@@ -340,6 +343,7 @@ func (site *Site) update(lp Updater) {
340343

341344
if sitePower, err := site.sitePower(); err == nil {
342345
lp.Update(sitePower)
346+
site.Health.Update()
343347
}
344348
}
345349

docker/tmpl.Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,7 @@ COPY docker/bin/* /evcc/
3535

3636
EXPOSE 7070
3737

38+
HEALTHCHECK --interval=60s --start-period=60s --timeout=30s --retries=3 CMD [ "evcc", "health" ]
39+
3840
ENTRYPOINT [ "/evcc/entrypoint.sh" ]
3941
CMD [ "evcc" ]

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ require (
4141
github.com/spf13/viper v1.7.1
4242
github.com/stretchr/testify v1.6.1 // indirect
4343
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
44+
github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c
4445
github.com/volkszaehler/mbmd v0.0.0-20200831092453-b235d6a65b21
4546
golang.org/x/net v0.0.0-20200707034311-ab3426394381
4647
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ github.com/tebeka/strftime v0.1.3/go.mod h1:7wJm3dZlpr4l/oVK0t1HYIc4rMzQ2XJlOMIU
389389
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
390390
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
391391
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
392+
github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ=
393+
github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM=
392394
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
393395
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
394396
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=

server/http.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type route struct {
3232

3333
// site is the minimal interface for accessing site methods
3434
type site interface {
35+
Healthy() bool
3536
Configuration() core.SiteConfiguration
3637
LoadPoints() []*core.LoadPoint
3738
loadpoint
@@ -100,10 +101,15 @@ func jsonResponse(w http.ResponseWriter, r *http.Request, content interface{}) {
100101
}
101102

102103
// HealthHandler returns current charge mode
103-
func HealthHandler() http.HandlerFunc {
104+
func HealthHandler(site site) http.HandlerFunc {
104105
return func(w http.ResponseWriter, r *http.Request) {
105-
res := struct{ OK bool }{OK: true}
106-
jsonResponse(w, r, res)
106+
if !site.Healthy() {
107+
w.WriteHeader(http.StatusInternalServerError)
108+
return
109+
}
110+
111+
w.WriteHeader(http.StatusOK)
112+
fmt.Fprintln(w, "OK")
107113
}
108114
}
109115

@@ -231,7 +237,7 @@ type HTTPd struct {
231237
// NewHTTPd creates HTTP server with configured routes for loadpoint
232238
func NewHTTPd(url string, site site, hub *SocketHub, cache *util.Cache) *HTTPd {
233239
var routes = map[string]route{
234-
"health": {[]string{"GET"}, "/health", HealthHandler()},
240+
"health": {[]string{"GET"}, "/health", HealthHandler(site)},
235241
"config": {[]string{"GET"}, "/config", ConfigHandler(site)},
236242
"templates": {[]string{"GET"}, "/config/templates/{class:[a-z]+}", TemplatesHandler()},
237243
"state": {[]string{"GET"}, "/state", StateHandler(cache)},

server/uds.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// +build !windows
2+
3+
package server
4+
5+
import (
6+
"net"
7+
"net/http"
8+
"os"
9+
)
10+
11+
// SocketPath is the unix domain socket path
12+
const SocketPath = "/tmp/evcc"
13+
14+
// remoteIfExists deletes file if it exists or fails
15+
func remoteIfExists(file string) {
16+
_, err := os.Stat(file)
17+
if err == nil {
18+
err = os.Remove(file)
19+
}
20+
21+
if err != nil && !os.IsNotExist(err) {
22+
log.FATAL.Fatal(err)
23+
}
24+
}
25+
26+
// HealthListener attaches listener to unix domain socket and runs listener
27+
func HealthListener(site site) {
28+
remoteIfExists(SocketPath)
29+
30+
l, err := net.Listen("unix", SocketPath)
31+
if err != nil {
32+
log.FATAL.Fatal(err)
33+
}
34+
defer l.Close()
35+
36+
mux := http.NewServeMux()
37+
httpd := http.Server{Handler: mux}
38+
mux.HandleFunc("/health", HealthHandler(site))
39+
40+
_ = httpd.Serve(l)
41+
}

0 commit comments

Comments
 (0)