Atualizando do Ruby 3.4.4 para o Ruby 4.0.3: guia passo a passo para apps Rails
TL;DR
O Ruby 4.0 quebra menos do que o número da versão sugere. O trabalho real é adicionar as gems que saíram do conjunto default (cgi, ostruct, logger), migrar Ractor para Ractor::Port, trocar open com pipe por IO.popen e corrigir asserções de Set e backtrace. Em troca, alocação mais rápida, GC mais enxuto e ERB corrigido. YJIT ligado, ZJIT desligado.
O Ruby 4.0 é o primeiro salto de versão major em mais de uma década, mas a maior parte do trabalho está por baixo do capô. Veja o que realmente quebra ao migrar um app Rails do 3.4.4 para o 4.0.3, o que você ganha e um plano de upgrade em fases que cabe em uma semana.
Por que migrar agora
O Ruby 4.0.0 foi lançado em 25 de dezembro de 2025. Desde então, a linha 4.0.x vem recebendo patch releases num ritmo de aproximadamente dois meses, e o 4.0.3 (21 de abril de 2026) é um bom ponto de chegada: ele contém uma única mudança, o ERB 6.0.1.1, que fecha a CVE-2026-41316. Essa CVE afeta qualquer app que chame Marshal.load sobre dados não confiáveis enquanto erb e activesupport estão carregados, o que descreve praticamente todo app Rails com cache store. Se você ainda está no 3.4.4, também está atrasado nos patches de segurança da própria linha 3.4, então o upgrade resolve dois problemas de uma vez.
As versões 4.0.x posteriores (4.0.4 em diante) são substitutas diretas do 4.0.3. Tudo o que está abaixo se aplica a elas sem alteração; fixe a versão que estiver atual quando você ler isto.
A boa notícia: o 4.0 é uma release de "reestruturação". Há pouquíssimas mudanças na linguagem, e as breaking changes se concentram em três áreas: gems que saíram do conjunto default, a API do Ractor e um punhado de comportamentos legados removidos. Um app Rails 8.x típico faz o upgrade em um dia de trabalho focado mais um período de observação.
O que quebra
1. Default gems promovidas a bundled gems
Essa é a que pega quase todo mundo. No 4.0, estas gems não carregam mais sem uma entrada no Gemfile:
ostruct, logger, benchmark, pstore, irb, reline, readline, rdoc, fiddle, win32ole
Além disso, a cgi foi removida completamente das default gems. Só o cgi/escape permanece (CGI.escape, CGI.escapeHTML e companhia). Qualquer coisa que precise de CGI::Cookie, CGI.parse ou dos helpers de sessão agora precisa da gem cgi.
O modo de falha é traiçoeiro: funciona localmente porque um Ruby mais antigo deixou a gem instalada, e depois falha num build limpo do Docker. O issue tracker do Rails teve uma thread no início de 2026 dizendo que o Rails 8.1.1 "não estava pronto para o Ruby 4"; a causa real era sprockets-rails e sitemap_generator dependendo de cgi, e gem "cgi" no Gemfile resolveu.
Adicione o que você realmente usa:
# Gemfile
gem "cgi" # if sprockets, sitemap_generator, or any gem still requires it
gem "ostruct" # if you OpenStruct anywhere, including specs
gem "logger" # usually pulled in by activesupport, but pin it explicitly
gem "benchmark" # if you require it in scripts or rake tasks
Descubra quais você precisa antes de trocar o Ruby:
grep -rn "require ['\"]\(ostruct\|logger\|benchmark\|pstore\|cgi\|irb\|fiddle\)" app lib config spec test
RUBYOPT="-W:deprecated" bundle exec rspec 2>&1 | grep -i "bundled gem"
O Ruby 3.4 já emite warnings para essas gems, então o segundo comando entrega a lista completa ainda no 3.4.4.
2. Reescrita da API do Ractor
Ractor.yield, Ractor#take, Ractor#close_incoming e Ractor#close_outgoing foram removidos, substituídos por Ractor::Port. Se você não tem código com Ractor, pule esta parte. Se tem:
# Ruby 3.4
r = Ractor.new { Ractor.yield compute }
result = r.take
# Ruby 4.0
r = Ractor.new { compute }
result = r.value # like Thread#value
# Explicit channels
port = Ractor::Port.new
r = Ractor.new(port) { |p| p << compute }
result = port.receive
Ractor#join, #value, #monitor e Ractor.shareable_proc são novos. Ractor.select agora aceita apenas Ractors e Ports.
3. Comportamentos removidos
Kernel#open("| cmd")e os métodos de classe de IO não iniciam mais um processo quando a string começa com pipe. UseIO.popen. Faça grep poropen("|eopen('|.--rjitfoi removido. Se está no seuRUBYOPTou num Dockerfile, troque por--yjit.Process::Status#&e#>>foram removidos (deprecados desde o 3.3).SortedSetfoi removido deset. Adicione a gemsorted_set.*nilnão chama maisnil.to_a.[*nil]continua resultando em[], mas um monkey patch customizado denil.to_a(raro, mas existe em codebases antigas) deixa de ser chamado.
4. Mudanças de comportamento que quebram testes, não código
Setagora é uma classe core.Set.new([1,2]).inspectretornaSet[1, 2]em vez de#<Set: {1, 2}>. Snapshot tests e qualquer coisa que faça parse da saída deinspectvão falhar.Net::HTTPnão define maisContent-Type: application/x-www-form-urlencodedautomaticamente em requisições com body. Alguns servidores rejeitam a requisição. Defina o header explicitamente nos seus clientes HTTP ou confira as versões de Faraday/HTTParty, que cuidam disso para você.- Backtraces de "wrong number of arguments" agora incluem a classe do receiver (
Foo#barem vez debar), e os frames<internal:*>sumiram. Qualquer regra de agrupamento de erros no Sentry, Rollbar ou Datadog que case com o texto antigo do frame precisa ser atualizada. - Uma linha começando com
&&,||,andouoragora continua a linha anterior, do mesmo jeito que um encadeamento.methodno início da linha. Código válido continua válido; o parser só passa a aceitar mais. Binding#local_variable_get(:_1)levantaNameError. Parâmetros numerados eitforam movidos paraBinding#implicit_parameters. Só debuggers e gems pesadas em metaprogramação se importam.Proc#parametersreporta parâmetros opcionais anônimos como[:opt]em vez de[:opt, nil].
5. Extensões nativas
O openssl 4.0.0 vem como default gem, e a API C removeu rb_path_check e transformou rb_thread_fd_close em no-op. nokogiri, pg, mysql2, bcrypt e ffi já têm releases compatíveis com o 4.0, mas uma cópia compilada antiga num bundle em cache vai dar segfault ou falhar ao carregar. Sempre rode bundle pristine depois da troca e limpe os caches de bundle do CI.
O que você ganha
Nada aqui exige mudança de código; você ganha só por fazer o deploy.
Alocação e GC. Class#new ficou mais rápido em geral, com os maiores ganhos em inicializadores com keyword arguments, ou seja, todo ActiveModel e service object que você tem. Os size pools do GC agora crescem de forma independente, então um heap cheio de strings pequenas não arrasta mais as páginas de objetos grandes junto, e a varredura dessas páginas ficou mais rápida. A tabela id2ref é construída sob demanda no primeiro uso, o que acelera tanto object_id quanto a varredura em apps que nunca o chamam.
Variáveis de instância em objetos não triviais. Strings, Arrays e objetos TypedData com ivars (pense em Pathname, ActiveSupport::SafeBuffer, qualquer coisa que use instance_variable_set numa String) agora as armazenam num objeto interno de campos, com acesso mais rápido.
Internals do Ractor. Tabelas de frozen strings e símbolos lock-free, lookups no cache de métodos quase todos lock-free, menos contenção na alocação. Você se beneficia mesmo sem usar Ractor, porque o lock global estava em hot paths.
Promoções a classe core. Set e Pathname agora são classes core, então não há custo de autoload no primeiro uso e existe uma API C (rb_set_add, rb_set_lookup, etc.) para extensões.
Stdlib. json 2.18, psych 5.3.1, prism 1.7, openssl 4.0, net-http 0.9.1, minitest 6.0. Unicode 17.0 para String e Regexp. Socket.tcp e TCPSocket.new aceitam open_timeout: e levantam IO::TimeoutError de forma consistente. Math.log1p e Math.expm1. Array#rfind.
YJIT. RubyVM::YJIT.enable agora aceita mem_size: e call_threshold:, então você ajusta a partir de um initializer em vez de variáveis de ambiente.
ZJIT e Ruby::Box: ainda não. O ZJIT é o sucessor do YJIT baseado em métodos, habilitado com --zjit. No 4.0 ele supera o interpretador, mas é mais lento que o YJIT, e o core team diz com todas as letras para não rodar em produção até o 4.1. Mantenha --yjit. O Ruby::Box (RUBY_BOX=1) isola monkey patches e definições de classe por box, pensado para execuções isoladas de testes e para comparar atualizações de dependências lado a lado. Experimental; interessante de testar, não algo para construir em cima.
Upgrade passo a passo
Fase 1: preparar no 3.4.4 (ainda sem trocar o Ruby)
- Suíte de testes verde no 3.4.4. Se não estiver verde, você não vai saber quais falhas são novas.
- Rode a suíte com warnings de deprecação como erros e corrija cada warning:
RUBYOPT="-W:deprecated" bundle exec rspec
- Adicione as bundled gems ao Gemfile (seção 1 acima). Faça commit e deploy disso no 3.4.4; lá é um no-op.
- Faça grep por
open("|,Ractor.yield,.take,SortedSet,--rjit,Process::Status. Corrija cada ocorrência. - Audite asserções baseadas em
inspectsobre Sets e regras customizadas de agrupamento de erros no seu APM. - Confira
bundle outdatedem busca de gems comrequired_ruby_version < 4. Atualize ou substitua agora:
bundle exec ruby -e 'Bundler.load.specs.each { |s| puts "#{s.name} #{s.required_ruby_version}" if s.required_ruby_version.to_s =~ /< ?4/ }'
Fase 2: trocar localmente e no CI
- Suba a versão:
echo "4.0.3" > .ruby-version
rbenv install 4.0.3 # or: mise install ruby@4.0.3 / asdf install ruby 4.0.3
- Atualize o Gemfile:
ruby file: ".ruby-version"
- Reconstrua o bundle do zero:
rm -rf vendor/bundle # or wherever your path is
bundle install
bundle pristine
- Rode a suíte. Espere um conjunto pequeno de falhas: saída de inspect do Set, content types do Net::HTTP, specs que casam com backtrace, uma ou outra gem que ainda exige
cgi. - Atualize o CI. No GitHub Actions, o
ruby/setup-rubylê o.ruby-version; basta invalidar a chave de cache do bundle. Não adicione toolchain do Rust a menos que queira ZJIT; as imagens oficiais do Ruby já vêm com YJIT. - Dockerfile:
ARG RUBY_VERSION=4.0.3
FROM ruby:${RUBY_VERSION}-slim AS base
ENV RUBY_YJIT_ENABLE=1
Se você compila o Ruby por conta própria, o 4.0 precisa de um compilador C11 e, apenas para o ZJIT, de Rust 1.85+. O YJIT só precisa de Rust em tempo de build, e as imagens oficiais cuidam disso.
Fase 3: staging e produção
- Faça deploy em staging com YJIT ligado e rode sua suíte de smoke de sempre mais um teste de carga contra a baseline do 3.4.4. Capture p50/p95 e RSS por worker do Puma, não só throughput.
- Confirme que seu error tracker agrupa o novo formato de backtrace corretamente. Dispare um
ArgumentErrorde propósito. - Verifique integrações HTTP de saída que enviam form bodies via
Net::HTTPpuro. - Refaça a baseline dos alertas de memória. As mudanças no GC normalmente reduzem o RSS; se o seu subir, procure uma gem com alocador próprio.
- Rollout em produção. Com Kamal, isso é um build com tag e um
kamal deploy; mantenha a imagem do 3.4.4 por perto parakamal rollback. Acompanhe taxa de erro e p95 por 48 horas antes de apagar a imagem antiga.
Notas específicas do Rails
- As patch releases atuais do Rails 8.1.x e 8.0.x rodam no Ruby 4.0. As falhas reportadas contra o Rails 8.1 por aí eram todas gems dependendo de defaults removidas, não o Rails em si. O Rails 7.2 exige Ruby 3.1+ e funciona no 4.0 para a maioria dos apps, mas espere mais churn de gems. Rails 7.1 e anteriores estão fora de suporte; atualize o Rails primeiro.
ActiveSupport::OrderedOptionseconfig.xnão usamOpenStruct, mas muitos apps usam em specs e seeds. Ou adicione a gem ou substitua porStruct.new(..., keyword_init: true)/Data.define.- Cache stores que usam
Marshalsão exatamente o motivo de a correção do ERB no 4.0.3 importar. Atualizar o Ruby corrige; atualizar só a gemerbtambém funciona se você estiver preso no 4.0.2. Setser core significa querequire "set"é no-op; deixe no lugar para as gems que ainda chamam.
Plano de rollback
Mantenha simples: a tag de imagem anterior, o Gemfile.lock anterior e um revert de uma linha no .ruby-version. Nada no 4.0 muda formatos em disco (entradas de cache, dumps de Marshal, cookies de sessão) de um jeito que impeça o 3.4.4 de lê-los, então rollback é um redeploy, não uma migração.
Resumo
O salto do 3.4 para o 4.0 é menor do que o número da versão sugere. O checklist de uma vez só: adicione as bundled gems, troque as chamadas de Ractor por Ports, substitua os opens com pipe por IO.popen, corrija as asserções de Set e de backtrace, bundle pristine, mantenha o YJIT ligado e o ZJIT desligado, observe por dois dias. Em troca você ganha alocação mais rápida, um GC mais enxuto, stdlib corrigida e um runtime pronto para Ractors e ZJIT quando o 4.1 chegar.
Fontes
- Ruby 4.0.0 lançado (ruby-lang.org) www.ruby-lang.org
- Ruby 4.0.3 lançado (ruby-lang.org) www.ruby-lang.org
- Ruby 3.4.4 lançado (ruby-lang.org) www.ruby-lang.org
- Lendo o NEWS do Ruby 4.0 com os pros (Koichi Sasada) dev.to
- Guia de adoção do Ruby 4.0 (RubyLearning) rubylearning.com
- rails/rails #56457: Rails 8.1.1 e Ruby 4 github.com
- Upgrading Ruby on Rails (Rails Guides) guides.rubyonrails.org
Perguntas frequentes
Preciso cair exatamente no Ruby 4.0.3 ou posso usar um 4.0.x mais novo?
Use o 4.0.x que estiver atual. O 4.0.3 é a primeira release que inclui a correção do ERB para a CVE-2026-41316, por isso é o mínimo que vale mirar, mas 4.0.4 e posteriores são substitutos diretos e tudo no guia se aplica a eles sem alteração.
Por que meu app funciona no Ruby 4.0 localmente mas falha num build limpo do Docker?
Uma gem que saiu do conjunto default (quase sempre `cgi`, `ostruct` ou `logger`) ainda está instalada na sua máquina por um Ruby mais antigo, então o `require` funciona localmente. Uma imagem nova não tem nenhuma delas. Adicione ao Gemfile as gems que você realmente usa e o build passa a ser determinístico.
O Rails 8.1 está pronto para o Ruby 4.0?
Sim. As patch releases atuais do Rails 8.1.x e 8.0.x rodam no Ruby 4.0. As falhas reportadas no início de 2026 foram causadas por gems como `sprockets-rails` e `sitemap_generator` dependendo da default gem `cgi` removida, não pelo Rails em si.
Devo habilitar o ZJIT em produção no Ruby 4.0?
Não. No 4.0, o ZJIT supera o interpretador, mas ainda é mais lento que o YJIT, e o core team recomenda esperar o 4.1 antes de rodar em produção. Mantenha `--yjit` (ou `RUBY_YJIT_ENABLE=1`) ligado.
Não tenho código com Ractor. A reescrita do Ractor::Port me afeta?
Não diretamente. Você pode pular a migração da API por completo. Ainda assim se beneficia do trabalho interno por trás dela, já que tabelas de símbolos e frozen strings lock-free e um cache de métodos quase todo lock-free reduzem a contenção em hot paths mesmo em apps com um único Ractor.
Qual é o jeito mais seguro de fazer rollback se algo der errado em produção?
Refaça o deploy da imagem anterior. O Ruby 4.0 não muda formatos em disco como entradas de cache, dumps de Marshal ou cookies de sessão de um jeito que o 3.4.4 não consiga ler, então o rollback é a tag de imagem anterior, o `Gemfile.lock` anterior e um revert do `.ruby-version`. Com Kamal, isso é `kamal rollback`.
Comentários
Faça login com GitHub para comentar.
Ainda não há comentários.