Internals
View as MarkdownRolling vs stop-first for a Laravel service with processes (expertn)
Question: can every process of one NebulaCtrl service (web, worker, scheduler, ssr from one serversideup/php image) deploy "rolling" (new container healthy before the old one stops), or do some need stop-first?
Primary sources only. Pinned versions: laravel/framework 12.x at 18bf64a (v12.70.2), laravel/docs 12.x, serversideup/docker-php at af1462e, inertia-laravel 2.x at 7723950, moby at 9aa9edf. Repo citations are file:line at NebulaCtrl 74d9a62. Anything not verified from a primary source says unverified.
Verdict#
| Process | Strategy | Why | Settings to change |
|---|---|---|---|
| web | rolling (default) | Stateless; the image stops it with SIGQUIT, which nginx/php-fpm treat as graceful. | None. 30 s grace is fine. |
| worker | rolling (default) | Redis pops are atomic, so no job is claimed twice. The risk is not duplicates but (a) a job killed mid-run when grace < job time and (b) old code briefly pulling jobs. | stop_grace_seconds = 75 (was 30). Keep --timeout=60 and retry_after >= 90. If any job sets $timeout above 60, raise grace to $timeout + 15 and retry_after to $timeout + 30. |
| scheduler | stop-first: keep singleton = true |
schedule:work has no cross-instance guard of its own. Tasks without onOneServer() would run twice during overlap. |
Add stop_grace_seconds = 10 (was 30). In the app, give every long task withoutOverlapping(<minutes>) with a short expiry. |
| ssr | rolling (default) | Stateless HTTP renderer. If it is down or errors, inertia-laravel falls back to client-side rendering instead of failing the request. | stop_grace_seconds = 5. Nothing in the process handles SIGQUIT/SIGTERM, so it likely waits out the full grace anyway (see 5). |
| shared env | n/a | Migration isolation only works with a cache shared by all containers. Losers of the lock do not wait. | Confirm CACHE_STORE is redis (or database), not file/array. Optional, cleaner: run migrate --force as Nebula pre_deploy and set AUTORUN_LARAVEL_MIGRATION=false (see 4). |
0. What NebulaCtrl does#
- A process stops first when its strategy is not rolling, it is a singleton, or the service has volumes:
internal/agent/engine/process_deploy.go:497; same rule ininternal/services/scaling_view.go:238-243. Plan-level: rolling as soon as any process rolls,internal/deployments/plan.go:61-74. - Processes are rolled one at a time, in payload order. Each waits for its own health check before the next starts (
process_deploy.go:572-583,641-701). A stop-first process stops its old container at the start of its own turn (process_deploy.go:665-667,deploy.go:599-612). - Old containers of rolling processes are not touched until the commit point after the last process is healthy (
process_deploy.go:594-603; comment at640). So an old worker keeps consuming for the whole deploy, including while the singleton scheduler is stopped and restarted. Only then doesretireAllrun (process_deploy.go:854-920). It waitsDrainDelay(default 2 s,engine.go:73-75,329-331), then stops every old container in parallel, each with its own process's grace (process_deploy.go:884-895,902-918). - If a later process fails, the new containers are removed, the previous rolling containers were never touched, and stopped singletons are started again (
process_deploy.go:924-940). Docs:docs/operations/processes.md:56-67. - Signal. The agent calls Docker's
ContainerStop(id, timeout)(internal/agent/docker/sdk.go:414-421) and sets the containerStopTimeoutto the process's grace (deploy.go:710-728). It sets no signal unless the service's Docker optionstopSignalis set (sdk.go:302,dockeropts.go:105-106). Docker then uses the image'sSTOPSIGNAL, else SIGTERM:docker stop, with the merge of image config into container config atmoby daemon/commit.go:116-118andcontainer.go:596-606. After the grace elapses it sends SIGKILL (daemon/stop.go:56-67).docs/operations/deployments.md:87says "SIGTERM", which holds only when the image has noSTOPSIGNAL. - serversideup's fpm-nginx/fpm/fpm-apache images set
STOPSIGNAL SIGQUIT(Dockerfile:279-280; docs); the cli image uses SIGTERM (cli.md). Processes share one image and the stop-signal option is service-wide (docs/operations/processes.md:249), so every process of expertn gets SIGQUIT if the image is fpm-nginx (unverified which variant expertn uses). serversideup's own queue/scheduler guides tell you to override to SIGTERM (queue.md:21, task-scheduler.md:28). Forqueue:workthat makes no difference (2). The Dockerfile comment says SIGQUIT was chosen for a graceful nginx/fpm shutdown "instead of S6's preferred SIGTERM", so a service-widestopSignal = SIGTERMwould change how web stops (unverified effect); leave it unset.
1. Is overlap safe, per process?#
web. Yes. Nothing is held in the container; the proxy switches only after the new container is healthy and verified through the proxy (process_deploy.go:743-851). The old one drains for 2 s and then gets a graceful stop. The only overlap issue is old code running against a possibly newer schema (see 4).
worker. No double execution: a worker takes a job with a single Lua script that pops it and moves it to :reserved in one Redis step (LuaScripts::pop, RedisQueue::retrieveNextJob). Two workers cannot receive the same job. What can go wrong:
- Version skew. Until retire, old workers keep pulling. A job dispatched by new code can run on old worker code (missing class or changed constructor, so
unserialize/class-not-found failures), and vice versa. Laravel states workers run the code they started with: "they will not notice changes to your code without being restarted". Keep job payloads backward compatible for one release. If that is not acceptable, the only stop-first switch issingleton = true, which also pins the worker to one replica (scaling_view.go:238-243); the backlog then accumulates for about the grace period and Redis keeps the jobs. - Job killed mid-run, if grace is shorter than the job (see 2).
scheduler. Not safe without a guard; see 3.
ssr. Yes; see 5.
2. Worker shutdown, timeouts and grace#
What queue:work does on SIGTERM/SIGQUIT/SIGINT. With pcntl available the worker installs handlers that only set a flag: SIGQUIT, SIGTERM, SIGINT set $shouldQuit = true; SIGUSR2 pauses, SIGCONT resumes (Worker::listenForSignals). The flag is checked between jobs: after runJob returns the loop calls stopIfNecessary, whose shouldQuit branch returns exit 0 (Worker.php:170-250, 358-372). So the worker finishes the current job and then exits cleanly. SIGQUIT (the image's signal) and SIGTERM behave identically. The docs confirm the dependency on signal handling indirectly: "block_for of 0 ... will also prevent signals such as SIGTERM from being handled until the next job has been processed". pcntl is bundled in serversideup images (default-configurations.md:89).
How the four numbers relate.
--timeout(default 60): if a job runs longer, "the worker processing the job will exit with an error". Mechanism: aSIGALRMset per job kills the worker (registerTimeoutHandler, Worker.php:261-290). The job's$timeoutproperty overrides it: "it will take precedence over any timeout specified on the command line", seetimeoutForJob, Worker.php:310-313.retry_after(per connection inconfig/queue.php; the docs' default is 90): "the job will be released back onto the queue if it has been processing for [retry_after] seconds without being released or deleted". Reserved jobs are re-queued bymigrateExpiredJobs(RedisQueue.php:327-335). A connection without the key falls back to 60 (RedisConnector.php:47).- Rule: "The
--timeoutvalue should always be at least several seconds shorter than yourretry_afterconfiguration value ... If your--timeoutoption is longer than yourretry_afterconfiguration value, your jobs may be processed twice." Also: "a job's 'timeout' value should always be less than its 'retry after' value". - Stop grace is the supervisor's
stopwaitsecsequivalent: "You should ensure that the value ofstopwaitsecsis greater than the number of seconds consumed by your longest running job. Otherwise, Supervisor may kill the job before it is finished processing." Nebula'sstop_grace_secondsis exactly that value (docs/operations/processes.md:46-48).
Is --timeout=60 with grace 30 a problem? Yes, for jobs that can run 30-60 s. SIGQUIT arrives; the worker keeps running the job; at 30 s Docker sends SIGKILL, and the job dies without the SIGALRM path (no failed-job record, no JobTimedOut event). It stays in :reserved and returns after retry_after, with its attempt count already incremented (Lua pop sets attempts+1, LuaScripts.php:78-81). With --tries=3, the third such kill ends as MaxAttemptsExceededException on pickup (markJobAsFailedIfAlreadyExceedsMaxAttempts, Worker.php:592-612). Side effects of the killed run may repeat on the retry. Jobs under about 25 s are unaffected.
Recommended (derivation, not a quote): no job can outlive max(--timeout, job $timeout) because the alarm kills the worker at that point, so grace needs to cover that plus a margin.
--timeout=60(keep),retry_after >= 90(keep, check expertn'sconfig/queue.php; unverified),stop_grace_seconds = 75.- Cost: a busy worker can delay the end of a deploy by up to 75 s, because retire waits for all stops and
post_deployfollows (process_deploy.go:900-919). An idle worker exits within its--sleepinterval.
3. Scheduler#
Does overlap of two schedule:work run tasks twice? Yes, for any task without a lock. schedule:work fires schedule:run at second 0 of each minute (ScheduleWorkCommand.php:56-78); each instance evaluates due tasks independently. Laravel states: "if the task scheduler is running on three worker servers, the scheduled task will run on all three servers". An overlapping old+new pair is the same situation.
| Task kind | Protected during overlap? | How |
|---|---|---|
->onOneServer() |
Yes | Atomic lock mutexName + 'Hi' (the minute), 3600 s TTL (CacheSchedulingMutex.php:43-57, checked in ScheduleRunCommand.php:131,161). Requires a shared cache: "database, memcached, dynamodb, or redis ... all servers must be communicating with the same central cache server". |
->withoutOverlapping() only |
Partly | Per-task cache lock (CacheEventMutex.php:41-52, Event.php:146-149): prevents a task from running while a previous run of it is still going, so two instances will not run it simultaneously, but a run that already finished can be repeated by the other instance within the same minute. Docs: "behind the scenes ... utilizes your application's cache to obtain locks". |
| Neither | No | Runs once per scheduler instance. Closures and Schedule::job() (which only dispatches) are included. |
Is singleton (stop-first) still right? Yes. It is the only setting that guarantees no overlap regardless of how each task is annotated. Nebula documents this choice: processes.md:42-44,83-85.
Cost of the gap.
schedule:workdoes not catch up. The loop startsschedule:runonly whenCarbon::now()->second === 0and that minute was not already run (ScheduleWorkCommand.php:59-66). A task is due only if its cron expression matches the current minute (Event::isDue→expressionPasses, Event.php:272-280). Every minute inside the gap is lost, including adailyAt()that falls in it. Laravel's docs describeschedule:workas the local-development runner (#running-the-scheduler-locally); serversideup recommends it for containers (task-scheduler.md:9,18).- Gap length = old stop + create/start + autorun (DB wait, migrate check,
optimize: automations.sh:445-471) + the port-less health period (processes.md:52). Inference: the old stop likely takes the whole grace.ScheduleWorkCommandregisters no signal handlers (read in full), PHP is PID 1 (exec "$@", entrypoint:71), and Linux delivers to a container's init only signals it has a handler for (pid_namespaces(7)). Nebula does not enableinitby default (engine.go:134,dockeropts.go:105). Not measured. Hencestop_grace_seconds = 10for the scheduler: shortens the gap by about 20 s at the price of SIGKILLing a task that is mid-run (SIGKILL kills the whole container, children included). - Stuck locks after a kill.
withoutOverlapping()locks default to 24 h (ManagesAttributes.php:70,145-150) and are released only when the task finishes (Event.php:838-843). A SIGKILL mid-task leaves the lock, and the task is silently skipped until expiry orschedule:clear-cache(docs: "typically only necessary if a task becomes stuck due to an unexpected server problem"). UsewithoutOverlapping(<minutes>)with a realistic expiry on every long task. Unverified: which tasks expertn defines. - Alternative with no gap: a Nebula cron task running
php artisan schedule:runevery minute withconcurrency = "forbid"(processes.md:83-85,128-134). The same stuck-lock caveat applies if the task is killed. - Alternative: roll the scheduler. Only if every task has
onOneServer()and the cache is Redis. I do not recommend it without auditing expertn's schedule (unverified).
4. Migrations through serversideup autorun#
What autorun does. The entrypoint runs entrypoint.d/*.sh before exec "$@" for any command, not only the default one (entrypoint:40-71). 50-laravel-automations.sh has no check on the command, so it runs in web, worker, scheduler and ssr (bun) containers (script:47-50,445-471). With isolation on, it runs php artisan migrate --force --isolated, but only if migrate:status succeeds; on a first deploy (no migrations table) it silently omits --isolated (script:122-132). serversideup: "ensure no other containers are running a migration"; "memcached, redis, dynamodb, database, file, or array" cache, same central cache for all servers.
Is --isolated an atomic cache lock? Yes. Laravel: "Laravel will acquire an atomic lock using your application's cache driver before attempting to run your migrations. All other attempts to run the migrate command while that lock is held will not execute; however, the command will still exit with a successful exit status code". Source: MigrateCommand implements Isolatable (line 21); Command::execute takes CacheCommandMutex::create, and on failure prints "already running" and returns the success code without running (Command.php:197-206); the lock is store->lock(...)->get() with a default TTL of one hour (CacheCommandMutex.php:45-60), released in finally (Command.php:217-219).
Processes that lose the lock do not wait and do not fail: they exit 0 from the migrate step and continue to optimize and the real command, possibly while the winner is still altering the schema. Within one Nebula deploy this cannot happen between different processes: they start one after another, each after the previous is healthy (0), and a container is not healthy until its entrypoint scripts finished (the health check runs against the main command, which execs only afterwards; for fpm-nginx the start period is 60 s, Dockerfile:286). The first process to start migrates; the rest find nothing to do. Concurrent starts are only possible for replicas of one process (worker with replicas > 1) or a restart-policy restart. Unverified: whether replicas of a process are created in parallel (createAndStartAll, not read).
Risks to know:
- Lock outlives a killed winner. If the container running the migration is SIGKILLed or the deploy is cancelled mid-migration, the
finallynever runs and the lock remains up to 1 h; the next--isolatedrun exits 0 without migrating, and the new containers start on an unmigrated schema. Moving migration to Nebulapre_deployavoids this: it runs once, before any previous container is touched, and a failure aborts the deploy (process_deploy.go:521-526;processes.md:61; the Laravel example atprocesses.md:111-112). Unverified: whether Nebula runspre_deploythrough the image entrypoint (which would run autorun first as well; harmless with isolation). - Cache not shared. With
CACHE_STORE=fileorarrayeach container has its own lock store, so isolation protects nothing (the same flag silently "works"). Useredisordatabase. The scheduler'sonOneServer/withoutOverlappinghave the same requirement. - Old code on the new schema. Rolling keeps old web and old worker running after the new web has migrated until the commit/retire step (
process_deploy.go:594-603). Any non-maintenance deploy has this window; migrations must be backward compatible with the previous release (add before use, drop in the next release). Stop-first would only shrink, not remove, the window for that process. Unverified: expertn's migrations. - Autorun's
config:clear/optimizewrite inside each container's own filesystem (no volumes), so there is nothing shared to race on (script:56-58,168-256).
5. SSR and web#
- web: see 1. Stateless; port 8080 behind the proxy; 30 s grace with SIGQUIT.
- ssr: rolling is safe. The Laravel side posts pages to the SSR server over HTTP and, on any exception or empty response,
dispatch()returnsnull, so the page is rendered client-side instead of returning an error (HttpGateway::dispatch, lines 17-41).ssrhas a port but no domain, so Nebula does not route it through the proxy (process_deploy.go:199,routedProcessneeds both); it is reached by the aliasssr.<service>(docs/operations/networking.md:37), unverified whichINERTIA_SSR_URLexpertn sets (inertia-laravel's default ishttp://127.0.0.1:13714, config, which would not reach another container). - Asset-version mismatch during overlap. Inertia compares the
X-Inertia-Versionrequest header with the server's current version and answers409+X-Inertia-Location, which makes the client do a full page visit (protocol; asset versioning). During overlap a browser may hit old web, then new web (or the reverse), producing one extra full reload, not an error. With Vite the version is derived from the manifest automatically. The SSR-specific risk: old web calling the new SSR bundle (or vice versa) can produce server HTML that does not match the client bundle, so hydration warnings until the browser reloads. If old and newssrcontainers share thessr.<service>alias, which one answers during overlap is unverified (I found no primary Docker documentation on alias resolution with several holders). Window is the overlap only (seconds to tens of seconds). - Stopping ssr. Inertia's server registers no SIGTERM/SIGQUIT handler; its only shutdown path is the HTTP
/shutdownendpoint used byinertia:stop-ssr(server.ts:95,151-153; deployment docs). Whether Bun terminates on SIGQUIT as PID 1 is unverified; if it does not, every deploy waits the full grace on the old ssr container (not user-visible, but it holds uppost_deploy).stop_grace_seconds = 5costs nothing because killing an in-flight render only triggers the client-side fallback above.
Summary of changes for expertn#
[services.expertn.processes.worker]
command = ["php", "/var/www/html/artisan", "queue:work", "--tries=3", "--timeout=60"]
stop_grace_seconds = 75 # was 30; must exceed max(--timeout, any job $timeout)
[services.expertn.processes.scheduler]
command = ["php", "/var/www/html/artisan", "schedule:work"]
singleton = true # keep: stop-first
stop_grace_seconds = 10 # was 30; shorter gap, tasks must tolerate a kill
[services.expertn.processes.ssr]
command = ["bun", "/var/www/html/bootstrap/ssr/ssr.js"]
stop_grace_seconds = 5App-side: retry_after >= 90 on the Redis connection, CACHE_STORE=redis, withoutOverlapping(<minutes>) with a short expiry on long scheduled tasks, backward-compatible migrations and job payloads for one release.