R-1214: the second-drive copy runs in a 512 MB memory-capped container for /mnt paths (its page cache no longer evicts the apps) — measured on 9202; guarded against a path the daemon sees differently; unreleased
gates / gates (push) Successful in 1m40s
gates / gates (push) Successful in 1m40s
This commit is contained in:
@@ -5,12 +5,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
@@ -770,6 +772,16 @@ func (m *Manager) tier2NoTargetReason(err error) string {
|
||||
// --- helpers ---
|
||||
|
||||
// rsyncMirror mirrors src→dst with rsync -a --delete (exact copy, browsable on disk, no versioning).
|
||||
//
|
||||
// R-1214 (night soak 2026-10-21; measured on 9202 2026-10-22, felhom.eu `audits/night-2026-10-22/P4-R1214/`): run
|
||||
// in the controller's own process, the copy's page cache is charged to the GUEST's memory and evicts the apps' working
|
||||
// memory — on a 4 GB guest with 3.5 GB in use a 6 GB copy drove memory „full" pressure to 6.9–16.8 % (10-s average,
|
||||
// 2.5–5.7 s of full stall); the same copy inside a container capped at 512 MB: 0.9–1.0 %, 0.3–0.4 s. So the copy runs
|
||||
// in a capped container of the controller's own image (it carries rsync) — ONLY when both paths are under /mnt/,
|
||||
// where the controller and the Docker daemon see the same paths. Any other path (the controller's own data volume)
|
||||
// differs for the daemon: a capped `--delete` there would mirror an EMPTY folder over the copy. Those paths, a box
|
||||
// whose image cannot be read, and a container that cannot START (docker's own exit 125) take the plain rsync, as
|
||||
// before. Pinned by TestR1214_*.
|
||||
func rsyncMirror(src, dst string) error {
|
||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", dst, err)
|
||||
@@ -777,7 +789,23 @@ func rsyncMirror(src, dst string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
||||
defer cancel()
|
||||
// Trailing slashes: copy the CONTENTS of src into dst.
|
||||
cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", strings.TrimRight(src, "/")+"/", strings.TrimRight(dst, "/")+"/")
|
||||
s, d := strings.TrimRight(src, "/"), strings.TrimRight(dst, "/")
|
||||
rs := []string{"-a", "--delete", s + "/", d + "/"}
|
||||
if tier2CappedPath(s) && tier2CappedPath(d) {
|
||||
if img := tier2SelfImage(); img != "" {
|
||||
args := append([]string{"run", "--rm", "--memory", tier2CopyMemCap, "--memory-swap", tier2CopyMemCap,
|
||||
"--network", "none", "-v", s + ":" + s + ":ro", "-v", d + ":" + d, "--entrypoint", "rsync", img}, rs...)
|
||||
out, code, err := tier2RunCapped(ctx, args)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if code != 125 {
|
||||
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
tier2Logf("[WARN] [tier2] the capped copy container did not start (%s) — copying in-process, as before (R-1214)", strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "rsync", rs...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
|
||||
@@ -785,6 +813,39 @@ func rsyncMirror(src, dst string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tier2CopyMemCap bounds the capped copy's memory: rsync itself needs a few MB per 100 000 files; the rest is the page
|
||||
// cache the copy creates, reclaimed inside the cap instead of from the apps.
|
||||
const tier2CopyMemCap = "512m"
|
||||
|
||||
// tier2CappedPath: a path the controller and the Docker daemon see identically (the guest's /mnt is bind-mounted into
|
||||
// the controller at /mnt). Never a relative path, never `..`.
|
||||
func tier2CappedPath(p string) bool {
|
||||
return strings.HasPrefix(p, tier2CappedRoot) && filepath.Clean(p) == p
|
||||
}
|
||||
|
||||
// tier2CappedRoot is "/mnt/" in production (a test seam: tests point it at a temp dir).
|
||||
var tier2CappedRoot = "/mnt/"
|
||||
|
||||
// Seams (tests never reach Docker — tests that do act on the host they run on).
|
||||
var (
|
||||
tier2SelfImage = func() string {
|
||||
out, err := dockerexec.Command("docker", "inspect", "felhom-controller", "--format", "{{.Config.Image}}").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
tier2RunCapped = func(ctx context.Context, args []string) ([]byte, int, error) {
|
||||
out, err := dockerexec.CommandContext(ctx, "docker", args...).CombinedOutput()
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return out, ee.ExitCode(), err
|
||||
}
|
||||
return out, 0, err
|
||||
}
|
||||
tier2Logf = log.Printf
|
||||
)
|
||||
|
||||
// tier2UnitPreservedWarning is the customer-facing half of the R-403 skip. It rides in
|
||||
// CrossDriveBackup.LastWarning, which the per-app card already renders, so the refusal reaches the
|
||||
// SURFACE and not only the log — the shape recordTier2NoTarget established.
|
||||
|
||||
Reference in New Issue
Block a user