DEV Community

Cover image for Docker Compose + tmpfs: Armazenamento efêmero que sua aplicação
Rafael Pazini
Rafael Pazini

Posted on

Docker Compose + tmpfs: Armazenamento efêmero que sua aplicação

Você já parou para pensar onde sua aplicação Go escreve arquivos temporários? /tmp, cache de build, sessões, uploads intermediários... tudo isso vai parar em disco. E disco é lento. Não "lento para 2024" — lento mesmo, comparado com o que podemos fazer.

Existe uma alternativa que a maioria dos devs ignora: tmpfs. Um filesystem que mora na RAM, some quando o container reinicia, e é absurdamente rápido. É tipo aquele colega que resolve o problema na hora, mas não te manda e-mail depois — eficiente e efêmero.

Vamos ver o que muda na prática e quando faz sentido usar.

Nota: Os exemplos deste artigo usam Go 1.26, lançado em fevereiro de 2026. Uma das novidades dessa versão é o Green Tea GC habilitado por padrão — o que torna a combinação com tmpfs ainda mais poderosa: menos alocações em disco + GC mais eficiente = serviços Go mais rápidos e previsíveis.

O que é tmpfs, afinal?

Tmpfs é um filesystem temporário que reside inteiramente na memória RAM:

  • Ultra-rápido — velocidade de RAM, não de disco
  • 🔒 Seguro — dados não persistem entre restarts
  • 🧹 Auto-limpante — some sozinho quando o container para

Parece mágica, mas é só o SO sendo inteligente. Linux já tem suporte nativo há décadas; o Docker Compose apenas expõe isso de forma limpa.

Configuração básica

A forma mais simples de usar:

services:
  app:
    image: myapp-go
    tmpfs:
      - /tmp
      - /app/cache
      - /var/run
Enter fullscreen mode Exit fullscreen mode

Com limite de tamanho (o que você definitivamente deveria fazer em produção):

services:
  app:
    image: myapp-go
    tmpfs:
      - /tmp:size=100M
      - /app/cache:size=500M
      - /var/run:size=10M
Enter fullscreen mode Exit fullscreen mode

Sem o limite de tamanho, seu tmpfs pode consumir toda a RAM disponível. Isso é o tipo de surpresa que você não quer às 2h da manhã com o PagerDuty tocando.

Configuração avançada

Para quem quer controle fino sobre permissões:

services:
  app:
    image: myapp-go
    tmpfs:
      - type: tmpfs
        target: /tmp
        tmpfs:
          size: 100M
          mode: 1770    # permissões do diretório
          uid: 1000     # usuário dono
          gid: 1000     # grupo dono
Enter fullscreen mode Exit fullscreen mode

Ou usando a sintaxe de volumes (mais verbosa, mas mais explícita):

services:
  app:
    image: myapp-go
    volumes:
      - type: tmpfs
        target: /app/temp
        tmpfs:
          size: 200000000  # 200MB em bytes
Enter fullscreen mode Exit fullscreen mode

Casos de uso com Go: onde isso realmente brilha

1. Cache de build Go

Quem já esperou o go build rodar dentro de um container sabe a dor. O cache de compilação do Go pode ser colocado em tmpfs para acelerar builds em CI:

services:
  builder:
    image: golang:1.26
    working_dir: /app
    volumes:
      - .:/app
      - type: tmpfs
        target: /root/.cache/go-build
        tmpfs:
          size: 2G
    command: go build ./...
Enter fullscreen mode Exit fullscreen mode

Aviso: o cache some quando o container para. Para CI eficiente, combine isso com um volume persistente para o cache entre pipelines. Para builds únicos dentro de um job, tmpfs é perfeito.

2. Uploads temporários em APIs Go

Sua API Go recebe arquivos, processa e manda para S3? Não tem sentido escrever em disco se você vai deletar logo depois:

services:
  api:
    image: myapi-go
    tmpfs:
      - /tmp/uploads:size=2G,mode=1770
    environment:
      UPLOAD_DIR: /tmp/uploads
      MAX_UPLOAD_SIZE: 100M
Enter fullscreen mode Exit fullscreen mode

No código Go, basta ler a variável de ambiente:

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
    "path/filepath"
)

var uploadDir = getEnv("UPLOAD_DIR", "/tmp/uploads")

func getEnv(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }

    r.Body = http.MaxBytesReader(w, r.Body, 100<<20) // 100MB max
    if err := r.ParseMultipartForm(32 << 20); err != nil {
        http.Error(w, "file too large", http.StatusBadRequest)
        return
    }

    file, header, err := r.FormFile("file")
    if err != nil {
        http.Error(w, "invalid file", http.StatusBadRequest)
        return
    }
    defer file.Close()

    // Escreve no tmpfs — rápido e sem deixar rastro
    tmpPath := filepath.Join(uploadDir, header.Filename)
    dst, err := os.Create(tmpPath)
    if err != nil {
        http.Error(w, "failed to save file", http.StatusInternalServerError)
        return
    }
    defer func() {
        dst.Close()
        os.Remove(tmpPath) // limpa após processar
    }()

    if _, err = io.Copy(dst, file); err != nil {
        http.Error(w, "failed to write file", http.StatusInternalServerError)
        return
    }

    // Aqui você processaria e mandaria pro S3, por exemplo
    fmt.Fprintf(w, "arquivo %s processado com sucesso", header.Filename)
}
Enter fullscreen mode Exit fullscreen mode

3. Banco de dados para testes

Esse é o meu favorito. Rodar o banco de testes com dados em tmpfs é 3-5x mais rápido que em disco:

services:
  # Banco de testes — rápido, dados não persistem (e não precisam)
  test-db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: test
      POSTGRES_DB: testdb
    tmpfs:
      - /var/lib/postgresql/data:size=1G
    profiles: ["test"]

  # Banco de produção — persistente, como deve ser
  prod-db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    profiles: ["prod"]

volumes:
  postgres_data:
Enter fullscreen mode Exit fullscreen mode

Em Go, seus testes de integração com Testcontainers ou docker compose ficam muito mais ágeis. Se você ainda não usa Testcontainers em Go, dá uma olhada no artigo que escrevi sobre isso — a combinação com tmpfs é poderosa.

4. Segurança: filesystem read-only com exceções tmpfs

Esse padrão é subestimado. Você torna o filesystem inteiro read-only e usa tmpfs apenas para os diretórios que precisam de escrita:

services:
  secure-api:
    image: myapi-go
    read_only: true          # filesystem inteiro somente leitura
    tmpfs:
      - /tmp:size=100M       # escrita permitida aqui
      - /var/run:size=10M
      - /app/cache:size=50M
    volumes:
      - app_logs:/var/log:rw # logs persistentes
Enter fullscreen mode Exit fullscreen mode

Para aplicações Go, isso é excelente. O binário compilado é estático — não precisa escrever em lugar nenhum além dos diretórios que você define explicitamente. É defense-in-depth sem custo nenhum.

Benchmark: vendo a diferença

Quer medir o ganho? Coloca isso no seu compose:

services:
  benchmark:
    image: golang:1.26-alpine
    command: |
      sh -c "
        echo '=== Tmpfs Write Performance ==='
        time dd if=/dev/zero of=/tmp/test bs=1M count=500 conv=fsync

        echo ''
        echo '=== Volume Write Performance ==='
        time dd if=/dev/zero of=/data/test bs=1M count=500 conv=fsync
      "
    tmpfs:
      - /tmp:size=1G
    volumes:
      - benchmark_data:/data

volumes:
  benchmark_data:
Enter fullscreen mode Exit fullscreen mode

Em máquinas típicas de desenvolvimento, tmpfs é 3 a 10x mais rápido que um volume em disco. Em produção com SSDs NVMe, a diferença diminui, mas ainda é significativa para workloads de I/O intenso.

Sizing dinâmico por ambiente

Uma dica que uso nos meus projetos: tamanho de tmpfs configurável por ambiente:

services:
  app:
    image: myapi-go
    tmpfs:
      - /tmp:size=${TMPFS_SIZE:-100M}
      - /app/cache:size=${CACHE_SIZE:-200M}
    deploy:
      resources:
        limits:
          memory: 2G
        reservations:
          memory: 1G
Enter fullscreen mode Exit fullscreen mode

E nos arquivos de ambiente:

# .env.development
TMPFS_SIZE=500M
CACHE_SIZE=1G

# .env.production
TMPFS_SIZE=2G
CACHE_SIZE=4G

# .env.test
TMPFS_SIZE=256M
CACHE_SIZE=512M
Enter fullscreen mode Exit fullscreen mode

Isso é especialmente útil quando você tem a mesma stack rodando em máquinas com capacidades de RAM diferentes — CI com 8GB, produção com 32GB, máquina local com 16GB.

Monitorando o uso

Não vai querer descobrir que o tmpfs estourou em produção. Monitore:

# Verificar uso de tmpfs em um container específico
docker compose exec app df -h | grep tmpfs

# Monitorar todos os containers
docker compose ps -q | xargs -I {} docker exec {} df -h 2>/dev/null | grep tmpfs

# Ver impacto na memória do sistema
docker stats --no-stream
Enter fullscreen mode Exit fullscreen mode

No Go, você pode expor uma métrica de uso de /tmp diretamente no seu endpoint de health check:

package health

import (
    "encoding/json"
    "net/http"
    "syscall"
)

type DiskStats struct {
    Total     uint64 `json:"total_bytes"`
    Free      uint64 `json:"free_bytes"`
    Used      uint64 `json:"used_bytes"`
    UsedPct   float64 `json:"used_pct"`
}

func TmpfsStats() (*DiskStats, error) {
    var stat syscall.Statfs_t
    if err := syscall.Statfs("/tmp", &stat); err != nil {
        return nil, err
    }

    total := stat.Blocks * uint64(stat.Bsize)
    free := stat.Bfree * uint64(stat.Bsize)
    used := total - free

    return &DiskStats{
        Total:   total,
        Free:    free,
        Used:    used,
        UsedPct: float64(used) / float64(total) * 100,
    }, nil
}

func HealthHandler(w http.ResponseWriter, r *http.Request) {
    stats, err := TmpfsStats()
    if err != nil {
        http.Error(w, "failed to check tmpfs", http.StatusInternalServerError)
        return
    }

    status := "ok"
    if stats.UsedPct > 85 {
        status = "warning"
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]any{
        "status": status,
        "tmpfs":  stats,
    })
}
Enter fullscreen mode Exit fullscreen mode

Exemplos do mundo real

Teoria é legal, mas vamos ver como tmpfs se encaixa em cenários que você provavelmente já viveu — ou vai viver.

LocalStack + tmpfs: desenvolvimento AWS sem custo e sem lentidão

Se você já configurou o LocalStack para emular S3, DynamoDB, SQS localmente (tem um artigo completo sobre isso aqui), sabe que o DynamoDB e o SQS escrevem dados temporários em disco durante os testes. Em ambiente de dev local, esses dados não precisam persistir entre restarts. É desperdício puro.

A combinação tmpfs + LocalStack elimina esse overhead e faz o ambiente local rodar muito mais próximo da velocidade real:

services:
  localstack:
    image: localstack/localstack:latest
    container_name: localstack
    ports:
      - "4566:4566"
    environment:
      - SERVICES=dynamodb,sqs,s3
      - DEBUG=0
    # Dados do LocalStack não precisam sobreviver ao restart em dev
    tmpfs:
      - /var/lib/localstack:size=1G
      - /tmp/localstack:size=512M

  # Sua API Go apontando para o LocalStack
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      AWS_ACCESS_KEY_ID: test
      AWS_SECRET_ACCESS_KEY: test
      AWS_ENDPOINT: http://localstack:4566
    # Uploads intermediários antes de mandar para o S3 "local"
    tmpfs:
      - /tmp/uploads:size=512M,mode=1770
    depends_on:
      - localstack
Enter fullscreen mode Exit fullscreen mode

No código Go, a lógica do worker que consome a fila SQS e processa arquivos fica mais enxuta quando você sabe que o diretório temporário é RAM:

package worker

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "os"
    "path/filepath"

    "github.com/aws/aws-sdk-go-v2/service/sqs"
)

type UploadMessage struct {
    UserID   string `json:"user_id"`
    Filename string `json:"filename"`
    Size     int64  `json:"size"`
}

func ProcessUploadQueue(ctx context.Context, sqsClient *sqs.Client, queueURL, uploadDir string) {
    for {
        out, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
            QueueUrl:            &queueURL,
            MaxNumberOfMessages: 10,
            WaitTimeSeconds:     5,
        })
        if err != nil {
            log.Printf("receive error: %v", err)
            continue
        }

        for _, msg := range out.Messages {
            var payload UploadMessage
            if err := json.Unmarshal([]byte(*msg.Body), &payload); err != nil {
                log.Printf("unmarshal error: %v", err)
                continue
            }

            // Arquivo temporário no tmpfs — processamento em RAM
            tmpPath := filepath.Join(uploadDir, fmt.Sprintf("%s_%s", payload.UserID, payload.Filename))

            // Processa (redimensiona, valida, converte)
            if err := processFile(ctx, tmpPath, payload); err != nil {
                log.Printf("process error: %v", err)
                continue
            }

            // Limpa — no tmpfs isso é instantâneo
            os.Remove(tmpPath)

            // Delete da fila só após processar com sucesso
            sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
                QueueUrl:      &queueURL,
                ReceiptHandle: msg.ReceiptHandle,
            })
        }
    }
}

func processFile(ctx context.Context, path string, msg UploadMessage) error {
    // Aqui: validar, converter, enviar pro S3 real ou LocalStack
    log.Printf("processing %s for user %s (%d bytes)", msg.Filename, msg.UserID, msg.Size)
    return nil
}
Enter fullscreen mode Exit fullscreen mode

O ganho é duplo: o LocalStack inicia mais rápido (sem I/O de disco para inicializar os dados), e os arquivos temporários do seu worker nunca tocam o disco da máquina.


Imagens scratch + tmpfs: a combinação mais segura que você pode fazer

No artigo sobre imagens Docker para Go, mostrei que usar scratch como imagem final chega a ~21MB — menos que alpine (~30MB) e muito menos que a imagem com SDK (~251MB). O que não mencionei lá é que scratch e read_only: true são feitos um para o outro, e tmpfs é o elo que faz tudo funcionar.

O problema de usar scratch com read_only: true é que a aplicação não consegue escrever em lugar nenhum — nem em /tmp. Sem tmpfs, você precisaria ou relaxar o read_only ou montar volumes, o que aumenta a superfície de ataque. Com tmpfs, você tem o melhor dos dois mundos:

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile  # multi-stage com scratch
    read_only: true            # filesystem inteiro imutável
    tmpfs:
      - /tmp:size=256M         # única área gravável — e ainda assim efêmera
      - /var/run:size=10M
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
Enter fullscreen mode Exit fullscreen mode

E o Dockerfile que casa com isso (multi-stage com scratch, como discutido no artigo de imagens):

# syntax=docker/dockerfile:1

# Build: compila com Go 1.26
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 garante binário estático — requisito para scratch
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -o /app/server ./cmd/server

# Final: scratch + tmpfs = imagem mínima + filesystem imutável
FROM scratch
COPY --from=builder /app/server /server
# /tmp será montado como tmpfs pelo docker compose — não precisa existir na imagem
EXPOSE 8080
ENTRYPOINT ["/server"]
Enter fullscreen mode Exit fullscreen mode

A combinação scratch + read_only: true + tmpfs resulta em:

  • Imagem final de ~21MB (só o binário Go)
  • Zero pacotes extras para explorar
  • Filesystem completamente imutável em produção
  • Única área gravável (/tmp) existe na RAM e some com o container

É o padrão mais hardened que você consegue com Go e Docker hoje, sem abrir mão de nenhuma funcionalidade.


MCP server + tmpfs: cache de respostas de API sem persistência

Se você já brincou com MCP servers em Docker, sabe que eles ficam intermediando chamadas entre o Claude (ou qualquer LLM) e APIs externas. Essas chamadas muitas vezes retornam payloads grandes de JSON — respostas de APIs, documentações de Swagger, listas de recursos — que são processados uma vez e descartados.

Salvar isso em disco é desperdício. Salvar no tmpfs faz sentido:

services:
  mcp-gateway:
    image: rflpazini/mcp-api-gateway
    environment:
      API_1_NAME: internal-api
      API_1_SWAGGER_URL: https://api.exemplo.com/swagger.json
      API_1_BASE_URL: https://api.exemplo.com/v1
      # Diz pro gateway onde cachear as respostas temporárias
      CACHE_DIR: /tmp/mcp-cache
      RESPONSE_CACHE_TTL: 30s
    tmpfs:
      # Cache de respostas — não precisa sobreviver a restart
      - /tmp/mcp-cache:size=200M,mode=1770
      # Schemas Swagger baixados para parsing
      - /tmp/schemas:size=50M

  # Seu servidor Go que o MCP chama
  internal-api:
    build: .
    read_only: true
    tmpfs:
      - /tmp:size=128M
    environment:
      PORT: "8080"
Enter fullscreen mode Exit fullscreen mode

Se você quiser ir além e implementar um cache de respostas no próprio servidor Go que o MCP gateway chama, fica assim:

package cache

import (
    "crypto/sha256"
    "encoding/json"
    "fmt"
    "os"
    "path/filepath"
    "time"
)

type Entry struct {
    Data      any       `json:"data"`
    ExpiresAt time.Time `json:"expires_at"`
}

// FileCache é um cache simples em tmpfs.
// Como é tmpfs, não há custo de I/O — é literalmente memória.
type FileCache struct {
    dir string
    ttl time.Duration
}

func New(dir string, ttl time.Duration) (*FileCache, error) {
    if err := os.MkdirAll(dir, 0o750); err != nil {
        return nil, fmt.Errorf("cache dir: %w", err)
    }
    return &FileCache{dir: dir, ttl: ttl}, nil
}

func (c *FileCache) key(resource string) string {
    h := sha256.Sum256([]byte(resource))
    return filepath.Join(c.dir, fmt.Sprintf("%x.json", h))
}

func (c *FileCache) Get(resource string, dest any) bool {
    path := c.key(resource)
    data, err := os.ReadFile(path)
    if err != nil {
        return false
    }

    var entry Entry
    if err := json.Unmarshal(data, &entry); err != nil {
        return false
    }

    if time.Now().After(entry.ExpiresAt) {
        os.Remove(path) // limpa expirado
        return false
    }

    b, _ := json.Marshal(entry.Data)
    return json.Unmarshal(b, dest) == nil
}

func (c *FileCache) Set(resource string, data any) error {
    entry := Entry{
        Data:      data,
        ExpiresAt: time.Now().Add(c.ttl),
    }

    b, err := json.Marshal(entry)
    if err != nil {
        return err
    }

    // WriteFile em tmpfs = write em RAM. Sem syscall de disco.
    return os.WriteFile(c.key(resource), b, 0o600)
}
Enter fullscreen mode Exit fullscreen mode

A beleza desse padrão é que você tem um cache com TTL que:

  • Não precisa de Redis para casos simples
  • Não polui o disco com dados temporários
  • Morre junto com o container — sem estado residual
  • É tão rápido quanto acessar memória

Para um MCP gateway que precisa cachear respostas de API por 30 segundos antes de refazer a chamada, isso é mais do que suficiente.


Quando NÃO usar tmpfs

Vamos ser honestos: tmpfs não é bala de prata.

Não use quando:

  • Os dados precisam sobreviver a um restart (sessões de usuário em produção, por exemplo)
  • Você tem pouca RAM e workloads com uso intenso de memória concorrente
  • Os arquivos temporários são gigantes (acima de 20-30% da RAM disponível)
  • Você precisa de persistência entre deploys no mesmo host

Use sem medo quando:

  • Dados temporários de processamento (uploads, conversões, transformações)
  • Cache de compilação em CI
  • Bancos de dados de teste
  • Sessões de curta duração que não precisam sobreviver a restarts
  • Qualquer coisa que você jogaria fora de qualquer jeito

O impacto real no dia a dia

Combinado com boas práticas que já falei em outros artigos — como imagens Docker otimizadas para Go e tuning de performance — tmpfs fecha um gap importante: I/O de disco como gargalo.

Para pipelines de CI/CD, a diferença é imediata. Para APIs Go que processam arquivos, é a diferença entre latência aceitável e latência excelente. Para testes de integração, é o que separa um suite de 5 minutos de um de 15 minutos.

Vale a pena?

Sim. E é uma das mudanças mais fáceis de implementar com impacto real.

Não exige mudança de código. Não exige nova dependência. É um bloco de 3 linhas no seu compose.yml que pode te poupar memória de disco, acelerar I/O e ainda melhorar a postura de segurança do serviço.

Se você já usa Docker Compose no dia a dia (e se escreve Go em 2026, provavelmente usa), isso é a otimização de menor esforço com maior retorno que você pode fazer hoje.

Experimenta, mede com docker stats, e me conta o que você encontrou.

Top comments (0)