Stockage : Loki signale un /data ou /models servi par le FUSE d'Unraid

Sur Unraid, /mnt/user/… n'est pas un disque mais shfs, un démon FUSE :
chaque fsync de loki.db et chaque page d'un gros MoE mmappé relue depuis
le disque (modèle plus gros que la RAM) le traverse, à chaque token. Rien
n'est perdu ni altéré, mais le décodage ralentit — sans que rien ne le
dise.

Loki ne déplace rien : changer le chemin hôte d'une installation donnerait
un /data vide. Il le voit et le dit, sur un ton d'information :
- sys_storagefuse.go lit /proc/self/mountinfo (type lu après le séparateur
  « - », montage le plus long qui contient le chemin) pour LOKI_HOME et
  chaque dossier de modèles ; Linux en conteneur seulement, mis en cache
  une minute puisque /api/status est interrogé en boucle ;
- conseil en jaune au démarrage et champ « hint » de /api/status, affiché
  en encart discret, distinct du bandeau rouge de perte de données ;
- compose Unraid et README : « Exclusive access » recommandé (même chemin,
  FUSE court-circuité), /mnt/<pool> en option avancée avec migration
  manuelle et mise en garde sur un pool inexistant (RAM).

La détection de thrash du cache de pages est laissée de côté : elle exige
un signal majflt calibré par modèle sur le serveur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 02:31:59 +02:00
1 parent 667e340e57
commit 056f307983
9 files changed
+333

No files matched your search

+17
View File
@@ -167,6 +167,23 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
emplacements différents dès que le partage n'est pas en cache-only ou que le
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
**Performance sur Unraid.** `/mnt/user/…` passe par `shfs`, la couche FUSE des
partages : chaque écriture de `loki.db` et chaque page d'un gros modèle relue
depuis le disque (MoE plus gros que la RAM, mmappé) traverse un démon en espace
utilisateur, ce qui ralentit le décodage. Loki le détecte au démarrage
(`fuse.shfs` dans `/proc/self/mountinfo`) et l'affiche en encart d'information —
rien n'est perdu ni altéré. Pour l'éviter :
- **recommandé** — passe le partage `appdata` (et celui des modèles) en
*Exclusive access* (Unraid 6.12+, partage sur un seul pool) : le chemin
`/mnt/user/…` ne change pas, FUSE est court-circuité ;
- **avancé** — mappe `/mnt/<ton-pool>/appdata/loki/…` (ex. `/mnt/cache` si ton
pool s'appelle `cache`), avec une migration manuelle : `Compose Down`,
`rsync -a` de l'ancien dossier vers le nouveau, puis modification des deux
lignes `volumes`. Un `/mnt/<pool>` inexistant atterrit dans la RAM d'Unraid
(données perdues au redémarrage, serveur saturé par un modèle de 80 Go), et
changer le chemin sans migrer donne un `/data` vide.
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
+15
View File
@@ -40,6 +40,21 @@ services:
# rebinding). Inutile pour un accès par IP ou nom local.
- LOKI_TRUSTED_HOSTS=
volumes:
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
# modèle relue depuis le disque (modèle plus gros que la RAM) traverse un
# démon en espace utilisateur. Loki le signale au démarrage (encart
# d'information, rien n'est perdu). Deux remèdes, au choix :
# - Recommandé : Shares -> appdata (et le partage des modèles) ->
# « Exclusive access » = Yes (Unraid 6.12+, partage sur un seul
# pool). Le chemin /mnt/user reste le même, FUSE est court-circuité.
# - Avancé : mapper /mnt/<ton-pool>/appdata/loki/… (ex. /mnt/cache si
# ton pool s'appelle « cache »). Migration MANUELLE obligatoire :
# Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis
# changer les deux lignes ci-dessous. Un /mnt/<pool> qui n'existe
# pas atterrit dans la RAM d'Unraid : données perdues au redémarrage,
# et un modèle de 80 Go peut saturer la mémoire du serveur.
# Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide.
# /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
- /mnt/user/appdata/loki/data:/data
# /models : dossier de .gguf déposés à la main (gros fichiers)
+177
View File
@@ -0,0 +1,177 @@
package loki
// sys_storagefuse.go — conseil de PERFORMANCE pour Unraid : /data et /models
// servis par la couche FUSE des partages (fuse.shfs).
//
// Sur Unraid, /mnt/user/… n'est pas un disque mais un démon en espace
// utilisateur (shfs) qui agrège array et pools. Tout y passe : chaque fsync de
// loki.db, et surtout chaque page du .gguf que le noyau doit relire quand un
// gros MoE mmappé ne tient pas en RAM — pendant le décodage, à chaque token.
// Rien de perdu, rien d'altéré (les octets du modèle sont les mêmes), mais tout
// est plus lent.
//
// Loki ne déplace RIEN : changer le chemin hôte d'une installation existante
// donnerait un /data vide et ferait croire à une perte de données (voir le
// README). Il se contente de le VOIR et de le DIRE, sur un ton d'information —
// pas le bandeau rouge de sys_datavolume.go, réservé à la perte de données. Le
// remède sans changement de chemin : passer les partages en « Exclusive
// access » (Unraid 6.12+), qui court-circuite shfs.
import (
"bufio"
"io"
"os"
"path"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// mountEntry : ce qu'on retient d'une ligne de /proc/self/mountinfo.
type mountEntry struct {
point string // point de montage (5ᵉ champ), échappements décodés
fsType string // type de système de fichiers (1ᵉʳ champ après « - »)
}
// parseMountinfo lit le format de /proc/<pid>/mountinfo. Le nombre de champs
// optionnels (shared:N, master:N…) varie d'une ligne à l'autre : le type de
// système de fichiers se lit donc APRÈS le séparateur « - », jamais à un index
// fixe. Les lignes malformées sont ignorées.
func parseMountinfo(r io.Reader) []mountEntry {
var out []mountEntry
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 5 {
continue
}
sep := -1
for i := 5; i < len(fields); i++ {
if fields[i] == "-" {
sep = i
break
}
}
if sep < 0 || sep+1 >= len(fields) {
continue
}
out = append(out, mountEntry{
point: path.Clean(unescapeMountinfo(fields[4])),
fsType: fields[sep+1],
})
}
return out
}
// unescapeMountinfo décode les séquences octales du noyau (\040 espace,
// \011 tabulation, \012 saut de ligne, \134 antislash).
func unescapeMountinfo(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+3 < len(s) {
if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
b.WriteByte(byte(v))
i += 3
continue
}
}
b.WriteByte(s[i])
}
return b.String()
}
// mountFSType renvoie le type de système de fichiers qui porte path : celui du
// point de montage le plus LONG qui le contient (/data/models peut être un
// montage imbriqué distinct de /data). À longueur égale, la dernière ligne
// gagne — c'est le montage empilé par-dessus, celui qu'on voit vraiment.
// "" quand aucun montage ne couvre le chemin. Chemins Linux : package path, et
// non filepath, pour que les tests lisent la même chose sous Windows.
func mountFSType(mounts []mountEntry, target string) string {
p := path.Clean(target)
best, fs := -1, ""
for _, m := range mounts {
mp := m.point
if mp != "/" && p != mp && !strings.HasPrefix(p, mp+"/") {
continue
}
if len(mp) >= best {
best, fs = len(mp), m.fsType
}
}
return fs
}
// shfsPaths renvoie, parmi paths, ceux qui passent par fuse.shfs (dédoublonnés,
// dans l'ordre reçu).
func shfsPaths(mounts []mountEntry, paths []string) []string {
var out []string
seen := map[string]bool{}
for _, p := range paths {
if strings.TrimSpace(p) == "" {
continue
}
p = path.Clean(p)
if seen[p] {
continue
}
seen[p] = true
if mountFSType(mounts, p) == "fuse.shfs" {
out = append(out, p)
}
}
return out
}
// storageHintText formule le conseil pour les chemins concernés ("" si aucun).
// Conseil uniquement : les fichiers sont intacts, seule la vitesse en pâtit.
func storageHintText(paths []string) string {
if len(paths) == 0 {
return ""
}
return "ℹ️ Stockage via la couche FUSE des partages Unraid (fuse.shfs) : " + strings.Join(paths, ", ") + ". " +
"Les écritures de la base et les pages du modèle relues depuis le disque (gros modèle qui ne tient pas en RAM) " +
"traversent un démon en espace utilisateur, ce qui ralentit le décodage. Rien n'est perdu. " +
"Remède sans changer de chemin : partages appdata/modèles en « Exclusive access » (Unraid 6.12+). " +
"Voir docker-compose.unraid.yml."
}
// storageHint mis en cache : /api/status est interrogé en boucle, et la liste
// des dossiers de modèles passe par la base. Les montages ne bougent pas pendant
// la vie du conteneur ; une minute suffit à refléter un dossier ajouté dans l'UI.
var storageHintCache struct {
sync.Mutex
at time.Time
val string
}
// storageFuseHint renvoie le conseil « /data ou /models sur fuse.shfs » ("" si
// sans objet). Linux en conteneur seulement, comme dataVolumeWarning : hors
// conteneur, l'utilisateur gère ses chemins.
func storageFuseHint() string {
if runtime.GOOS != "linux" || os.Getenv("LOKI_CONTAINER") == "" {
return ""
}
c := &storageHintCache
c.Lock()
defer c.Unlock()
if !c.at.IsZero() && time.Since(c.at) < time.Minute {
return c.val
}
c.at = time.Now()
c.val = ""
f, err := os.Open("/proc/self/mountinfo")
if err != nil {
return "" // illisible : on ne conseille rien plutôt que de deviner
}
defer f.Close()
mounts := parseMountinfo(f)
paths := append([]string{LokiHome()}, modelDirs()...)
c.val = storageHintText(shfsPaths(mounts, paths))
return c.val
}
+100
View File
@@ -0,0 +1,100 @@
package loki
import (
"runtime"
"strings"
"testing"
)
// Extrait réaliste d'un conteneur Loki sur Unraid : racine overlay, /data et
// /models en bind depuis /mnt/user (fuse.shfs), un /data/models imbriqué sur
// un pool btrfs, champs optionnels de longueur variable, un chemin à espace.
const mountinfoUnraid = `612 553 0:156 / / rw,relatime master:221 - overlay overlay rw,lowerdir=/var/lib/docker/l
613 612 0:159 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw
620 612 0:52 /appdata/loki/data /data rw,noatime - fuse.shfs shfs rw,user_id=0,group_id=0,allow_other
621 612 0:52 /appdata/loki/models /models rw,noatime shared:5 master:7 - fuse.shfs shfs rw,user_id=0
622 620 0:61 /appdata/loki/fast /data/models rw,noatime - btrfs /dev/nvme0n1p1 rw,ssd
623 612 0:52 /media /mnt/mes\040modeles rw,noatime - fuse.shfs shfs rw
garbage line
624 612 8:1 / /broken rw
`
func TestParseMountinfo(t *testing.T) {
m := parseMountinfo(strings.NewReader(mountinfoUnraid))
if len(m) != 6 {
t.Fatalf("attendu 6 montages valides, obtenu %d : %+v", len(m), m)
}
// Champs optionnels (shared:5 master:7) : le type se lit après « - ».
if m[3].point != "/models" || m[3].fsType != "fuse.shfs" {
t.Fatalf("ligne à champs optionnels mal lue : %+v", m[3])
}
if m[5].point != "/mnt/mes modeles" {
t.Fatalf("échappement \\040 non décodé : %q", m[5].point)
}
}
func TestMountFSType(t *testing.T) {
m := parseMountinfo(strings.NewReader(mountinfoUnraid))
cases := []struct {
path, want string
}{
{"/data", "fuse.shfs"},
{"/data/loki.db", "fuse.shfs"},
{"/data/models", "btrfs"}, // montage imbriqué : le plus long gagne
{"/data/models/x.gguf", "btrfs"},
{"/data/modelsX", "fuse.shfs"}, // préfixe de chaîne ≠ préfixe de chemin
{"/models", "fuse.shfs"},
{"/mnt/mes modeles/a.gguf", "fuse.shfs"},
{"/opt/loki", "overlay"},
}
for _, c := range cases {
if got := mountFSType(m, c.path); got != c.want {
t.Errorf("mountFSType(%q) = %q, attendu %q", c.path, got, c.want)
}
}
if got := mountFSType(nil, "/data"); got != "" {
t.Errorf("sans montage connu : %q, attendu \"\"", got)
}
}
func TestShfsPaths(t *testing.T) {
m := parseMountinfo(strings.NewReader(mountinfoUnraid))
got := shfsPaths(m, []string{"/data", "/data/models", "/models", "/models/", "/opt"})
want := []string{"/data", "/models"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("shfsPaths = %v, attendu %v", got, want)
}
// Partages en « Exclusive access » : plus de fuse.shfs, plus de conseil.
excl := `1 0 0:1 / / rw - overlay overlay rw
2 1 0:2 /appdata/loki/data /data rw - btrfs /dev/nvme0n1p1 rw
3 1 0:3 /loki/models /models rw - xfs /dev/md1p1 rw
`
if p := shfsPaths(parseMountinfo(strings.NewReader(excl)), []string{"/data", "/models"}); len(p) != 0 {
t.Fatalf("aucun chemin ne devrait être signalé, obtenu %v", p)
}
}
func TestStorageHintText(t *testing.T) {
if s := storageHintText(nil); s != "" {
t.Fatalf("rien à signaler : %q", s)
}
s := storageHintText([]string{"/data", "/models"})
for _, w := range []string{"/data, /models", "fuse.shfs", "Exclusive access"} {
if !strings.Contains(s, w) {
t.Errorf("conseil sans %q : %s", w, s)
}
}
}
func TestStorageFuseHintOutsideContainer(t *testing.T) {
t.Setenv("LOKI_CONTAINER", "")
if s := storageFuseHint(); s != "" {
t.Fatalf("hors conteneur, aucun conseil attendu : %q", s)
}
if runtime.GOOS != "linux" {
t.Setenv("LOKI_CONTAINER", "1")
if s := storageFuseHint(); s != "" {
t.Fatalf("hors Linux, aucun conseil attendu : %q", s)
}
}
}
+8
View File
@@ -2301,6 +2301,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
<div id="app-hint" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;border:1px solid var(--border)"></div>
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
OU par son propre chevron — c'est une section comme les autres, sinon on ne
@@ -4042,6 +4043,13 @@ async function loadStatus(){
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
else { wb.style.display='none'; }
}
// Conseil de performance (stockage Unraid via fuse.shfs) : encart discret,
// pas le bandeau d'alerte — rien n'est perdu, seul le décodage ralentit.
const hb=document.getElementById('app-hint');
if(hb){
if(s.hint){ hb.textContent=s.hint; hb.style.display=''; }
else { hb.style.display='none'; }
}
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
const me=document.getElementById('model-err');
+1
View File
@@ -75,6 +75,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
<div id="app-hint" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;border:1px solid var(--border)"></div>
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
OU par son propre chevron — c'est une section comme les autres, sinon on ne
+7
View File
@@ -50,6 +50,13 @@ async function loadStatus(){
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
else { wb.style.display='none'; }
}
// Conseil de performance (stockage Unraid via fuse.shfs) : encart discret,
// pas le bandeau d'alerte — rien n'est perdu, seul le décodage ralentit.
const hb=document.getElementById('app-hint');
if(hb){
if(s.hint){ hb.textContent=s.hint; hb.style.display=''; }
else { hb.style.display='none'; }
}
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
const me=document.getElementById('model-err');
+3
View File
@@ -83,6 +83,9 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
// id du preset actif : un autre appareil a pu basculer, l'UI se
// resynchronise sans reload (voir loadStatus). AJEAN 0.13.6.
"preset": activePresetID(),
// Conseil de performance (stockage sur fuse.shfs d'Unraid) : encart
// discret, distinct de « warn » qui annonce une perte de données.
"hint": storageFuseHint(),
})
}
+5
View File
@@ -52,6 +52,11 @@ func cmdWeb(args []string) error {
if dv := dataVolumeWarning(); dv != "" {
fmt.Println(red(dv))
}
// Simple conseil de performance (fuse.shfs d'Unraid) : jaune, pas rouge —
// rien n'est perdu, seul le décodage d'un gros modèle hors RAM ralentit.
if sh := storageFuseHint(); sh != "" {
fmt.Println(yellow(sh))
}
if readWebKey() == "" {
fmt.Printf("%s API de pilotage NON protégée (aucune clé). Avant de l'exposer sur internet :\n", yellow("[!]"))
fmt.Printf(" %s\n", bold("loki set-web-key"))