Skip to content

Move the blob tree off the block volume onto the system disk - #65

Merged
openipc-ai merged 3 commits into
masterfrom
chore/retire-block-volume
Aug 23, 2026
Merged

Move the blob tree off the block volume onto the system disk#65
openipc-ai merged 3 commits into
masterfrom
chore/retire-block-volume

Conversation

@openipc-ai

Copy link
Copy Markdown
Collaborator

The 100 GB Hetzner volume attached to webber-eu held 13 GB, and 4.2 GB of that was empty directories.

ActiveStorage's DiskService creates key[0..1]/key[2..3]/ shard directories on write and never removes them on purge, so every snapshot deleted since 2023 left two behind. df -i reported 1,168,972 inodes against ~110k real files; sampling 20 of the 1,296 top-level shards found 16,079 directories of which 16,033 were empty. storage:reap cannot reclaim these — it works from database rows and only ever deletes files.

What the volume actually holds:

Tenant On disk Real data
github-releases 4.3 GB re-downloaded hourly from GitHub, excluded from the backup
github-mirror 3.6 GB git mirrors of public repos
storage ~5 GB 0.34 GB of live blobs on a two-day retention

The system disk has 52 GB free, so the volume buys nothing but a line on the invoice.

What changes here

storage/ was the only path pinned to the mount point. It moves to /srv/www/shared/storage, beside dev-storage, files and dl. The purge and reap containers bind-mount the same path and move with it.

/srv/github-releases and /srv/github-mirror are host symlinks that become real directories, so nothing in this repo referred to them by mount point.

Host side

The data has already been copied and the empty directories swept. Once this merges, /srv/www/deploy-src gets a git pull and prod restarts on the new mount; the volume stays attached and untouched as a rollback until it is detached from the console.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MFwmYHCbci2esgc8AMmzJR

The 100 GB Hetzner volume held 13 GB, and 4.2 GB of that was empty
directories: ActiveStorage's DiskService creates key[0..1]/key[2..3]/
shards on write and never removes them on purge, so every snapshot
deleted since 2023 left two behind. `df -i` showed 1,168,972 inodes
against ~110k real files. storage:reap cannot reclaim them -- it works
from database rows and only ever deletes files.

What is actually on the volume is 4.3 GB of firmware assets re-downloaded
hourly from GitHub, 3.6 GB of git mirrors of public repos, and 0.34 GB of
live blobs on a two-day retention. The system disk has 52 GB free, so the
volume buys nothing but a line on the invoice.

storage/ is the only path that was pinned to the mount point. It moves to
/srv/www/shared/storage, beside dev-storage, files and dl. The purge and
reap containers bind-mount the same path and move with it.

/srv/github-releases and /srv/github-mirror are host symlinks that become
real directories, so nothing in this repo refers to them by mount point.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MFwmYHCbci2esgc8AMmzJR
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Move ActiveStorage disk mounts to /srv/www/shared/storage (retire block volume)

⚙️ Configuration changes 📝 Documentation 🕐 10-20 Minutes

Grey Divider

AI Description

• Repoint production Rails container storage bind-mount to the system disk path.
• Update snapshot purge/reap job containers to use the same new storage location.
• Adjust restore documentation to reflect the new blob-tree path and ownership step.
Diagram

graph TD
  host_storage("/srv/www/shared/storage") --> web_prod["web-prod container"] --> rails["Rails (ActiveStorage)"]
  host_storage --> purge_job["purge/reap container"] --> rails
  host_socket("/run/mysqld") --> web_prod
  host_socket --> purge_job
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep block volume; add periodic empty-shard cleanup
  • ➕ Avoids changing production mount points
  • ➕ Directly addresses inode/empty-directory accumulation issue
  • ➖ Requires writing/maintaining a safe directory-pruning tool
  • ➖ Still pays for the volume and keeps operational complexity
2. Move ActiveStorage to object storage (S3-compatible)
  • ➕ Eliminates inode/directory shard issues entirely
  • ➕ Improves portability and simplifies host storage management
  • ➖ Larger migration effort (service config, credentials, backfill/transition)
  • ➖ Potential cost/egress and operational changes
3. Patch/customize DiskService directory layout/cleanup
  • ➕ Targets the root cause within the storage implementation
  • ➕ Could reduce filesystem churn long-term
  • ➖ Framework-level customization risk across Rails upgrades
  • ➖ More complex than needed given current low live-blob size/retention

Recommendation: The PR’s approach (move the bind-mount to the system disk and stop relying on the block volume) is the most pragmatic option given the low live-blob footprint and ample free space. Alternatives either add ongoing tooling/maintenance (cleanup scripts) or significantly expand scope (object storage / framework customization) without clear benefit for a two-day retention blob tree.

Files changed (3) +7 / -6

Documentation (1) +4 / -3
RESTORE.mdUpdate restore steps to use /srv/www/shared/storage for blob tree +4/-3

Update restore steps to use /srv/www/shared/storage for blob tree

• Replaces the old Hetzner volume mount path with the new system-disk storage directory in the ownership fixup command. Updates host prerequisites to describe the blob tree location and clarify that an empty, uid-1000-owned directory is sufficient on restore.

deploy/RESTORE.md

Other (2) +3 / -3
docker-compose.ymlSwitch web-prod ActiveStorage bind-mount to system disk storage directory +1/-1

Switch web-prod ActiveStorage bind-mount to system disk storage directory

• Changes the production container volume mapping for /rails/storage from the Hetzner volume mount to /srv/www/shared/storage. Keeps other shared mounts (mysqld socket, releases cache, files, dl) unchanged.

deploy/docker-compose.yml

purge-snapshots.shPoint purge + storage:reap job containers at the new storage mount +2/-2

Point purge + storage:reap job containers at the new storage mount

• Updates both docker run invocations to bind-mount /srv/www/shared/storage at /rails/storage, aligning the nightly purge and reap jobs with the new ActiveStorage disk location.

deploy/purge-snapshots.sh

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 23, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Storage mount may be unwritable ✓ Resolved 🐞 Bug ☼ Reliability
Description
The new bind mount path /srv/www/shared/storage is used without any preflight check that it exists
and is owned/writable by uid 1000, even though the container runs as uid 1000. If the directory is
missing or root-owned on a rebuilt/rollback host, ActiveStorage writes and purges will fail with
EACCES.
Code

deploy/docker-compose.yml[R42-45]

   - /run/mysqld:/run/mysqld
   - /srv/github-releases:/srv/github-releases:ro
-      - /mnt/HC_Volume_103161270/storage:/rails/storage
+      - /srv/www/shared/storage:/rails/storage
   - /srv/www/shared/files:/rails/public/files
Evidence
The PR changes production’s bind mount to a new host directory. The container image is explicitly
configured to run as uid 1000 (non-root), and the repo’s own restore docs state that root-owned
blobs cause EACCES; however, the deploy and cron scripts do not create/validate ownership of the
new host path before mounting it.

deploy/docker-compose.yml[28-46]
deploy/purge-snapshots.sh[15-35]
Dockerfile[109-116]
deploy/RESTORE.md[97-106]
deploy/RESTORE.md[111-122]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Production now bind-mounts `/srv/www/shared/storage` into `/rails/storage`, but deploy-time tooling does not assert that the host directory exists and is writable by the container’s runtime user (uid 1000). A missing or root-owned directory will cause ActiveStorage writes/purges to error with `EACCES`.
## Issue Context
- `web-prod` mounts `/srv/www/shared/storage:/rails/storage`.
- The image runs as an unprivileged `rails` user with uid/gid 1000.
- RESTORE.md already documents that wrong ownership causes `EACCES`, but nothing enforces this at deploy/cron runtime.
## Fix Focus Areas
- deploy/deploy.sh[68-106]
- deploy/purge-snapshots.sh[15-46]
- deploy/RESTORE.md[97-122]
## Suggested changes
1. Add a small helper in `deploy.sh` (called before `compose run db:migrate` and before `compose up`) that:
- fails fast if `/srv/www/shared/storage` is missing, OR
- creates it with correct ownership/permissions (e.g. `install -d -o 1000 -g 1000 -m 0755 /srv/www/shared/storage`).
- optionally verifies it is writable by uid 1000 (check `stat -c %u:%g` and/or try a write as uid 1000).
2. Add the same preflight to `purge-snapshots.sh` before the `docker run` commands, so cron cannot run against a misconfigured host and/or accidentally create a root-owned directory.
3. Update RESTORE.md to include an explicit `mkdir/install -d` step (not only `chown -R`) so a rebuild restore is deterministic.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can turn these tips off under Display preferences

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread deploy/docker-compose.yml
openipc-ai and others added 2 commits August 23, 2026 19:34
Purging a snapshot removes its blob files but not the two directories
DiskService created to hold them, so the tree gains dead directories at
exactly the rate it loses blobs. Since 2023 that reached 1.04 million of
them -- 4.2 GB, and slow enough to walk that `du` on the tree took
minutes on network storage.

They were swept by hand during the volume migration. This keeps them
swept: the nightly purge is the right place, because it is the thing
that creates them.

Two passes, because emptying a leaf makes its parent empty. The blob
root moves to a variable so the mount and the sweep cannot drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MFwmYHCbci2esgc8AMmzJR
The blob tree used to be a mount point, so it always existed. As a plain
host directory it does not, on a rebuilt host or after a restore -- and
Docker creates a missing bind-mount source itself, owned by root. The
container runs as uid 1000, so every upload and every purge then fails
with EACCES while the health check still reports the container up.

deploy.sh creates it with the right ownership before anything mounts it,
per environment, and refuses to deploy if it exists owned by someone
else. purge-snapshots.sh does the same, because cron reaches the host
before a deploy does. RESTORE.md gains the install -d step it was
missing; the recursive chown alone cannot help a directory that is not
there yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MFwmYHCbci2esgc8AMmzJR
@openipc-ai
openipc-ai merged commit a122b14 into master Aug 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant