warn when a host's kernel has cgroup memory accounting disabled

This commit is contained in:
Jarek Krochmalski committed 2026-09-12 16:24:13 +02:00
1 parent 5954e22297
commit 5512357a74
6 files changed
+121 -6

No files matched your search

+5 -1
View File
@@ -12505,6 +12505,9 @@
},
"name": {
"type": "string"
},
"memoryLimitSupported": {
"type": "boolean"
}
}
},
@@ -12523,7 +12526,8 @@
"serverVersion": "string",
"containers": 0,
"images": 0,
"name": "string"
"name": "string",
"memoryLimitSupported": true
},
"hawser": {}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Whether the daemon's kernel supports per-container memory accounting, read from
* Docker `/info`. When the cgroup memory controller is disabled in the kernel boot
* config (common on Raspberry Pi and some ARM boards), the daemon reports
* `MemoryLimit: false` and every container's memory usage shows as 0. Docker itself
* detects this - we surface it so the user isn't left guessing why memory is blank.
*/
export interface MemorySupportInfo {
/** true = the UI can show a warning that memory accounting is off. */
warn: boolean;
/** Docker's own `/info.MemoryLimit`. */
memoryLimitSupported: boolean;
/** Docker's own `/info.SwapLimit`. */
swapLimitSupported: boolean;
}
/**
* Derive the memory-support flags from a Docker `/info` response. `MemoryLimit`/
* `SwapLimit` are booleans; a missing/undefined field is treated as SUPPORTED (no
* warning) so an older daemon or a partial info payload never produces a false alarm.
*/
export function memorySupportFromInfo(info: { MemoryLimit?: boolean; SwapLimit?: boolean } | null | undefined): MemorySupportInfo {
const memoryLimitSupported = info?.MemoryLimit !== false;
const swapLimitSupported = info?.SwapLimit !== false;
return { warn: !memoryLimitSupported, memoryLimitSupported, swapLimitSupported };
}
/** Link to the manual section explaining how to enable cgroup memory accounting. */
export const MEMORY_SUPPORT_DOC_URL = 'https://dockhand.pro/manual/#troubleshooting-rpi-memory';
+7 -2
View File
@@ -4,6 +4,7 @@ import type { DockerClientConfig } from '$lib/server/docker';
import { getEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { isSafeNotificationUrl } from '$lib/server/url-safety';
import { memorySupportFromInfo } from '$lib/utils/memory-support';
import type { RequestHandler } from './$types';
interface TestConnectionRequest {
@@ -58,7 +59,7 @@ function buildDockerClientConfig(config: TestConnectionRequest): DockerClientCon
* description: Pass environmentId to fall back to that saved environment's stored secrets (hawserToken, tlsKey) for any secret field left blank in the body, so Test connection works when editing without re-entering the token.
* body: {connectionType:string!, socketPath:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, hawserToken:string, environmentId:integer}
* body-example: {"connectionType":"socket","socketPath":"/var/run/docker.sock"}
* resp-200: {success:boolean!, info:{serverVersion:string, containers:integer, images:integer, name:string}, hawser:{}}
* resp-200: {success:boolean!, info:{serverVersion:string, containers:integer, images:integer, name:string, memoryLimitSupported:boolean}, hawser:{}}
* resp-200-desc: success:false with a human-readable error message is also returned as HTTP 200 (connection failures are not transport errors)
* resp-400: Host is required for direct/hawser-standard connection types
* resp-403: The caller cannot access the environment named by environmentId
@@ -177,13 +178,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
}
}
const memSupport = memorySupportFromInfo(info);
return json({
success: true,
info: {
serverVersion: info.ServerVersion,
containers: info.Containers,
images: info.Images,
name: info.Name
name: info.Name,
// The kernel's cgroup memory controller: false => per-container memory
// shows 0 (common on Raspberry Pi). The modal warns on this at create time.
memoryLimitSupported: memSupport.memoryLimitSupported
},
hawser: hawserInfo
});
@@ -86,6 +86,7 @@
import { licenseStore } from '$lib/stores/license';
import { formatDateTime, formatDate } from '$lib/stores/settings';
import { getLabelColor, getLabelBgColor, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { MEMORY_SUPPORT_DOC_URL } from '$lib/utils/memory-support';
import { labelColorOverrides } from '$lib/stores/label-colors';
import EventTypesEditor from './EventTypesEditor.svelte';
import UpdatesTab from './tabs/UpdatesTab.svelte';
@@ -494,6 +495,9 @@
// Test connection state
let testingConnection = $state(false);
let testResult = $state<{ success: boolean; info?: any; error?: string; isEdgeMode?: boolean } | null>(null);
// Shown when a successful Test connection reveals the daemon's kernel has cgroup
// memory accounting disabled (per-container memory shows 0 - common on Raspberry Pi).
let memWarnOpen = $state(false);
// Socket detection state
let detectingSockets = $state(false);
@@ -758,6 +762,9 @@
toast.info('Edge mode - connection will be tested when agent connects');
} else {
toast.success(`Connected! Docker ${result.info.serverVersion} - ${result.info.containers} containers`);
// Docker reports the kernel's cgroup memory controller is off -> container
// memory will show 0. Surface it now so the user isn't left guessing.
if (result.info.memoryLimitSupported === false) memWarnOpen = true;
}
} else {
toast.error(result.error || 'Connection failed');
@@ -2005,10 +2012,11 @@
A direct daemon shares no filesystem with Dockhand. When set, Dockhand copies each
stack's folder to
<code class="bg-muted px-1 rounded">&lt;this path&gt;/&lt;stack&gt;</code>
<span class="font-medium text-foreground">on the remote host</span> so the backup
<span class="font-medium text-foreground">on the remote host</span>
<span class="font-medium text-foreground">on each deploy</span> so the backup
helper can read the compose and config, and rewrites relative binds
(<code class="bg-muted px-1 rounded">./data</code>) to that host path so they resolve
on the remote daemon.
on the remote daemon. After setting this, redeploy a stack once so its files are staged there.
</p>
<p class="text-muted-foreground">
Leave empty to skip this: the stack won't be backupable and a relative bind resolves
@@ -2034,6 +2042,8 @@
Absolute path on the remote host where Dockhand keeps this stack's files, so its compose
and config are backupable and relative binds resolve on the remote daemon. Leave empty to
use only absolute paths or named volumes.
<span class="text-foreground font-medium">Takes effect on the next deploy</span> - after
setting this, redeploy each stack so Dockhand stages its files there.
{/if}
</p>
</div>
@@ -3146,6 +3156,36 @@
</Dialog.Content>
</Dialog.Root>
<!-- Kernel cgroup memory accounting is disabled on this daemon (container memory shows 0). -->
<Dialog.Root bind:open={memWarnOpen}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle class="w-5 h-5" />
Container memory won't be shown
</Dialog.Title>
</Dialog.Header>
<div class="text-sm text-muted-foreground space-y-3">
<p>
This host's kernel has cgroup memory accounting disabled, so Docker can't report
per-container memory - it will show <span class="font-medium text-foreground whitespace-nowrap">0&nbsp;B</span>
for every container. This is common on Raspberry Pi and some ARM boards.
</p>
<p>
It's fixed in the kernel boot config, not in Dockhand. See the guide:
<a href={MEMORY_SUPPORT_DOC_URL} target="_blank" rel="noopener noreferrer"
class="text-primary hover:underline inline-flex items-center gap-1">
enabling container memory accounting
<ExternalLink class="w-3 h-3" />
</a>
</p>
</div>
<Dialog.Footer>
<Button onclick={() => (memWarnOpen = false)}>OK</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Env-rename confirmation: only fires for socket/direct envs where the
server actually moves $DATA_DIR/stacks/<oldName> → <newName>. -->
<Dialog.Root bind:open={showRenameConfirm}>