perf(boot): wait for readiness on the host and take the container id from docker run - #166
Merged
Merged
Conversation
…from docker run ## Summary Two round trips come out of every boot, and one of them scaled with how long a container takes to come up. `Dash::Commands::App#wait_for_ready` builds a shell loop that evaluates the same status the poller used to read one round trip at a time — docker's health status, or the `healthcheck: exec:` probe's exit code — returns the moment that status is one the poller accepts, and otherwise keeps looking until deploy_timeout. Boot runs it as a single capture with an interaction handler attached, so the "Container not ready yet" beacons still print once a second while it waits. Progress goes to stderr and the final status to stdout, which keeps the captured value the status and nothing else. `docker run --detach` already prints the id of the container it started, so the proxy target is read out of the run rather than asked for again on the next round trip. Per host: a proxy role pays 6 instead of 7, a healthchecked role without a proxy pays 5 instead of 4 + one per poll attempt, an unchecked role 6. Every readiness decision stays in Dash::Cli::Healthcheck::Poller, word for word — drift, the missing-gate warning, the readiness-delay confirm, the timeout error. The host loop only decides when to return, never what it means, and Dash::Commands::Base::READY_STATUSES is the one thing both sides read. `unhealthy` deliberately does NOT return early, against the issue's proposal: dash's default healthcheck probes every second with no start period and docker's default is three retries, so an app slower than ~3s to serve /up reports `unhealthy` long before it is up. Waiting through it is what the client-side poll did, and what keeps a normal Rails boot passing. ## Test Coverage - exact command strings for both branches of the wait, including the deadline and that timeout: 0 makes exactly one observation - the streaming handler against chunks split mid-line, and against lines that are not its own - the poller's new call pattern: one wait for a healthchecked role, wait plus confirm for an unchecked one, and no second wait after an unacceptable result - at the boot level: the proxy target read from the run, one readiness round trip for a healthchecked role, two for an unchecked one, the interaction handler and raise_on_non_zero_exit wiring, and a deadline that fails once ## Verification - [x] bundle exec rubocop --parallel passes - [x] unit tests pass (1899 runs) - [x] bin/test passes (1918 runs, integration included) - [x] the generated loop run under a real POSIX sh against a fake docker Refs #163
There was a problem hiding this comment.
All reported issues were addressed across 14 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
Three findings from review, two of them real behaviour regressions this PR
introduced.
The wait swallowed the status read's stderr and ignored its exit status, so a
docker daemon that had gone away — or a container that had vanished — turned
into an empty status the loop then waited out for the whole deploy timeout,
before blaming the container for not being ready. Before this PR that read was a
capture of its own with raise_on_non_zero_exit on, and it failed the boot on the
spot with docker's own error. Restore that: the inspect read no longer redirects
its stderr and takes the command down with `|| exit $?`, and the CLI no longer
passes raise_on_non_zero_exit: false.
That needs the deadline to stop signalling itself with a non-zero exit, which is
the better shape anyway: reaching the deadline is an ANSWER, and the poller is
what phrases it, so the loop exits 0 with the last status on stdout. A non-zero
exit now means only one thing — the command broke — which is the contract SSHKit
already has. An exec probe's non-zero exit is untouched: there it IS the answer
"not ready", so it stays swallowed and the loop goes on.
The progress reporter buffered both streams into one buffer. stdout and stderr
are separate SSH streams whose chunks can interleave, so the final status could
land in the middle of a half-arrived progress line and corrupt both. Only stderr
is buffered now — the stream is part of the contract, not something the line
regex should be left to infer.
Lastly, two `stub_readiness_confirm` calls were asserting nothing because they
were never invoked at all: deploy_with_accessories.yml and deploy_with_proxy.yml
both set readiness_delay: 0, so those roles never make the confirming read.
Dead stubs removed rather than asserted; the four call sites where the confirm
does happen now assert it with expect: true.
## Test Coverage
- the wait fails the command when the status cannot be read, and no longer
redirects it to /dev/null
- a boot whose readiness read fails reports the failure instead of printing
"Container not ready yet" until the deadline
- the final status on stdout never lands inside a half-arrived progress line
## Verification
- [x] bundle exec rubocop --parallel passes
- [x] unit tests pass (1902 runs)
- [x] the generated loop run under a real POSIX sh: the deadline exits 0 with the
last status, an unreadable status exits 123 with docker's error on stderr
(verified under GNU xargs semantics, which is what Linux deploy hosts run)
Refs #163
There was a problem hiding this comment.
All reported issues were addressed across 8 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…rgs the host ships The status read is a pipeline, so its exit code is xargs'. When `docker container ls` is the half that fails it pipes nothing, and the two xargs families disagree about what happens next: GNU (no -r) runs `docker inspect` with no container anyway, which exits 1 and makes xargs exit 123; BSD and BusyBox skip the utility and exit 0. So `|| exit $?` catches an unreachable daemon on a Debian host and misses it elsewhere. Both leave $status empty, and empty is not something a working `docker inspect --format` can print — so check that too. The failure path no longer depends on the host's findutils. Verified with the generated command run under both: Debian stable-slim (GNU findutils 4.10.0, /bin/sh -> dash) exits 123 with docker's error on stderr, macOS (BSD xargs) exits 1 with it; `starting` -> deadline exits 0 with the last status and `healthy` exits 0 at once on both. ## Verification - [x] bundle exec rubocop --parallel passes - [x] unit tests pass (1902 runs) - [x] bin/test passes (1921 runs, integration included) Refs #163
mhenrixon
added a commit
that referenced
this pull request
Sep 12, 2026
… output `dash app stale_containers --quiet` in test/cli/app_test.rb leaves :error on both the DASH singleton and SSHKit's global output_verbosity: Cli::Base#initialize_commander sets the commander's verbosity and Commander#configure_sshkit_with mirrors it into SSHKit. Nothing restores either between tests — Commander#reset would, but only `dash alias` calls it — so from that point on every SSHKit.config.output.info in the process is dropped. Whether that mattered depended on the seed. CI run 34709935073 put the quiet test ahead of test/cli/healthcheck/progress_reporter_test.rb on Ruby 3.2 (seed 36230) and three of its assertions saw "", while Ruby 3.3, 3.4 and 4.0 drew seeds that passed the same commit. Reproduced locally with `bin/test --seed 36230`, three failures, same three tests. Pin both to :info in the suite's global setup, beside the Docker pins that answer the same class of problem — a test that wants another verbosity still sets it itself. Refs #166
mhenrixon
added a commit
that referenced
this pull request
Sep 12, 2026
…bridge (#169) * fix(proxy): route every container-creating path through the stage-3c bridge `Dash::Cli::Proxy::Reboot`, `Dash::Cli::Proxy::LoadbalancerReboot` and `dash proxy loadbalancer start` all create the renamed container without ever running the stage-3c bridge. `docker run --volume dash-loadbalancer-config:...` auto-creates the named volume empty when it does not exist, so a `dash proxy reboot` against a host that has never been through `dash proxy boot` brings the new volume into existence before the bridge has had any chance to copy the legacy routing table and ACME cache into it. The next boot then finds the new volume already there and skips the copy for good, via `copy_legacy_config_volume`'s own guard - silently. Since #167 that state also writes the `.legacy-renamed` marker, so recovery needs the marker deleted as well as the volume fixed. Fix it at the source rather than making the marker's heuristic smarter: every path that can create the container, the volume or the network now runs `prepare_boot` first. On both reboot paths this is round-trip neutral - they already spent a round trip on `ensure_apps_config_directory`, which `prepare_boot` carries. Refs #168 * docs(proxy): record why the bridge copies the config volume while it is live Both cubic and a human reader will ask whether `cp -a` over a volume the legacy container still mounts can capture a half-written routing table or certificate. It cannot: dash-proxy renames into place on every writer - the routing table via writeFileAtomic, the dynamic domain and redirect state via their own temp + rename, the response cache via CreateTemp + Rename, and the ACME cache via autocert.DirCache. Written at the shared copy rather than at one caller, since `boot` and both reboots all reach it. Refs #168 * test: stop a --quiet CLI test deciding whether later tests see SSHKit output `dash app stale_containers --quiet` in test/cli/app_test.rb leaves :error on both the DASH singleton and SSHKit's global output_verbosity: Cli::Base#initialize_commander sets the commander's verbosity and Commander#configure_sshkit_with mirrors it into SSHKit. Nothing restores either between tests — Commander#reset would, but only `dash alias` calls it — so from that point on every SSHKit.config.output.info in the process is dropped. Whether that mattered depended on the seed. CI run 34709935073 put the quiet test ahead of test/cli/healthcheck/progress_reporter_test.rb on Ruby 3.2 (seed 36230) and three of its assertions saw "", while Ruby 3.3, 3.4 and 4.0 drew seeds that passed the same commit. Reproduced locally with `bin/test --seed 36230`, three failures, same three tests. Pin both to :info in the suite's global setup, beside the Docker pins that answer the same class of problem — a test that wants another verbosity still sets it itself. Refs #166 * test: pin the proxy boot's round trips per host, not in one cross-host order `on` runs the proxy hosts in parallel threads, and the recorder behind "boot issues no round trip beyond the pinned per-host sequence" appended from both. The pin then spelled out host 1's sequence followed by host 2's, which held only while the two threads happened not to overlap. CI seed 59404 interleaved them (login, bridge, login, bridge, inspect, ...) - same commands, same count per host, different scheduling - and the test failed on a run that issued exactly what it pins. It reproduces standalone here too: 1 in 150. Tag every recorded round trip with the host it went to (the Printer command carries it; a capture reads it off SSHKit::Backend.current, the thread-local the backend sets for its run) and assert each host's own sequence. That is the claim the test was making - the count and the order the gem chooses - minus the one it never meant to: which thread the scheduler ran first. 0 in 300 after. Refs #167
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two round trips come out of every boot, and one of them is the row that scaled with how long a container takes to come up.
The readiness wait moves onto the host.
Dash::Commands::App#wait_for_readybuilds a shell loop that evaluates the same statusDash::Cli::Healthcheck::Pollerused to read one round trip at a time —docker inspect's health status, or thehealthcheck: exec:probe's exit code — returns the moment that status is one the poller accepts, and otherwise keeps looking untildeploy_timeout.Dash::Cli::App::Bootruns it as a single capture with an interaction handler attached, so theContainer not ready yet, retrying in 1s (Xs elapsed, Ys left)beacons still print once a second while it waits. Progress goes to stderr and the final status to stdout, which is what keeps the captured value the status and nothing else.Reaching the deadline exits 0 — it is an answer, and the poller phrases it. A non-zero exit means only that the status could not be read at all (docker unreachable, container gone), which is a broken command and SSHKit's to raise, exactly as it was when that read was a round trip of its own.
The proxy target comes out of
docker run.docker run --detachprints the id of the container it just started; boot captures it and takes the first twelve characters — the same short iddocker container ls --quietprinted — instead of asking docker for it again on the next round trip.Every readiness decision still lives in the poller, word for word: drift,
announce_missing_gate, the readiness-delay confirm, the timeout error. The host loop only decides when to return, never what it means —Dash::Commands::Base::READY_STATUSESis the one thing both sides read, so they cannot drift apart.Closes #163
Round trips per host, per role shape
Counted from the boot path and pinned by tests (
test/cli/app_test.rb), not from a live deploy — see Deviations.boot_stateensure_env_directory(upload!is SFTP)docker run --detachcontainer_id_for_version10dash-proxy deployN1N2stopold versionclean_up_assetsOn the 4-host deploy in the issue (N ≈ 5 on the job host) that is
web7 → 6 andjob9 → 5.container_id_for_versionsaving is a skip — the answer was already on the host's stdout.unhealthystill waits — the issue's one reversed decisionThe issue proposed returning from the wait the moment docker reports
unhealthy, and invited the executor to keep the retry if there was a reason. There is one, and it is decisive: dash's default healthcheck emits--health-interval 1swith no--health-start-period, and docker's default--health-retriesis 3. A container whose app needs longer than ~3s to serve/upis therefore reportedunhealthyat t≈3s andhealthywhen it finishes booting. Today's poller waits through that and accepts the boot; returning early onunhealthywould fail essentially every normal Rails boot at three seconds.So the host loop returns early for exactly the statuses
Poller#acceptable?accepts and waits out the deadline for everything else — which is precisely what the client-side poll did, attempt by attempt. The only behaviour that changes is the beacon cadence: a fixed second instead of 1s, 2s, 3s … , which is strictly more responsive.Test plan
bundle exec ruby -Itest -e 'Dir["test/**/*_test.rb"].grep_v(/integration/).each { |f| require File.expand_path(f) }'— 1899 runs, 0 failuresbundle exec rubocop --parallel— no offensesbin/test— full suite, 1918 runs, Docker +ghcr.io/zoolutions/dash-proxy:v1.1.0.1(MINIMUM_VERSIONunchanged). The integration deploys run the wait loop on real Docker-in-Docker hosts, includingapp_with_roles' uncheckedworkersroledocs/—bundle exec rspec spec/config_docs_spec.rbgreen after the doc-wording updatesshagainst a fakedocker:starting→healthyexits 0 with the status on stdout and beacons on stderr; never-ready exits 1 at the deadline with the last status;no-healthcheck:runningreturns immediately;timeout: 0makes exactly one observation without spinning; the exec branch loops on the probe's exit codetest/commands/app_test.rb), the streaming handler against chunks split mid-line and against an interleaved stdout status (test/cli/healthcheck/progress_reporter_test.rb), the poller's new call pattern (test/cli/healthcheck/poller_test.rb), and at the boot level — the proxy target read from the run, one wait round trip for a healthchecked role, wait-plus-confirm for an unchecked one, the streaming handler wiring, a deadline that fails once rather than waiting twice, and a boot whose status read fails reporting that instead of waiting out the timeoutDeviations & judgment calls
Deviation —
unhealthydoes not return early from the host loop. Reasoned above; it is the one decision in the issue's Decision section I reversed, and the tests pin the behaviour either way.Judgment — the block protocol is
|mode, seconds_left|. The poller needs two different reads from the CLI: the blocking wait, and the plain status confirm after a readiness delay. Splittingwait_for_healthyinto two callables would have churned every poller test, so the block is called with:wait/:confirminstead; procs ignore extra args, so the existing tests' blocks were untouched.seconds_leftis passed so a retried wait cannot overshootdeploy_timeout.Judgment — the run capture is unconditional, not proxy-only. One code path reads better than branching
execute/captureonrunning_proxy?, and the id is what the failure message is about for every role.Judgment —
stub_captureechoes the captured command into SSHKit's output. A stubbedcapture_with_infois intercepted above the Printer and never printed, so movingdocker runfromexecutetocapturemade it invisible to roughly a dozen assertions across three test files. Rather than rewrite them all to read a recorded array, the shared stub helper echoes what it answered into the same streamstdoutedreads. Side effects in a mocha matcher are not lovely;recorded_commandsandstub_boot_statealready use the idiom.Discovery — the docs described the old cost model.
lib/dash/configuration/docs/role.ymlanddocs/app/views/docs/pages/worker_roles.rbboth claimed an exec probe costs "an SSH round trip plus a process spawn per poll" and that dash "polls docker's verdict with backoff". Neither survives this change; both updated.Discovery — a latent bug next door, left alone.
Dash::Cli::Main#container_available?rescuesSSHKit::Runner::MultipleExecuteError, which sshkit 1.25 does not define, so a rollback to a version whose container is missing raisesNameErrorinstead of the intended message. Surfaced when a test stub moved; out of this issue's path and not touched here. Worth its own issue.Review round (94be4cb)
Three findings from the cubic pass, two of them regressions this PR had introduced. All resolved.
2>/dev/nullplus an ignored exit status turned an unreachable daemon or a vanished container into an empty status the loop waited out for the wholedeploy_timeout, then blamed the container. Before this PR that read raised on the spot with docker's own error. Restored: the inspect read no longer redirects stderr and ends in|| exit $?, the CLI no longer passesraise_on_non_zero_exit: false, and the deadline exits 0 instead — so a non-zero exit means exactly one thing. The exec branch is untouched, because there a non-zero exit is the answer "not ready".stub_readiness_confirmcalls asserted nothing because they never ran.deploy_with_accessories.ymlanddeploy_with_proxy.ymlboth setreadiness_delay: 0, and the poller only makes the confirming read when the delay is non-zero. The dead stubs are gone; the four call sites where the confirm genuinely happens now assert it withexpect: true.Re-verified under a real POSIX
shwith GNUxargssemantics (what Linux deploy hosts run): container gone → exit 123 withError: No such object; daemon down → exit 123 withCannot connect to the Docker daemon; deadline → exit 0 with the last status; healthy → exit 0 at once.bin/testgreen at 1921 runs.Follow-up (1774d57). The re-review flagged that
|| exit $?alone is not a portable signal, and it was right about the mechanism even though its example platform is not one dash deploys to. The read is a pipeline, so its exit code is xargs': GNU (no-r) runsdocker inspectwith no container when nothing is piped and exits 123, while BSD and BusyBox skip the utility and exit 0. Both leave the status empty, and empty is not something a workingdocker inspect --formatcan print — so that is checked too, and the failure path no longer depends on the host's findutils.Verified by running the generated command under both:
debian:stable-slim(GNU findutils 4.10.0,/bin/sh→ dash) exits 123 withCannot connect to the Docker daemonon stderr, macOS/BSD exits 1 with it, andstarting→ deadline exits 0 with the last status whilehealthyexits 0 at once on both.