Docker su macOS: gli aggiustamenti nascosti che ti fanno ricadere nel "it works on my machine"

Docker su macOS non è Docker. O meglio, non è lo stesso Docker che gira sul tuo server di produzione. È Docker Linux che vive dentro una piccola macchina virtuale gestita da Docker Desktop, accessibile attraverso una serie di ponti, proxy e layer di compatibilità che l'utente finale quasi non vede. Proprio questa invisibilità è il problema: rende l'esperienza di sviluppo fluida, ma al prezzo di mascherare comportamenti che su un host Linux puro emergono senza pietà. Il risultato è il classico "sul mio Mac funziona" quando il container raggiunge la produzione.

In questo articolo analizziamo i principali aggiustamenti che Docker Desktop applica solo su macOS, il motivo tecnico per cui esistono, e le contromisure pratiche da adottare in un flusso di lavoro professionale.

Filesystem case-insensitive: il killer silenzioso

APFS, il filesystem di default di macOS, è case-insensitive ma case-preserving. Significa che i file conservano l'ortografia originale, ma il filesystem considera equivalenti nomi come Utils.ts e utils.ts. Linux, al contrario, è rigorosamente case-sensitive. Una volta che il progetto viene buildato dentro un'immagine basata su Debian, Alpine o Ubuntu, ogni discrepanza di maiuscole si trasforma in un modulo non trovato.

// Su macOS entrambe le importazioni risolvono allo stesso file
// In produzione Linux una delle due lancia MODULE_NOT_FOUND
import { createUser } from './services/userService';
import { deleteUser } from './services/UserService';

Lo stesso vale per i template di Laravel, gli include PHP, i path di asset serviti da Vite, i package Go e i moduli Python. La contromisura più solida è creare un volume APFS case-sensitive dedicato al codice sorgente tramite Utility Disco, oppure integrare in CI un controllo che fallisca sui riferimenti con case sbagliato.

Bind mount attraverso VirtioFS

Su Linux un bind mount è un'operazione del kernel a costo zero: la directory dell'host appare dentro il container con la stessa semantica POSIX. Su macOS il codice sorgente attraversa un layer di condivisione file, oggi VirtioFS e in passato gRPC-FUSE, che traduce le operazioni tra il Mac e la VM Linux. Questo layer introduce tre effetti collaterali importanti.

Il primo è la latenza di I/O, sensibilmente più alta rispetto a un bind mount nativo. Il secondo è la propagazione degli eventi di filesystem: inotify su Linux e fsnotify in Go non sempre ricevono notifiche affidabili quando un file viene modificato fuori dal container. Per questo motivo strumenti come Vite, nodemon e air talvolta non rilevano i cambi e richiedono il fallback al polling.

# Workaround tipico quando il file watcher non reagisce
CHOKIDAR_USEPOLLING=true npm run dev

Il terzo effetto riguarda permessi e ownership. Su macOS i file montati appaiono spesso con l'utente del container senza attriti, mentre su Linux gli UID e i GID contano davvero: è comune trovarsi in produzione con file scritti dal container come root che poi il processo applicativo non riesce a rimuovere.

host.docker.internal funziona "magicamente"

Su Docker Desktop per macOS l'hostname host.docker.internal punta sempre alla macchina host. Su Linux, storicamente, non esisteva affatto e ancora oggi funziona solo se il container viene avviato con un flag esplicito.

services:
  api:
    image: my-api
    extra_hosts:
      # Necessario su Linux, superfluo su macOS
      - "host.docker.internal:host-gateway"

Se il codice applicativo o un file compose fa affidamento su quell'hostname senza dichiararlo esplicitamente, la risoluzione fallirà in produzione. La buona pratica è passare l'indirizzo del servizio esterno tramite variabile d'ambiente e lasciare che sia l'orchestratore a definirlo.

Networking: niente network_mode host reale

Su Linux network_mode: host condivide letteralmente lo stack di rete del kernel tra host e container, eliminando il NAT. Su macOS questo è impossibile per costruzione, perché l'host è il Mac mentre il container gira dentro la VM Linux. Docker Desktop accetta la direttiva ma il comportamento non è equivalente. Analogamente, localhost dall'interno di un container ha significati diversi nei due mondi: sul Mac serve host.docker.internal per raggiungere un servizio dell'host, su Linux con host networking basta localhost.

Port publishing permissivo

Docker Desktop pubblica le porte attraverso un proxy in user space. Questo nasconde una serie di vincoli che su un server Linux reale tornano a mordere: conflitti con servizi di sistema già in ascolto, capability mancanti per porte privilegiate sotto la 1024, regole iptables o nftables restrittive. Un container che su Mac espone tranquillamente la porta 80 può richiedere CAP_NET_BIND_SERVICE o un reverse proxy su un host Linux.

Risorse elastiche e OOM silenziosi

La VM di Docker Desktop ha un tetto di RAM e CPU configurabile dall'interfaccia. Dentro quel tetto i container vedono risorse abbondanti e ben bilanciate, e i limiti dei cgroup raramente scattano. Su un host Linux di produzione la situazione è opposta: i cgroup v2 applicano i vincoli in modo stringente, la memoria è condivisa con altri processi e l'OOM killer è rapido. Un servizio Node che sul Mac girava a 800 MB di heap senza problemi può essere terminato silenziosamente in produzione.

services:
  worker:
    image: my-worker
    deploy:
      resources:
        limits:
          # Allineare allo stesso tetto della produzione
          memory: 512M
          cpus: '0.5'

Imporre gli stessi limiti anche in sviluppo fa emergere i problemi mentre si ha ancora il debugger sotto mano.

Orologio e timer

Quando il Mac entra in sleep e si risveglia, la VM di Docker Desktop subisce salti temporali. Questo influisce su timer monotonici, cron interni, TTL Redis molto stretti e scadenze JWT corte. Un server Linux in datacenter non dorme mai, quindi comportamenti che sul laptop sembrano stabili possono essere artefatti del ciclo sleep/wake del portatile.

Architettura CPU: arm64 contro amd64

Su Apple Silicon la piattaforma di default è linux/arm64. La maggior parte dei registry di produzione serve ancora linux/amd64. Docker Desktop maschera il problema con l'emulazione QEMU quando si pulla un'immagine amd64, ma l'emulazione è lenta e, cosa peggiore, non è sempre corretta per codice che contiene binari nativi: better-sqlite3, sharp, estensioni PHP compilate, wheel Python con parti in C. Succede che l'immagine buildata sul Mac funzioni localmente per via dell'emulazione e fallisca sul server, o viceversa.

# Build esplicita per il target di produzione
docker buildx build \
  --platform linux/amd64 \
  --tag registry.example.com/my-app:latest \
  --push .

La regola è semplice: il --platform va dichiarato esplicitamente e idealmente impostato anche nel compose di sviluppo quando si vuole riprodurre il target reale.

Risoluzione DNS ereditata dal Mac

Il resolver usato dai container su Docker Desktop eredita la configurazione DNS del Mac, inclusi i file in /etc/resolver, i suffissi mDNS .local, le eventuali VPN aziendali e le regole di split-DNS. Su un host Linux di produzione nulla di tutto questo è presente: il resolv.conf è diverso, mDNS è assente, e i domini interni dell'azienda potrebbero non essere raggiungibili. Hostname che risolvono sul laptop possono non risolvere affatto in produzione.

Signal handling e PID 1

Questo punto non è esclusivo di macOS, ma si nota soprattutto lì perché Docker Desktop tende a fermare i container in modo morbido. Un processo Node, Python o PHP che non gestisce esplicitamente SIGTERM in sviluppo sembra funzionare, ma in produzione su Kubernetes o systemd viene terminato brutalmente dopo il grace period, perdendo connessioni in volo e lavori in coda. L'uso di un init minimale come tini o dumb-init come PID 1 è una buona pratica indipendente dalla piattaforma.

FROM node:22-alpine

# Init leggero per inoltrare correttamente i segnali
RUN apk add --no-cache tini

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

Strategia di mitigazione complessiva

Nessuna delle contromisure citate fino a qui, presa da sola, elimina il rischio. La protezione reale arriva dall'adottarle come sistema. In pratica significa costruire le immagini di produzione su runner Linux nativi in CI, mai sul Mac; forzare la piattaforma target con buildx; allineare i limiti di memoria e CPU tra sviluppo e produzione; evitare di introdurre nel codice applicativo nomi come host.docker.internal, preferendo variabili d'ambiente; trattare il file watcher in polling come un sintomo da indagare, non una soluzione definitiva; e soprattutto eseguire almeno uno smoke test finale dell'immagine su un host Linux reale prima di promuoverla.

Docker Desktop su macOS rimane un ottimo ambiente di sviluppo proprio perché smussa gli spigoli. Il problema è che quegli stessi spigoli sono i punti esatti in cui il Linux di produzione presenta il conto. Considerare "verde sul Mac" come sinonimo di "pronto al deploy" è la singola abitudine che genera più incidenti evitabili in questo stack. L'artefatto che va in produzione deve essere costruito e verificato sulla piattaforma su cui girerà, senza scorciatoie.