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
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
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
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
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 ./...
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
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)
}
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:
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
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:
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
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
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
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,
})
}
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
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
}
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
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"]
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"
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)
}
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)