-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathservice.go
More file actions
402 lines (370 loc) · 15.1 KB
/
Copy pathservice.go
File metadata and controls
402 lines (370 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
package main
import (
"errors"
"fmt"
"log"
"net"
"os/exec"
"sync"
"time"
)
func startServiceIfNotAlreadyRunningAndConnect(serviceConfig ServiceConfig, clientDisconnected <-chan struct{}) net.Conn {
if interrupted.Load() {
return nil
}
var serviceConnection net.Conn
runningService, found := resourceManager.maybeGetRunningService(serviceConfig.Name)
if !found {
serviceConn, err := startService(serviceConfig, clientDisconnected)
if err != nil {
log.Printf("[%s] Failed to start: %v", serviceConfig.Name, err)
return nil
}
serviceConnection = serviceConn
} else {
if !runningService.manageMutex.TryLock() {
if interrupted.Load() {
return nil
}
log.Printf("[%s] Service is already starting or stopping, waiting for that operation to finish before proceeding with the current connection", serviceConfig.Name)
// Wait for the holder to finish, but abort promptly if THIS queued client
// disconnects. Otherwise its waiting-connection count would
// stay inflated until the holder finishes/aborts, and when the holder aborts
// we would recurse into a fresh startService for an already-disconnected
// client. The mutex is a channelMutex, so LockOrCancel blocks on its token
// channel and is woken the instant the holder releases it (no polling, no
// orphaned goroutine) while still selecting on clientDisconnected.
if !runningService.manageMutex.LockOrCancel(clientDisconnected) {
return nil // client disconnected while queued
}
// We hold the lock. The service may have stopped while we waited, so search
// for it again (as the original did) — but do not start/connect a service for
// a client that is already gone.
runningService.manageMutex.Unlock()
select {
case <-clientDisconnected:
return nil
default:
}
return startServiceIfNotAlreadyRunningAndConnect(serviceConfig, clientDisconnected)
}
trackServiceLastUsed(serviceConfig, true)
runningService.manageMutex.Unlock()
serviceConnection = connectToService(serviceConfig, clientDisconnected)
}
return serviceConnection
}
func getIdleTimeout(serviceConfig ServiceConfig) time.Duration {
idleTimeout := serviceConfig.ShutDownAfterInactivitySeconds
if idleTimeout == 0 {
idleTimeout = config.ShutDownAfterInactivitySeconds
}
// for old configs
if idleTimeout == 0 {
idleTimeout = 2 * 60
}
return time.Duration(idleTimeout) * time.Second
}
func startService(serviceConfig ServiceConfig, clientDisconnected <-chan struct{}) (net.Conn, error) {
now := time.Now()
runningService := RunningService{
lastUsed: &now,
isWaitingForResources: true,
manageMutex: newChannelMutex(),
resourcesReleased: new(bool),
}
runningService.manageMutex.Lock()
resourceManager.serviceMutex.Lock()
_, ok := resourceManager.runningServices[serviceConfig.Name]
if ok {
resourceManager.serviceMutex.Unlock()
runningService.manageMutex.Unlock()
log.Printf("[%s] ERROR: Trying to start a service while it is already present in the list of running services", serviceConfig.Name)
return nil, fmt.Errorf("service already started")
}
resourceManager.runningServices[serviceConfig.Name] = &runningService
resourceManager.serviceMutex.Unlock()
if !reserveResources(serviceConfig.ResourceRequirements, serviceConfig.Name, clientDisconnected) {
if interrupted.Load() {
return nil, fmt.Errorf("interrupt signal was received")
}
resourceManager.serviceMutex.Lock()
cleanUpStoppedServiceWhenServiceMutexIsLocked(&serviceConfig, &runningService, true)
resourceManager.serviceMutex.Unlock()
runningService.manageMutex.Unlock()
return nil, fmt.Errorf("insufficient resources %s", serviceConfig.Name)
}
resourceManager.serviceMutex.Lock()
runningService.isWaitingForResources = false
runningService.resourcesReserved = true
resourceManager.serviceMutex.Unlock()
cmd, outW, errW := runServiceCommand(serviceConfig)
if cmd == nil {
resourceManager.serviceMutex.Lock()
releaseReservedResourcesWhenServiceMutexIsLocked(serviceConfig.ResourceRequirements)
cleanUpStoppedServiceWhenServiceMutexIsLocked(&serviceConfig, &runningService, true)
resourceManager.serviceMutex.Unlock()
runningService.manageMutex.Unlock()
return nil, fmt.Errorf("failed to run command \"%s %s\"", serviceConfig.Command, serviceConfig.Args)
}
resourceManager.serviceMutex.Lock()
runningService.cmd = cmd
runningService.stdoutWriter = outW
runningService.stderrWriter = errW
runningService.exitWaitGroup = new(sync.WaitGroup)
runningService.exitWaitGroup.Add(1)
go monitorProcess(serviceConfig.Name, cmd.Process, &runningService)
resourceManager.serviceMutex.Unlock()
var startupConnectionTimeout time.Duration
if serviceConfig.StartupTimeoutMilliseconds == nil {
startupConnectionTimeout = 10 * time.Minute
} else {
startupConnectionTimeout = time.Duration(*serviceConfig.StartupTimeoutMilliseconds) * time.Millisecond
}
giveUpTime := time.Now().Add(startupConnectionTimeout)
err := performHealthCheck(serviceConfig, startupConnectionTimeout, runningService.exitWaitGroup, clientDisconnected)
if err != nil {
log.Printf("[%s] Stopping service due to healthcheck error: %v", serviceConfig.Name, err)
runningService.manageMutex.Unlock()
stopService(serviceConfig)
releaseReservedResources(serviceConfig.ResourceRequirements)
return nil, fmt.Errorf("healthcheck failed: %w", err)
}
log.Printf("[%s] Service started with pid %d", serviceConfig.Name, cmd.Process.Pid)
if interrupted.Load() {
return nil, fmt.Errorf("interrupt signal was received")
}
var serviceConnection, processExited = tryConnectingUntilTimeoutOrProcessExit(
serviceConfig.ProxyTargetHost,
serviceConfig.ProxyTargetPort,
serviceConfig.Name,
time.Until(giveUpTime),
runningService.exitWaitGroup,
clientDisconnected,
)
if serviceConnection == nil {
if processExited {
log.Printf("[%s] Process terminated before a connection to the service could be established, stopping the service", serviceConfig.Name)
runningService.manageMutex.Unlock()
stopService(serviceConfig)
releaseReservedResources(serviceConfig.ResourceRequirements)
return nil, fmt.Errorf("process terminated before a connection to the service could be established")
}
//This log has to happen before the mutex unlock to maintain a logical order of logs
log.Printf("[%s] Failed to connect to %s:%s, stopping the service", serviceConfig.Name, serviceConfig.ProxyTargetHost, serviceConfig.ProxyTargetPort)
runningService.manageMutex.Unlock()
stopService(serviceConfig)
releaseReservedResources(serviceConfig.ResourceRequirements)
return nil, fmt.Errorf("failed to connect to service")
}
defer runningService.manageMutex.Unlock()
if interrupted.Load() {
return nil, fmt.Errorf("interrupt signal was received")
}
resourceManager.serviceMutex.Lock()
releaseReservedResourcesWhenServiceMutexIsLocked(serviceConfig.ResourceRequirements)
runningService.isReady = true
idleTimeout := getIdleTimeout(serviceConfig)
runningService.idleTimer = time.AfterFunc(idleTimeout, func() {
if interrupted.Load() {
return
}
resourceManager.serviceMutex.Lock()
// cleanUpStoppedServiceWhenServiceMutexIsLocked sets idleTimer to nil, so a nil
// timer means the service has already been destroyed and this late-firing
// callback must do nothing.
if runningService.idleTimer == nil {
resourceManager.serviceMutex.Unlock()
if config.LogLevel == LogLevelDebug {
log.Printf("[%s] Idle timer fired after the service was already destroyed, ignoring", serviceConfig.Name)
}
return
}
// Hold serviceMutex across canBeStopped AND the Reset so that
// cleanUpStoppedServiceWhenServiceMutexIsLocked cannot nil idleTimer
// between the nil-check above and this Reset. stopService acquires
// serviceMutex internally, so it must run OUTSIDE this critical section.
shouldStop := canBeStopped(serviceConfig.Name, &runningService)
if shouldStop {
resourceManager.serviceMutex.Unlock()
log.Printf("[%s] Idle timeout %s reached, stopping service", serviceConfig.Name, idleTimeout)
stopService(serviceConfig)
} else {
runningService.idleTimer.Reset(getIdleTimeout(serviceConfig))
resourceManager.serviceMutex.Unlock()
log.Printf("[%s] Idle timeout %s reached, but service is busy, resetting idle time", serviceConfig.Name, idleTimeout)
}
})
resourceManager.serviceMutex.Unlock()
return serviceConnection, nil
}
func performHealthCheck(serviceConfig ServiceConfig, timeout time.Duration, processExitWaitGroup *sync.WaitGroup, clientDisconnected <-chan struct{}) error {
if serviceConfig.HealthcheckCommand == "" {
return nil
}
log.Printf("[%s] Running healthcheck command \"%s\"", serviceConfig.Name, serviceConfig.HealthcheckCommand)
totalTimeoutDeadlineTime := time.Now().Add(timeout)
var sleepDuration time.Duration
if serviceConfig.HealthcheckIntervalMilliseconds == 0 {
sleepDuration = 100 * time.Millisecond
} else {
sleepDuration = time.Duration(serviceConfig.HealthcheckIntervalMilliseconds) * time.Millisecond
}
// processExitedChannel fires as soon as the service process is observed dead
// (monitorProcess signals exitWaitGroup). When ConsiderStoppedOnProcessExit is
// set, the proxy treats the child process exiting as the service being down,
// so selecting on this channel aborts the healthcheck loop the moment the
// process exits — mirroring tryConnectingUntilTimeoutOrProcessExit. When
// ConsiderStoppedOnProcessExit is false (e.g. detached services such as docker
// containers), the child process exiting is expected and the real service may
// still be starting up, so the healthcheck must keep retrying until the
// startup timeout instead of aborting. Leaving the channel nil makes the
// process-exit cases below never selectable, so the loop is unaffected then.
var processExitedChannel chan struct{}
if *serviceConfig.ConsiderStoppedOnProcessExit {
processExitedChannel = make(chan struct{})
go func() {
processExitWaitGroup.Wait()
close(processExitedChannel)
}()
}
for {
if interrupted.Load() {
return errors.New("interrupt signal was received")
}
remainingUntilDeadlineDuration := time.Until(totalTimeoutDeadlineTime)
if remainingUntilDeadlineDuration <= 0 {
return fmt.Errorf("healthcheck timed out after %s", timeout)
}
cmd := exec.Command("sh", "-c", serviceConfig.HealthcheckCommand)
if err := cmd.Start(); err != nil {
log.Printf("[%s] Failed to start healthcheck command \"%s\": %v", serviceConfig.Name, serviceConfig.HealthcheckCommand, err)
return fmt.Errorf("failed to start healthcheck command \"%s\": %w", serviceConfig.HealthcheckCommand, err)
}
waitResultChan := make(chan error, 1)
go func() { waitResultChan <- cmd.Wait() }()
var waitErr error
select {
case waitErr = <-waitResultChan:
// finished within the remaining time
case <-time.After(remainingUntilDeadlineDuration):
_ = cmd.Process.Kill()
<-waitResultChan
return fmt.Errorf("starting healthcheck command timed out after %s", remainingUntilDeadlineDuration)
case <-clientDisconnected:
_ = cmd.Process.Kill()
<-waitResultChan
return fmt.Errorf("client disconnected while waiting for healthcheck")
case <-processExitedChannel:
_ = cmd.Process.Kill()
<-waitResultChan
return fmt.Errorf("service process terminated while waiting for healthcheck, considering the service stopped, set ConsiderStoppedOnProcessExit to true if this is not desired")
}
if waitErr == nil {
log.Printf("[%s] Healthcheck \"%s\" returned exit code 0, healthcheck completed", serviceConfig.Name, serviceConfig.HealthcheckCommand)
return nil
}
exitCode := -1
if exitError, ok := waitErr.(*exec.ExitError); ok {
exitCode = exitError.ExitCode()
}
log.Printf(
"[%s] Healthcheck \"%s\" returned exit code %d, trying again in %s",
serviceConfig.Name,
serviceConfig.HealthcheckCommand,
exitCode,
sleepDuration,
)
remainingUntilDeadlineDuration = time.Until(totalTimeoutDeadlineTime)
if sleepDuration > remainingUntilDeadlineDuration {
return fmt.Errorf(
"healthcheck timed out, not starting another healthcheck command: not enough time left for another HealthcheckInterval(%v) in StartupTimeout(%v)",
sleepDuration,
timeout,
)
}
if sleepDuration > 0 {
select {
case <-time.After(sleepDuration):
case <-clientDisconnected:
return fmt.Errorf("client disconnected while waiting for healthcheck")
case <-processExitedChannel:
return fmt.Errorf("service process terminated while waiting for healthcheck")
}
}
}
}
func connectToService(serviceConfig ServiceConfig, clientDisconnected <-chan struct{}) net.Conn {
log.Printf("[%s] Opening new service connection to %s:%s", serviceConfig.Name, serviceConfig.ProxyTargetHost, serviceConfig.ProxyTargetPort)
serviceConn, err := net.Dial("tcp", net.JoinHostPort(serviceConfig.ProxyTargetHost, serviceConfig.ProxyTargetPort))
if err != nil {
log.Printf("[%s] Error: failed to connect to %s:%s: %v", serviceConfig.Name, serviceConfig.ProxyTargetHost, serviceConfig.ProxyTargetPort, err)
if serviceConfig.RestartOnConnectionFailure {
log.Printf("[%s] Restarting service due to connection error", serviceConfig.Name)
_, isRunning := resourceManager.maybeGetRunningService(serviceConfig.Name)
if isRunning {
stopService(serviceConfig)
}
serviceConn, err = startService(serviceConfig, clientDisconnected)
if err != nil {
log.Printf("[%s] Failed to restart: %v", serviceConfig.Name, err)
return nil
}
return serviceConn
}
return nil
}
return serviceConn
}
func tryConnectingUntilTimeoutOrProcessExit(
serviceHost string,
servicePort string,
serviceName string,
timeout time.Duration,
processExitWaitGroup *sync.WaitGroup,
clientDisconnected <-chan struct{},
) (net.Conn, bool) {
deadline := time.Now().Add(timeout)
sleepDuration := 1 * time.Microsecond
maxSleep := 100 * time.Millisecond
processExitedChannel := make(chan struct{})
go func() {
processExitWaitGroup.Wait()
close(processExitedChannel)
}()
for time.Now().Before(deadline) {
select {
case <-processExitedChannel:
log.Printf("[%s] Process terminated while trying to connect to %s:%s", serviceName, serviceHost, servicePort)
return nil, true
case <-clientDisconnected:
log.Printf("[%s] Client disconnected while trying to connect to %s:%s", serviceName, serviceHost, servicePort)
return nil, false
default:
}
if interrupted.Load() {
return nil, false
}
conn, err := net.DialTimeout("tcp", net.JoinHostPort(serviceHost, servicePort), 1*time.Second)
if err == nil {
return conn, false
}
select {
case <-processExitedChannel:
log.Printf("[%s] Process terminated while trying to connect to %s:%s", serviceName, serviceHost, servicePort)
return nil, true
case <-time.After(sleepDuration):
case <-clientDisconnected:
log.Printf("[%s] Client disconnected while trying to connect to %s:%s", serviceName, serviceHost, servicePort)
return nil, false
}
// Exponentially increase up to the maximum.
sleepDuration *= 2
if sleepDuration > maxSleep {
sleepDuration = maxSleep
}
}
log.Printf("[%s] Error: failed to connect to %s:%s: All connection attempts failed after trying for %s",
serviceName, serviceHost, servicePort, timeout)
return nil, false
}