runner: drain on SIGTERM !322
4 files changed, +100 −8
Layout: unified · split
.gitbay/wiki/Admin.org +8
| @@ -476,6 +476,14 @@ cannot call =newuidmap= and the runner refuses to start. That is a | |||
| 476 | considered trade, explained in the file and in the Threat-Model; if you | 476 | considered trade, explained in the file and in the Threat-Model; if you |
| 477 | run with =-isolation none=, set it back to =yes=. | 477 | run with =-isolation none=, set it back to =yes=. |
| 478 | 478 | ||
| 479 | *Restarting the runner is safe.* On SIGTERM it stops claiming, finishes | ||
| 480 | the build in flight, reports it, and exits; the drop-in's | ||
| 481 | =TimeoutStopSec=50min= covers the longest build. So =make deploy-runner= | ||
| 482 | waits for a running build rather than orphaning it, and a build's result | ||
| 483 | is retried for half a minute if gitbayd is restarting at that moment. A | ||
| 484 | second SIGTERM ends the runner at once, abandoning the build to the | ||
| 485 | reaper. | ||
| 486 | |||
| 479 | *Validate podman mode on a scratch repository before pointing the runner | 487 | *Validate podman mode on a scratch repository before pointing the runner |
| 480 | at real ones.* Every deploy that switched the whole instance to | 488 | at real ones.* Every deploy that switched the whole instance to |
| 481 | containers and failed took CI down with it. Instead: create a throwaway | 489 | containers and failed took CI down with it. Instead: create a throwaway |
cmd/gitbay-runner/env_test.go +46
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "os" | 4 | "os" |
| 5 | "strings" | 5 | "strings" |
| 6 | "testing" | 6 | "testing" |
| 7 | "time" | ||
| 7 | ) | 8 | ) |
| 8 | 9 | ||
| 9 | // A step's environment is constructed, not inherited: repository content | 10 | // A step's environment is constructed, not inherited: repository content |
| @@ -129,3 +130,48 @@ func TestLimitArgs(t *testing.T) { | |||
| 129 | t.Errorf("limitArgs = %v, want %v", got, want) | 130 | t.Errorf("limitArgs = %v, want %v", got, want) |
| 130 | } | 131 | } |
| 131 | } | 132 | } |
| 133 | |||
| 134 | // Closing stop drains: the build in flight finishes and is reported, and | ||
| 135 | // no further build is claimed (#179). | ||
| 136 | func TestServeDrainsOnStop(t *testing.T) { | ||
| 137 | stop := make(chan struct{}) | ||
| 138 | started := make(chan struct{}) | ||
| 139 | release := make(chan struct{}) | ||
| 140 | calls := 0 | ||
| 141 | r := &runner{stepFn: func() (bool, error) { | ||
| 142 | calls++ | ||
| 143 | if calls == 1 { | ||
| 144 | close(started) | ||
| 145 | <-release // the build is in flight while stop closes | ||
| 146 | } | ||
| 147 | return true, nil | ||
| 148 | }} | ||
| 149 | done := make(chan struct{}) | ||
| 150 | go func() { r.serve(1, false, time.Millisecond, stop); close(done) }() | ||
| 151 | <-started | ||
| 152 | close(stop) | ||
| 153 | close(release) | ||
| 154 | select { | ||
| 155 | case <-done: | ||
| 156 | case <-time.After(2 * time.Second): | ||
| 157 | t.Fatal("serve did not return after the in-flight build finished") | ||
| 158 | } | ||
| 159 | if calls != 1 { | ||
| 160 | t.Errorf("claimed %d builds after stop, want the one already in flight", calls-1) | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | // An idle worker leaves promptly on stop rather than sleeping out a poll. | ||
| 165 | func TestServeStopsWhileIdle(t *testing.T) { | ||
| 166 | stop := make(chan struct{}) | ||
| 167 | r := &runner{stepFn: func() (bool, error) { return false, nil }} | ||
| 168 | done := make(chan struct{}) | ||
| 169 | go func() { r.serve(1, false, time.Hour, stop); close(done) }() | ||
| 170 | time.Sleep(20 * time.Millisecond) | ||
| 171 | close(stop) | ||
| 172 | select { | ||
| 173 | case <-done: | ||
| 174 | case <-time.After(2 * time.Second): | ||
| 175 | t.Fatal("idle worker did not stop") | ||
| 176 | } | ||
| 177 | } | ||
cmd/gitbay-runner/main.go +41 −8
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | "log" | 17 | "log" |
| 18 | "os" | 18 | "os" |
| 19 | "os/exec" | 19 | "os/exec" |
| 20 | "os/signal" | ||
| 20 | "path/filepath" | 21 | "path/filepath" |
| 21 | "strings" | 22 | "strings" |
| 22 | "sync" | 23 | "sync" |
| @@ -52,6 +53,8 @@ type runner struct { | |||
| 52 | // memory and cpus cap one build's container; empty means no cap. | 53 | // memory and cpus cap one build's container; empty means no cap. |
| 53 | memory string | 54 | memory string |
| 54 | cpus string | 55 | cpus string |
| 56 | // stepFn is step, replaceable by tests. | ||
| 57 | stepFn func() (bool, error) | ||
| 55 | // repos limits which repositories this runner claims builds for. Empty | 58 | // repos limits which repositories this runner claims builds for. Empty |
| 56 | // means any, which is what a runner on the server itself wants; a runner | 59 | // means any, which is what a runner on the server itself wants; a runner |
| 57 | // somewhere that should not execute every repository's steps names them. | 60 | // somewhere that should not execute every repository's steps names them. |
| @@ -133,28 +136,58 @@ func main() { | |||
| 133 | // claiming at once is already safe; the runner just never used that. | 136 | // claiming at once is already safe; the runner just never used that. |
| 134 | // Each build works in its own build-<id> directory, so they do not | 137 | // Each build works in its own build-<id> directory, so they do not |
| 135 | // meet on disk either. | 138 | // meet on disk either. |
| 139 | // A stop signal drains: no build is claimed after it, and each build | ||
| 140 | // already in flight runs to completion and is reported. The old | ||
| 141 | // behaviour was to die mid-build, which left the build "running" on | ||
| 142 | // the server with nothing executing (#179). The unit's | ||
| 143 | // TimeoutStopSec bounds the drain; a second signal ends it now. | ||
| 144 | stop := make(chan struct{}) | ||
| 145 | go func() { | ||
| 146 | sigs := make(chan os.Signal, 2) | ||
| 147 | signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT) | ||
| 148 | <-sigs | ||
| 149 | log.Printf("draining: finishing builds in flight, claiming no more") | ||
| 150 | close(stop) | ||
| 151 | <-sigs | ||
| 152 | log.Printf("second signal: exiting without draining") | ||
| 153 | os.Exit(1) | ||
| 154 | }() | ||
| 155 | r.serve(n, *once, *poll, stop) | ||
| 156 | } | ||
| 157 | |||
| 158 | // serve runs n workers until stop closes. A worker checks stop only | ||
| 159 | // between builds, so closing it never interrupts one. | ||
| 160 | func (r *runner) serve(n int, once bool, poll time.Duration, stop <-chan struct{}) { | ||
| 161 | if r.stepFn == nil { | ||
| 162 | r.stepFn = r.step | ||
| 163 | } | ||
| 136 | var wg sync.WaitGroup | 164 | var wg sync.WaitGroup |
| 137 | for i := 0; i < n; i++ { | 165 | for i := 0; i < n; i++ { |
| 138 | wg.Add(1) | 166 | wg.Add(1) |
| 139 | go func(i int) { | 167 | go func(i int) { |
| 140 | defer wg.Done() | 168 | defer wg.Done() |
| 141 | // Spread the idle polls across the interval rather than | ||
| 142 | // having every worker wake together: n workers asking the | ||
| 143 | // same question in the same instant is n times the load for | ||
| 144 | // one answer. | ||
| 145 | if n > 1 { | 169 | if n > 1 { |
| 146 | time.Sleep(time.Duration(i) * *poll / time.Duration(n)) | 170 | time.Sleep(time.Duration(i) * poll / time.Duration(n)) |
| 147 | } | 171 | } |
| 148 | for { | 172 | for { |
| 149 | ran, err := r.step() | 173 | select { |
| 174 | case <-stop: | ||
| 175 | return | ||
| 176 | default: | ||
| 177 | } | ||
| 178 | ran, err := r.stepFn() | ||
| 150 | if err != nil { | 179 | if err != nil { |
| 151 | log.Printf("runner: %v", err) | 180 | log.Printf("runner: %v", err) |
| 152 | } | 181 | } |
| 153 | if *once { | 182 | if once { |
| 154 | return | 183 | return |
| 155 | } | 184 | } |
| 156 | if !ran { | 185 | if !ran { |
| 157 | time.Sleep(*poll) | 186 | select { |
| 187 | case <-stop: | ||
| 188 | return | ||
| 189 | case <-time.After(poll): | ||
| 190 | } | ||
| 158 | } | 191 | } |
| 159 | } | 192 | } |
| 160 | }(i) | 193 | }(i) |
deploy/gitbay-runner.override.conf +5
| @@ -44,6 +44,11 @@ | |||
| 44 | # start joins its namespaces — including a /tmp that no longer exists. | 44 | # start joins its namespaces — including a /tmp that no longer exists. |
| 45 | # End it with the service. | 45 | # End it with the service. |
| 46 | ExecStopPost=-/usr/bin/pkill -u ci-runner -x catatonit | 46 | ExecStopPost=-/usr/bin/pkill -u ci-runner -x catatonit |
| 47 | # On stop the runner drains: it claims nothing more and finishes the | ||
| 48 | # build in flight, then exits. Give it long enough — the per-build limit | ||
| 49 | # is 45m plus half a minute of report retries — before systemd kills it. | ||
| 50 | # `make deploy-runner` therefore waits for a running build (#179). | ||
| 51 | TimeoutStopSec=50min | ||
| 47 | ExecStart= | 52 | ExecStart= |
| 48 | ExecStart=/usr/local/bin/gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work -poll 5s -timeout 45m -repos krz/gitbay,cmc/ci-smoke -isolation podman -image localhost/gitbay-ci:1 -cpus 3 | 53 | ExecStart=/usr/local/bin/gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work -poll 5s -timeout 45m -repos krz/gitbay,cmc/ci-smoke -isolation podman -image localhost/gitbay-ci:1 -cpus 3 |
| 49 | Nice=10 | 54 | Nice=10 |