Add circ-celery container image to replace circ-scripts#3529
Add circ-celery container image to replace circ-scripts#3529dbernstein wants to merge 4 commits into
Conversation
|
Claude finished @dbernstein's task in 7m 13s —— View job Code Review — PR #3529
SummarySolid, thoroughly documented change. The DetailsMinor:
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3529 +/- ##
==========================================
- Coverage 93.45% 93.45% -0.01%
==========================================
Files 511 512 +1
Lines 46494 46511 +17
Branches 6346 6347 +1
==========================================
+ Hits 43450 43465 +15
- Misses 1968 1969 +1
- Partials 1076 1077 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Greptile SummaryThis PR introduces a
Confidence Score: 4/5Safe to merge after resolving the double CloudWatch publishing concern for existing circ-scripts deployments. Adding publish_queue_stats to the shared beat_schedule() means any circ-scripts deployment that picks up this code will have both the celery events Cloudwatch camera and the new scheduled task writing to the same CloudWatch namespace simultaneously. Whether this breaks existing alarms depends on the aggregation function in hosting-playbook, but it is a real, observable behavioral change for any deployment that has not yet completed the cutover. Everything else is well-implemented. src/palace/manager/service/celery/celery.py (beat schedule change affects all deployments sharing the codebase) and src/palace/manager/celery/monitoring.py (dual publishing paths now active simultaneously during transition). Important Files Changed
|
| schedule_dir="/var/run/celery" | ||
| mkdir -p "$schedule_dir" | ||
| chown palace:palace "$schedule_dir" | ||
| exec "$CELERY" -A "$APP" beat \ | ||
| --uid palace --gid palace \ | ||
| --schedule "$schedule_dir/beat-schedule" \ | ||
| "$@" |
There was a problem hiding this comment.
Beat schedule file lost on every container recreation
The Celery beat schedule database is written to /var/run/celery/beat-schedule, which lives in the container's ephemeral writable layer. Any time the container is replaced — rolling deployment, crash recovery, ECS task substitution, Kubernetes pod eviction — a fresh container starts with no schedule file. Celery beat then treats every scheduled task as never having run and fires all of them immediately, causing a burst of task executions at each restart. For tasks with side effects or that are expensive, this burst can be disruptive.
Consider either: (a) mounting a persistent volume at /var/run/celery so the schedule file survives container replacement, (b) using a broker-backed scheduler like celery-redbeat that stores schedule state in Redis rather than a local file, or (c) explicitly documenting the ephemeral-burst behaviour so operators know to expect it and can make scheduled tasks idempotent.
| function assert_running() { | ||
| service="$1" | ||
| cid=$(docker compose ps -q "$service") | ||
| if [[ -z "$cid" ]]; then | ||
| echo " FAIL: $service has no container" | ||
| exit 1 | ||
| fi | ||
| running=$(docker inspect -f '{{.State.Running}}' "$cid") | ||
| if [[ "$running" != "true" ]]; then | ||
| echo " FAIL: $service is not running (State.Running=$running)" | ||
| docker compose logs "$service" | ||
| exit 1 | ||
| fi | ||
| echo " OK: $service is running" | ||
| } |
There was a problem hiding this comment.
assert_running is a point-in-time snapshot, not a stability check
docker inspect -f '{{.State.Running}}' returns true only at the instant the check runs. A service that is crash-looping (starts, fails, restarts) can appear healthy here if the check happens to land during a brief "running" window. For celery-beat and celery-cloudwatch this is the only liveness check in the suite, so a restart-looping container will silently pass the smoke test. Consider adding a short sleep (e.g. 5–10 s) followed by a second assert_running call for those two services, or inspecting the restart count (RestartCount in docker inspect) to detect instability.
| FROM common AS celery | ||
|
|
||
| # Set the local timezone so log timestamps line up with the other images. Beat's | ||
| # own schedule is driven by the Celery `timezone` setting, not this. | ||
| ENV TZ=US/Eastern | ||
| RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime | ||
|
|
||
| COPY --chmod=755 docker/celery-entrypoint.sh /celery-entrypoint.sh | ||
|
|
||
| WORKDIR /var/www/circulation | ||
|
|
||
| # The role is provided as the container command, e.g. `docker run circ-celery | ||
| # worker`, with PALACE_CELERY_QUEUES / PALACE_CELERY_CONCURRENCY set for worker | ||
| # pools. See docker/celery-entrypoint.sh for the supported roles and env vars. | ||
| ENTRYPOINT ["/celery-entrypoint.sh"] |
There was a problem hiding this comment.
No
HEALTHCHECK in the celery stage
The other images (scripts, webapp) rely on runit or nginx/uwsgi to signal health, but the celery stage provides no HEALTHCHECK instruction. Without one, ECS, EKS, or any orchestrator that uses Docker health status will treat the container as always healthy — even if the worker has stalled and is no longer consuming tasks. For the worker role, celery inspect ping (running against the same broker) is a natural healthcheck. For beat and cloudwatch, checking that the process is still alive (CMD-SHELL pidof celery || exit 1) would at least catch a silent crash.
Introduce a single circ-celery image that runs one Celery process per container, with the role selected by the container command (beat|worker|cloudwatch). One image backs the beat scheduler, every worker pool, and the CloudWatch metrics camera; running one process per container is what lets the worker pools be autoscaled per queue and the beat scheduler be pinned to a single instance. Unlike circ-scripts, this image does not use runit to supervise several processes, and it logs to stdout/stderr so nothing is lost when an autoscaled worker is scaled away. Queues and concurrency are supplied per-deployment via PALACE_CELERY_QUEUES / PALACE_CELERY_CONCURRENCY, so a single image covers every worker configuration. circ-scripts is left in place; deployments can be cut over role-by-role and it can be dropped in a later change. - docker/Dockerfile: new `celery` build stage - docker/celery-entrypoint.sh: role dispatch (beat|worker|cloudwatch) - .github/workflows/build.yml: build, push, and smoke-test circ-celery - docker/ci/test_celery.sh: integration smoke test for the three roles - docker-compose.yml: celery-beat/worker/cloudwatch dev services - README.md / docker/README.md: image and launch-variable docs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…camera Queue-depth metrics (QueueWaiting / QueueOldestAge) are only a periodic Redis LLEN/LINDEX read plus put_metric_data -- they do not need the always-on `celery events` camera. Publish them from a beat-scheduled task so we can drop the standalone monitoring process entirely. - Extract QueueStatsReporter from the Cloudwatch(Polaroid) camera. The camera now composes the reporter (via inheritance) and still works for the circ-scripts `celery events` deployment; the reporter is usable on its own, decoupled from the events machinery. - Add publish_queue_stats task, scheduled every minute onto the `high` queue. `high` is the always-staffed, time-sensitive queue, so the metric keeps flowing while the default/apply pools are saturated and scaling -- exactly when the autoscaler needs it. - Drop the `cloudwatch` role from circ-celery: remove the entrypoint case, the celery-cloudwatch compose service, the smoke-test assertions, and the PALACE_CELERY_CLOUDWATCH_FLUSH_INTERVAL doc. The image is now beat|worker. The metric namespace, Manager/QueueName dimensions, and values are unchanged, so the existing hosting-playbook CloudWatch alarms keep matching. The Cloudwatch camera class is kept this release (circ-scripts still runs it); it can be removed once circ-scripts is retired. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The entrypoint runs as root and drops to the palace user via `celery --uid`, which does not reset HOME. Without this the worker inherited root's HOME=/root; libpq (psycopg2) probes $HOME/.postgresql/postgresql.crt on connect, hit EACCES on /root (mode 700), aborted the SSL handshake, and fell back to a plaintext connection that an SSL-required Postgres rejects (no pg_hba.conf entry ... no encryption). The phusion-based webapp/scripts images get a palace-usable HOME from my_init; the lean celery entrypoint needs it set explicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Boot the circ-celery image through my_init (like the webapp and scripts images) instead of running the Celery process directly. my_init runs the shared /etc/my_init.d startup scripts -- notably the advisory-locked initialize_instance (alembic upgrade head) -- before the Celery role starts, so every celery container brings the schema to head on startup exactly like the other images. The role is still selected by the container command (beat|worker), which my_init appends to the entrypoint; --skip-runit is used because this image has no runit services (one Celery process per container is what lets the worker pools autoscale). HOME is still pinned to /home/palace for the `celery --uid palace` drop, since my_init does not override an inherited HOME. This keeps the migration system intact without a separate, bespoke migration step: new celery code never starts against an un-migrated schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
29c5fd0 to
408094d
Compare
Description
Introduces a single
circ-celeryDocker image that runs one Celery process per container, with the role selected by the container command (beatorworker). One image backs the beat scheduler and every worker pool.Unlike
circ-scripts— which supervises the beat scheduler and every worker in a single container with runit —circ-celeryruns exactly one process, so each role can be deployed and (for workers) autoscaled independently. Running one process per container is what lets the worker pools be scaled horizontally per queue and the beat scheduler be pinned to a single instance.Key points:
my_init(like thecirc-webapp/circ-scriptsimages), so the shared/etc/my_init.dstartup scripts run before Celery — see "Database migration on startup" below.--skip-runitis used because this image has no runit services; the single Celery process is my_init's main command.beat|worker), which my_init appends to the entrypoint (docker/celery-entrypoint.sh).PALACE_CELERY_QUEUES/PALACE_CELERY_CONCURRENCY, so a single image covers every worker configuration. Concurrency is a single pool shared across all queues a worker consumes; per-queue concurrency = a separate deployment.circ-scriptsis left in place; deployments can be cut over role-by-role and it can be dropped in a later change.Database migration on startup
Like
circ-webappandcirc-scripts,circ-celeryboots throughmy_init, so the shared/etc/my_init.dstartup scripts run before the Celery role starts — most importantly the advisory-lockedinitialize_instance(alembic upgrade head). Every celery container therefore brings the schema to head on start, exactly like the other images, so the migration system stays intact and no separate, bespoke migration step is needed on the deployment side — a new worker/beat never starts against an un-migrated schema.The
celery --uid palacedrop keepsHOME=/home/palaceso that libpq (psycopg2) finds its~/.postgresqlTLS material and negotiates SSL correctly against an SSL-required Postgres, rather than inheriting root'sHOMEand falling back to a rejected plaintext connection.Queue-depth metrics without an always-on process
The queue-depth metrics that drive worker autoscaling (
QueueWaiting/QueueOldestAge,CeleryCloudWatch namespace) are just a periodic RedisLLEN/LINDEXread plusput_metric_data— they don't need the always-oncelery eventscamera. This PR publishes them from a newpublish_queue_statstask on the beat schedule (every minute, routed to thehighqueue), so there's no standalone metrics container and no new artifact/deploy pipeline.QueueStatsReporter; the existingCloudwatch(Polaroid)camera now composes it and still works for thecirc-scriptscelery eventsdeployment.Manager/QueueNamedimensions, and values are unchanged, so the existinghosting-playbookCloudWatch alarms keep matching.high(the always-staffed, time-sensitive queue) keeps the metric flowing while thedefault/applypools are saturated and scaling — exactly when the autoscaler needs it.Motivation and Context
Replace the monolithic
circ-scriptscontainer with independently operable containers: a beat container (pinned to one instance) that kicks off scheduled tasks, and worker containers that autoscale on queue depth.Because the image self-migrates on startup (via
my_init, as above), the deployment can roll a new celery task definition the same way it rolls the web service and let the container migrate-then-start — no gated, out-of-band migration step. On thehosting-playbookside this is what lets the ECS task-service follow the same rollout pattern as the web service.Autoscaling scale-down is safe with the existing Celery config:
task_acks_late,task_reject_on_worker_lost,worker_prefetch_multiplier=1, and a 30-minutetask_time_limitunder the 1-hour broker visibility timeout together ensure a worker killed mid-task has its work redelivered rather than lost.Deployment topology, replica counts, IAM (
cloudwatch:PutMetricDatamoves from the scripts host to the worker pool), and the autoscaling policy live inhosting-playbookand are out of scope here.Alternatives Considered
Image packaging — one configurable image vs. one image per role. We considered building separate
circ-beat/circ-worker(and originallycirc-cloudwatch) images. Rejected: the image contents are identical across roles — only the launch command and a few env vars differ, which are runtime concerns. Separate images would mean N Dockerfile targets to build, scan, and keep version-locked for no isolation benefit. Chosen: a singlecirc-celeryimage, role selected by the container command.Database migration — self-migrate via
my_initvs. a bespoke gated step. An earlier iteration ran the Celery process directly (nomy_init) and left migration to a separate, gated deployment step so new code never ran against an old schema. Rejected in favor of booting throughmy_initlike the other images: the container self-migrates on start (advisory-locked, idempotent), which keeps the migration system intact, needs no new machinery, and lets the deployment roll celery exactly like it rolls the web service.Queue-depth metrics — how to publish
QueueWaiting/QueueOldestAgewithout the always-oncelery eventscamera. The key realization is that these metrics are just a periodic RedisLLEN/LINDEXread plusput_metric_data— the camera's event-stream machinery is only being used as a 60s timer, so any scheduler can do the job. Three options:Keep the always-on camera as its own container. Simplest, but it's a third always-on singleton. On today's EC2 + docker-compose hosts it's effectively free (co-located), so this is only worth eliminating if/when workers move to Fargate (where each always-on task has a cost floor). Rejected as the default because it doesn't advance the "avoid an always-on metrics process" goal.
Scheduled Lambda (EventBridge, every minute). Near-zero run cost and fully decoupled from the worker fleet — the strongest option for an autoscaling signal. Rejected for now because the delivery plumbing doesn't exist: circulation's CI publishes only to GHCR (images) and PyPI, and has no AWS credentials at all. A Lambda would require either granting circulation CI AWS access to push code, or publishing the zip as a versioned artifact that
hosting-playbookfetches — plus VPC-attaching the function to reach the broker Redis. That's a non-trivial, net-new pipeline for a metric we already collect. Kept on the table as the natural choice if we later move to Fargate or otherwise want fleet-independent publishing.Custom beat
Schedulersubclass that polls in the beat process. Would give the camera's fleet-independence (beat is an unavoidable always-on singleton) with zero extra containers, but couples us to beat internals and is more code. Held as a future upgrade if the residual coupling below proves to matter.Chosen: a beat-scheduled task (
publish_queue_stats, every minute on thehighqueue). It ships inside the already-publishedcirc-celeryimage, needs no new artifact pipeline, no AWS creds in CI, and no Lambda/terraform, and reuses the exact metric logic (QueueStatsReporter). Its one tradeoff is that the metric now rides on a worker running the task, rather than an independent process; routing tohigh(the always-staffed, time-sensitive queue) largely defuses this, since autoscaling thedefault/applypools doesn't affecthigh, and a total fleet outage is a separate condition the depth metric can't act on anyway (the existing alarms usetreat_missing_data).How Has This Been Tested?
pytest tests/manager/celery/test_monitoring.py tests/manager/celery/tasks/test_monitoring.py— 18 passed (existing camera tests unchanged + newQueueStatsReporterandpublish_queue_statstests);tests/manager/service/celery/— 7 passed.mypyclean on all changed modules and tests.docker buildx build --check --target celerypasses.docker compose configresolves thebeat/workerservices and confirms the worker receives both the shared environment and itsPALACE_CELERY_QUEUES/PALACE_CELERY_CONCURRENCYlaunch vars.docker/ci/test_celery.sh) brings up Postgres/OpenSearch and verifies the worker answerscelery inspect pingover the broker and that beat starts and stays running — so themy_initstartup migration runs before the role process, exercising the self-migration path.Checklist