Skip to content

[Oztechan/CCC#4845] Run the backend as supervised systemd instances (prod + test) - #4846

Open
mustafaozhan wants to merge 2 commits into
developfrom
4845-Run_the_backend_as_a_supervised_systemd_service
Open

[Oztechan/CCC#4845] Run the backend as supervised systemd instances (prod + test)#4846
mustafaozhan wants to merge 2 commits into
developfrom
4845-Run_the_backend_as_a_supervised_systemd_service

Conversation

@mustafaozhan

@mustafaozhan mustafaozhan commented Aug 18, 2026

Copy link
Copy Markdown
Member

Closes #4845

What

Adds a deploy/ directory with a templated systemd unit that supervises the backend, one instance per environment, plus a one-time setup script and the memory analysis behind the tuning.

Nothing outside deploy/ changes — this PR does not touch the build, the backend code, or release.yml.

Why

CI copies the jar to the droplet but nothing starts it, so the process is launched by hand over SSH and manually restarted every 10-20 days once it exhausts RAM.

The application is not leaking. HttpClient is a Koin single, the sync loops are bounded, and Conversion is capped at ~160 rows by its base primary key. The cause is an unconstrained JVM: heap defaults to ¼ of total RAM and max direct memory defaults to the max heap, so both ceilings derive from total RAM and together oversubscribe a 1 GB box. The heap expands toward its maximum over time and is not returned to the OS, which is why it takes a week or two to surface.

What made it a manual chore is that when the kernel OOM killer acts, nothing restarts the service.

Two environments

ccc-backend@.service is a template, so %i gives each environment its own working directory — and therefore its own SQLite file, since application_database.sqlite.db is created relative to CWD.

prod test
Port 8080 8081
Syncs from premium API yes no
MemoryMax 560 MB 240 MB
MemorySwapMax 0 768 MB
Max heap ~224 MB ~108 MB
JIT full tiered C1 only

How both fit in 1 GB

A Ktor/Netty/Koin/SQLDelight JVM has a non-heap floor of ~170-220 MB before any heap, paid per instance. Three things make the second one affordable:

  1. A swapfile — DO droplets ship without one; it costs disk, not RAM.
  2. Asymmetric MemorySwapMax — prod pinned out of swap so its GC pauses stay predictable, test allowed to page out. This is what plain "add swap" would not give.
  3. test does not sync — no premium API calls, no periodic writes, so it idles and its pages are good swap candidates. Also keeps test off the premium API quota entirely.

Ceilings total 800 MB against ~981 MB MemTotal, leaving ~180 MB for the OS. vm.swappiness=10 keeps swap a safety valve rather than routine.

Notes on specific choices

  • No MemoryHigh. It forces reclaim, but a JVM's footprint is almost entirely anonymous pages — the service would stall under pressure instead of failing cleanly and restarting.
  • Cgroup limits are drop-ins, not EnvironmentFile values. systemd expands ${VAR} only in ExecStart, never in directives like MemoryMax; EnvironmentFile reaches the process, not the unit config.
  • No Netty allocator tuning. Netty 4.2 defaults to the adaptive allocator, so io.netty.allocator.* pooled-arena properties are no-ops. MaxDirectMemorySize is the control that applies.
  • UseSerialGC is for determinism, not effect. Ergonomics already selects it below 2 CPUs; the flag only guarantees the choice survives a droplet resize.
  • ProtectHome is computed by the setup script, since hardcoding yes would hide the jar when the deploy directory is under /home or /root.

Rollout

Not automatic. Prod first:

scp -r deploy/ user@droplet:/tmp/ccc-deploy
ssh user@droplet
sudo MIGRATE_FROM=/path/to/current/deploy/dir /tmp/ccc-deploy/setup-server.sh prod

MIGRATE_FROM copies the existing database and newest app-<version>.jar into /opt/ccc/prod; originals are left as a rollback path.

The test instance binds 8081 and therefore needs a build that reads SERVER_PORT from the environment — that is the next PR. The script warns if test is requested before then.

Not verified

The JVM flags, script syntax and template/placeholder parity are validated locally, but this has not been run on a real droplet. The health-check loop reports per-instance failures with the journalctl command to diagnose.

Follow-ups

  • Backend code: SERVER_HOST / SERVER_PORT / SYNC_ENABLED as independent env vars, replacing the overloaded IS_PRODUCTION — required before the test instance can start
  • CI: develop → test, master → prod, with a restart and health check
  • Cap log growth

The jar is copied to the droplet by CI but nothing starts it, so the
process is launched by hand over SSH and manually restarted every 10-20
days once it exhausts the droplet's RAM.

Nothing in the application leaks. On a 1G/1vCPU droplet the JVM's heap
defaults to a quarter of total RAM and max direct memory defaults to the
max heap, so both ceilings derive from total RAM and together
oversubscribe the box. The heap expands toward its maximum over time and
is not returned to the OS, which is why the failure takes a week or two
to surface. When the kernel OOM killer is what acts, nothing restarts.

Add a systemd unit that caps heap, direct memory and metaspace
explicitly, sets MemoryMax below total RAM so systemd restarts the
service rather than the kernel killing it silently, and recovers on its
own via Restart=always plus ExitOnOutOfMemoryError.

Deliberately no MemoryHigh: it forces reclaim, but a JVM's footprint is
almost entirely anonymous pages, which cannot be reclaimed without swap,
so the service would stall rather than fail cleanly and restart.

Claude-Session: https://claude.ai/code/session_013Km3Q6sfLatVr6YM8cffrF
Copilot AI lite review requested due to automatic review settings August 18, 2026 18:21
@mustafaozhan
mustafaozhan requested a review from a team as a code owner August 18, 2026 18:21
@mustafaozhan mustafaozhan self-assigned this Aug 18, 2026
@mustafaozhan mustafaozhan added this to the 2.13.0 milestone Aug 18, 2026
@codacy-production

codacy-production Bot commented Aug 18, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Coverage ∅ diff coverage · +0.00% coverage variation

Metric Results
Coverage variation +0.00% coverage variation
Diff coverage diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (4109f94) 3340 1746 52.28%
Head commit (0034985) 3340 (+0) 1746 (+0) 52.28% (+0.00%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#4846) 0 0 ∅ (not applicable)

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a deploy/ package to supervise the CCC backend jar with systemd on the droplet, aiming to eliminate manual starts/restarts and prevent kernel OOM kills from leaving the API down.

Changes:

  • Introduces a systemd unit template with JVM memory ceilings + restart-on-failure/OOM behavior.
  • Adds an idempotent setup-server.sh bootstrap script to install/enable the unit and perform a basic health check.
  • Documents the rationale and memory sizing analysis in deploy/README.md.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
deploy/setup-server.sh Bootstraps the droplet: selects jar, writes unit from template, (re)starts service, performs health check.
deploy/README.md Documents why systemd is needed, memory sizing, and how to run setup + operate day-to-day.
deploy/ccc-backend.service.template Defines the supervised systemd service with JVM sizing flags, memory cgroup ceiling, restart policy, and hardening.
Suppressed comments (1)

deploy/setup-server.sh:44

  • This log line invokes java via PATH, which can differ from what the unit runs (/usr/bin/java). Using the same absolute path makes the output accurate and avoids failures if PATH is unusual under sudo/root.
log "Java       : $(java -version 2>&1 | head -1)"

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread deploy/setup-server.sh
[[ -d $APP_DIR ]] || die "APP_DIR does not exist: $APP_DIR"
id "$APP_USER" &>/dev/null || die "user does not exist: $APP_USER"
APP_GROUP="$(id -gn "$APP_USER")"
command -v java &>/dev/null || die "java not found on PATH; install a JRE 21+ first"
Comment thread deploy/setup-server.sh Outdated
Comment on lines +53 to +59
if [[ ! -f "$APP_DIR/$JAR_NAME" ]]; then
newest_jar="$(find "$APP_DIR" -maxdepth 1 -name 'app-*.jar' -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -1 | cut -d' ' -f2-)"
[[ -n $newest_jar ]] || die "no $JAR_NAME and no app-*.jar found in $APP_DIR"
log "Promoting $(basename "$newest_jar") -> $JAR_NAME"
cp "$newest_jar" "$APP_DIR/$JAR_NAME"
fi
Comment thread deploy/setup-server.sh Outdated
Comment on lines +111 to +118
log "Writing $UNIT_PATH"
sed -e "s|__USER__|$APP_USER|g" \
-e "s|__GROUP__|$APP_GROUP|g" \
-e "s|__APP_DIR__|$APP_DIR|g" \
-e "s|__JAR_NAME__|$JAR_NAME|g" \
-e "s|__MEMORY_MAX__|$memory_max|g" \
-e "s|__PROTECT_HOME__|$protect_home|g" \
"$TEMPLATE" > "$UNIT_PATH"
Comment thread deploy/README.md Outdated
supported way to apply new settings. It will:

1. promote the newest `app-<version>.jar` to the stable name `ccc-backend.jar`;
2. derive `MemoryHigh`/`MemoryMax` from the droplet's actual RAM;
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 55.29%. Comparing base (4109f94) to head (0034985).

Additional details and impacted files
@@           Coverage Diff            @@
##           develop    #4846   +/-   ##
========================================
  Coverage    55.29%   55.29%           
========================================
  Files          169      169           
  Lines         3033     3033           
  Branches       452      452           
========================================
  Hits          1677     1677           
  Misses        1331     1331           
  Partials        25       25           

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Convert the unit to a systemd template, ccc-backend@.service, so each
environment runs as its own instance with its own working directory and
therefore its own SQLite file. Two instances sharing a directory would
corrupt a single database, since the file is created relative to CWD.

Fitting a second JVM on a 1G droplet needs three things. A swapfile,
which DO droplets ship without and which costs disk rather than RAM. An
asymmetric MemorySwapMax, pinning prod out of swap so its GC pauses stay
predictable while the mostly-idle test instance pages out — this is what
plain "add swap" would not give. And a test instance that does not sync,
keeping it off the premium API quota and leaving its pages idle enough
to be good swap candidates.

Ceilings total 800M against ~981M of MemTotal, leaving ~180M for the OS.

Per-instance cgroup limits are drop-ins rather than EnvironmentFile
values, because systemd expands ${VAR} only in ExecStart and never in
directives like MemoryMax.

The test instance additionally needs a build that reads SERVER_PORT from
the environment; the script warns when it is requested. Setup also gains
MIGRATE_FROM, which copies an existing database and jar into the prod
instance directory without touching the originals.

Claude-Session: https://claude.ai/code/session_013Km3Q6sfLatVr6YM8cffrF
@mustafaozhan mustafaozhan changed the title [Oztechan/CCC#4845] Run the backend as a supervised systemd service [Oztechan/CCC#4845] Run the backend as supervised systemd instances (prod + test) Aug 18, 2026
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run the backend as a supervised systemd service

2 participants