Zero-downtime upgrades (systemd)¶
ShinyHub hands new connections to a successor during a compatible upgrade.
Existing app sessions keep using the retiring proxy until they close or its
drain deadline expires; sessions still open at that deadline are disconnected.
On SIGHUP the running control plane re-execs the
new binary, hands off its listening sockets to the successor, drains in-flight
WebSocket sessions, releases the ownership lease, and exits. App processes keep
running throughout (server.shutdown_apps: adopt, the default).
One-time setup¶
- Install the binary at
/usr/local/bin/shinyhub(a real built binary;go runcannot perform graceful reloads). - Set a PID file in
shinyhub.yaml: - Install
deploy/systemd/shinyhub.service, thensystemctl daemon-reloadandsystemctl enable --now shinyhub.
Upgrading¶
Special procedure: 0.12.x to 0.13.0¶
The schedule-provenance upgrade is an intentional exception to the normal
zero-downtime procedure. Use a maintenance window and stop the 0.12.x control
plane; do not use systemctl reload for this version boundary. Version
0.13.0 installs a database admission fence that prevents a still-running 0.12.x
server from launching another unattributed schedule process.
- While 0.12.x is still running, inventory every enabled schedule with
on_success = "roll". Version 0.12.x allowed these schedules on local Docker; 0.13.0 requires every data-producing schedule to use effectiveworker_isolation = "multiplex"and a local native runtime. Move each app to that topology, or disable/remove the producer policy before upgrading. - Replace every manifest
run_on_registerdeclaration with an explicitdeploy_trigger: normallybundle_changefor a cache that must match its bundle, orfirst_deployfor a genuine one-time bootstrap. - Let all in-flight schedule runs finish, verify their terminal state with
shinyhub schedule runs, then stop the old control plane withsystemctl stop shinyhub. Confirm no scheduled-job process or descendant is left on the host; reboot if that is the only reliable way to establish the process fence. - Install 0.13.0 and start the service. Startup validates every enabled stored
producer before serving. An error names the app, schedule, isolation, tier,
or runtime that still needs correction. Stop the service again before
correcting a startup error so
Restart=on-failurecannot retry around your maintenance work. - If startup reports
legacy schedule writer fence, keep all old servers and the new service stopped, verify the listed process trees are gone, and run the new binary's offline resolver:
systemctl stop shinyhub
shinyhub resolve-legacy-schedule-writers
shinyhub resolve-legacy-schedule-writers --acknowledge-processes-stopped
systemctl start shinyhub
The first invocation is read-only and lists the affected app, schedule, and
run IDs. The acknowledged invocation records conservative data uncertainty;
it does not declare the cache safe.
6. Reapply the updated manifests after startup. Migrated schedules default to
deploy_trigger = "never" because 0.12.x did not persist
run_on_register. If the resolver marked a producer as requiring repair,
rerun that exact schedule under the new fence and wait for success:
Check shinyhub apps show APP and shinyhub schedule ls APP; do not return
the app to service while compatibility_quarantined or
producer_repair_required remains true.
For later compatible releases, use the normal handoff:
# 1. Replace the binary in place with the new version.
install -m 0755 ./shinyhub /usr/local/bin/shinyhub
# 2. Trigger the zero-downtime handoff.
systemctl reload shinyhub
A continuous client sees no connection-refused gap; in-flight sessions drain on
the old process for up to server.reload_drain_timeout (defaults to
server.drain_timeout, normally 60s) before any
straggler is force-closed.
For a longer upgrade drain without extending app-version retirement:
This setting is read by the retiring process at its own startup. Editing the file immediately before reload changes the successor's future drain budget, not the already-running parent's budget.
This postpones the upgrade disconnect; it does not transfer existing sessions
to the successor. The retiring proxy continues checking session authorization
throughout its drain. A SIGINT/SIGTERM shortens a reload drain to at most the
ordinary drain_timeout; it does not reset an earlier reload deadline.
For stable single-host native multiplex apps, session relays can remove the reload deadline entirely:
This is opt-in. Configure it before starting the process whose sessions you want to retain; the already-running parent's settings govern that handoff. The old process keeps its existing WebSocket tunnels, closes its database and lifecycle control plane, and retires its session lease when its final session closes. If it launched native backends, it keeps only their stdout/stderr readers and process reapers alive until those backends exit, preserving logs without database access. Adopted backends do not create another output reader on each reload. A ready successor reads sealed leases for the exact backend processes, counts them for admission and hibernation, and performs authorization and usage persistence for the relays. Later reloads can proceed while earlier relays remain alive. Login expiration, access revocation and ordinary service stop still close sessions as usual.
A preserving reload is refused while an app operation, candidate or previous generation is still draining, or when any pool uses elastic isolation. The current process keeps serving; inspect the upgrade log and retry after the transition finishes. At the relay limit, another preserving reload is refused instead of disconnecting older sessions, reserving capacity even if a new session arrives during handoff. Close old tabs to reclaim capacity. The limit bounds retained session relays, not their age or output-only processes still needed by native backends. Retained tunnels continue running the old proxy code, so use an ordinary service restart when a security fix must replace every running proxy immediately.
Compatible app deployments and rolling restarts may run after the control plane
has retired. Their own drain_timeout still applies to retained backend sessions;
preserving a hub reload does not preserve a session across replacement of its
app process. Stop-first changes remain fenced while relayed sessions exist.
Reloading during an app-generation drain is intentionally unsupported. This
mode preserves WebSocket transport; XHR fallback transport and recovery after
an owner crash are outside its continuity guarantee.
Relay IPC uses the private app-data registry when its path fits the Unix socket
limit, then a short private RUNTIME_DIRECTORY if available. For unusually long
paths it falls back to private /tmp/shinyhub-relay-* directories; exclude those
live directories from age-based temporary-file cleanup. Removing a live socket
interrupts authorization checks and closes retained sessions after the verification
grace period.
Do not replace the binary with a legacy build while relays are alive. The
side-effect-free relay-protocol preflight refuses a handoff to a binary lacking
relay support. Auth-secret rotation invalidates relay RPC authentication and
ends retained sessions after the fail-closed authorization grace (at least 90s).
Runtime leases live in the private .reload-relays directory beneath app-data;
a private, uid-scoped local Unix socket carries owner RPCs. Relay protocols use
frozen versioned identity and usage fields, so future upgrades must retain that
compatibility while older relays remain alive.
Run the real Shiny acceptance test with
SHINYHUB_E2E_SESSION_RELAYS=1 node scripts/browser-lifecycle-e2e.mjs. It holds
reactive sessions across repeated reloads, probes page loads, checks the relay
limit, deploys a replacement, and revokes access to a relayed session.
Tunables (shinyhub.yaml)¶
| Key | Default | Meaning |
|---|---|---|
server.pid_file |
(empty) | PID file the ready process writes; required for systemd MAINPID tracking. |
server.upgrade_timeout |
60s |
How long the old process waits for the successor to become ready before aborting the upgrade and continuing to serve. |
server.drain_timeout |
60s |
App-generation retirement, replica removal, and ordinary service shutdown session drain. |
server.reload_sessions |
drain |
Opt-in preserve for stable single-host native multiplex WebSockets. Environment: SHINYHUB_RELOAD_SESSIONS. |
server.reload_relay_limit |
4 |
Maximum retained relay processes, from 1 to 32. Environment: SHINYHUB_RELOAD_RELAY_LIMIT. |
server.reload_drain_timeout |
server.drain_timeout |
Positive duration for established WebSockets on a retiring proxy after a successful reload. Environment override: SHINYHUB_RELOAD_DRAIN_TIMEOUT. |
Notes & limits¶
- Repeated upgrades in drain mode. The handoff refuses another upgrade while the successor's parent is still alive. A long reload drain therefore delays the next upgrade; wait for the retiring proxy to exit before reloading again. It also retains that process's memory and database connection.
- Backend protection during handoff. The successor cannot count sessions still flowing through its parent. While that parent is alive, idle hibernation and lifecycle mutations such as deploy, stop, resize and non-rolling restart are fenced until the parent exits and recovery cleanup finishes. Rolling restarts are accepted into the durable queue and wait. This deliberately trades lifecycle availability for session continuity in drain mode. Session-preserving relays supply cross-process accounting after their control plane retires.
- Multiplex scope. Multiplex workers can be re-adopted. Elastic workers
(
groupedandper_session) have process-local client bindings and are rebuilt during recovery; a longer proxy drain does not preserve their backend sessions. - Service stop budget. Set systemd
TimeoutStopSecabove your ordinarydrain_timeoutplus HTTP and ownership shutdown (allow at least another minute). For a ten-minute drain, a twelve-minute stop budget is appropriate. The supervisor's termination policy can close sessions sooner than ShinyHub. - App changes remain separate. A deploy or schedule roll may retire the backend an old proxy connection uses before the reload drain ends. The longer reload budget does not extend that backend's generation deadline.
- Failure is safe. If the successor fails to start within
upgrade_timeout, the old process keeps serving - the upgrade simply does not happen. - Startup preflight. The handoff re-execs the process's own
argv[0], so at boot the server checks thatargv[0]resolves to an executable (the samePATHlookup the handoff uses) and logs a warning when it does not. With the warning present everySIGHUPfails safe with the current process still serving; fix how the service launches the binary (an absoluteExecStartpath, or a name resolvable on the unit'sPATH) before relying on reloads. - Database migrations must be backward-compatible and non-blocking. During the handoff window both versions briefly run against the same SQLite database. The successor applies any new migrations at startup while the previous version is still serving on the old schema. An upgrade that adds migrations must therefore use additive / expand-contract changes (the previous version keeps working against the new schema) - never a destructive rename or column drop in the same release - and must avoid long-running/locking migrations (SQLite runs them in a transaction that can block the old process); split large backfills or table rewrites out of the upgrade and run them separately afterward. Same-version restarts apply no migrations and are always safe.
- A rollback point is taken automatically. Before applying pending
migrations the successor copies the SQLite database aside as
<dsn>.pre-migration-v<version>-<timestamp>.sqlite, and logs the path. It is written only when migrations are pending, and older snapshots are pruned down to a configurable retention count afterward. See Schema migrations on startup for the opt-out and the Postgres equivalent. - Rolling back to an older binary needs the snapshot. An older build refuses
to serve a database a newer one migrated, exiting
7(schema_incompatible); the unit setsRestartPreventExitStatus=7so systemd stops rather than restart-loops. Put back the pre-migration snapshot along with the old binary, following Getting back to the older build - a snapshot is moved into place, not fed to
shinyhub restore, and its-walsidecar has to go or the rollback silently undoes itself. - systemd MAINPID. With
Type=notify, ShinyHub sendsREADY=1plusMAINPID=<own pid>on startup and after each handoff, so systemd retargets the main PID to the successor. The unit setsRestart=on-failure(notRestart=always): a genuine crash is restarted, but the original PID's deliberate exit 0 after a handoff is a clean success, so systemd does not fight it. After your firstsystemctl reload, verify withsystemctl show -p MainPID shinyhubthat it matches the new PID file. - Scope. This is the systemd/VM path and covers all app runtimes (native, Docker, Fargate, remote-worker). Multi-pod Kubernetes rolling upgrades are part of the separate high-availability project.
- Platform. Linux and macOS only (tableflip does not support Windows).