La lliçó anterior va deixar totes les decisions preses i cap executada. Aquesta és la primera vegada que CicloUrbana surt d'una màquina de desenvolupament i respon en una adreça pública d'Internet. I ho fa pel camí més curt que existeix: una plataforma com a servei, on algú s'ocupa del sistema operatiu, del servidor, del certificat TLS, de reiniciar el procés si mor i de mantenir PostgreSQL, i a nosaltres ens queda el codi i unes quantes variables.

Heroku és el PaaS que va inventar bona part del vocabulari que fa servir avui tota la indústria —el Procfile, els buildpacks, les config vars, el git push com a desplegament— i per això continua sent el millor lloc per aprendre'n els conceptes, encara que el seu nivell gratuït va desaparèixer el novembre de 2022. Tot el que veurem aquí es trasllada gairebé literalment a Railway, Render, Fly.io, Cloud Run o Azure App Service, i a l'apartat 3 hi ha la taula d'equivalències perquè puguis seguir la lliçó a la plataforma que prefereixis.

Contingut

  1. Què és un PaaS i què et dona fet
  2. Els conceptes de Heroku
  3. Heroku avui: preus i alternatives equivalents
  4. Requisits previs
  5. Preparar CicloUrbana: system.properties i Procfile
  6. Crear l'aplicació i desplegar
  7. La base de dades: Heroku Postgres i DATABASE_URL
  8. Configuració i secrets amb config vars
  9. Migracions de Flyway a la release phase
  10. Escalat, dynos i el reinici diari
  11. Logs i sistema de fitxers efímer
  12. Domini propi i TLS
  13. Alternativa: desplegar la imatge de contenidor
  14. Comprovar la salut i monitorar
  15. Costos i neteja
  16. Els límits d'un PaaS
  17. Errors Comuns i Consells
  18. Exercicis

  1. Què és un PaaS i què et dona fet

Una plataforma com a servei és un model d'allotjament en què lliures codi —o una imatge— i la plataforma s'ocupa absolutament de tot el que hi ha per sota. A la taula de models de 08-01 era la fila d'«esforç operatiu molt baix».

El que et dona fet un PaaS El que perds en control
Sistema operatiu i les seves actualitzacions de seguretat No tries la distribució ni el kernel
Instal·lació del JRE (a partir d'una declaració de versió) Ajustos fins de la JVM limitats per la memòria del pla
Construcció de l'artefacte als seus servidors Poc control sobre l'entorn de construcció
Arrencada, supervisió i reinici del procés No hi ha systemd ni accés persistent a la màquina
Balancejador HTTP i certificat TLS automàtic La terminació TLS és seva; no tries la configuració de xifrats
Encaminament a diverses instàncies Sense control fi de l'algorisme de balanceig
Base de dades gestionada com a add-on Menys paràmetres del motor ajustables
Recollida i consulta de logs Retenció curta llevat que hi hagi add-on de pagament
Mètriques bàsiques i alertes Observabilitat limitada davant d'un stack propi
Reversió a la publicació anterior en una ordre —

El tracte és explícit: cedeixes control a canvi de temps. Per a un projecte com CicloUrbana en la seva fase inicial —un desenvolupador, una demostració a l'ajuntament, zero persones dedicades a infraestructura— és un tracte excel·lent. El punt on deixa de ser-ho es tracta a l'apartat 16.

  1. Els conceptes de Heroku

Concepte Què és A CicloUrbana
App La unitat de desplegament: codi, configuració, add-ons i domini ciclourbana-ribalta
Dyno El contenidor Linux lleuger on corre un procés Un dyno web executant el JAR
Tipus de dyno Mida: Eco, Basic, Standard-1X/2X, Performance-M/L Basic per a la demostració; Standard-1X amb 512 MB per a ús real
Tipus de procés La classe de procés declarada: web, release, worker web (l'API) i release (les migracions)
Slug L'artefacte comprimit que resulta de la construcció i es copia als dynos El JAR més el JRE, uns 90 MB
Buildpack L'script que detecta el tipus de projecte i el construeix heroku/java, que detecta el pom.xml
Procfile Fitxer a l'arrel que declara quina ordre arrenca cada tipus de procés web: i release:
Config var Variable d'entorn de l'app, gestionada per la plataforma SPRING_PROFILES_ACTIVE, JWT_SECRET
Add-on Servei de suport connectable (factor 4 de 08-01) Heroku Postgres, Papertrail
Release Una combinació immutable de slug + config vars, numerada (v42) La unitat de reversió
Release phase Un procés que s'executa després de construir i abans d'activar la publicació On correran les migracions de Flyway
Pipeline Encadenament d'apps per etapa (staging → production) ciclourbana-pre → ciclourbana-ribalta
Review app App efímera creada automàticament per cada pull request Validar un canvi abans de fusionar-lo

Dos conceptes mereixen un matís. El primer: una release és slug + configuració, així que canviar una config var crea una publicació nova i reinicia els dynos. És exactament el model de 08-01: l'artefacte és un, la publicació combina artefacte i entorn.

El segon: el dyno no és una màquina virtual amb nom, és un contenidor efímer que pot reiniciar-se, moure's de màquina o duplicar-se en qualsevol moment. Tot el que vam descriure a 08-01 sobre processos llencables i sense estat s'aplica aquí de manera literal, i amb més rigor que en altres plataformes.

  1. Heroku avui: preus i alternatives equivalents

Avís important: Heroku va eliminar el seu nivell gratuït el 28 de novembre de 2022. Ja no existeixen els dynos gratuïts ni el pla hobby-dev de Postgres. Per seguir aquesta lliçó a Heroku cal una targeta i la despesa és real des de la primera hora: el pla més barat amb base de dades ronda els 10-15 dòlars al mes. Existeix el programa Heroku for GitHub Students amb crèdit, però requereix sol·licitud.

Com que els conceptes són universals, aquesta és la taula de traducció:

Plataforma Model Equivalències Nivell gratuït Notes
Railway PaaS de contenidors Servei ≈ app · Variables ≈ config vars · Plugin ≈ add-on Crèdit mensual limitat Molt proper a Heroku; detecta el pom.xml
Render PaaS Web Service ≈ app · Environment Group ≈ config vars · render.yaml ≈ Procfile + app.json Sí, amb suspensió per inactivitat Postgres gestionat; el pla gratuït caduca
Fly.io Contenidors a la vora Machine ≈ dyno · fly.toml ≈ Procfile · Secrets ≈ config vars Crèdit limitat Desplega imatges; desplegament en diverses regions
Google Cloud Run Contenidors sense servidor Service ≈ app · Revision ≈ release · Secrets des de Secret Manager Quota gratuïta mensual generosa Escala a zero; compte amb l'arrencada en fred de la JVM
Azure App Service PaaS App Service ≈ app · App Settings ≈ config vars · Deployment Slot ≈ blue-green Nivell F1 gratuït molt limitat Suporta JAR de Java 21 directament
Clever Cloud PaaS europeu Application ≈ app · Environment variables ≈ config vars No Allotjament a la UE, rellevant per a dades municipals

Els conceptes es traslladen gairebé tal qual: en totes elles cal declarar la versió de Java, escoltar al port que indiqui la plataforma per variable d'entorn, posar els secrets com a variables, connectar una base de dades gestionada per URL i escriure els logs a stdout. Si segueixes la lliçó a Railway o Render, canvia les ordres de la CLI i la resta encaixa.

  1. Requisits previs

# 1. Compte a heroku.com amb verificacio de targeta

# 2. Instal·lar la CLI (macOS amb Homebrew)
brew tap heroku/brew && brew install heroku

# 2 bis. Linux
curl https://cli-assets.heroku.com/install.sh | sh

# 3. Comprovar i autenticar-se (obre el navegador)
heroku --version
heroku login

heroku login desa un testimoni d'API a ~/.netrc. Aquest fitxer és una credencial: no el copiïs a cap repositori ni a cap imatge. Per a entorns automatitzats existeix heroku authorizations:create, que genera un testimoni revocable amb permisos acotats —la forma correcta de donar accés a una canalització (08-05)— en lloc de reutilitzar les teves credencials personals.

També necessites el repositori de CicloUrbana amb el mvnw versionat i commit net: el desplegament és literalment un git push.

  1. Preparar CicloUrbana: system.properties i Procfile

El buildpack de Java (heroku/java) detecta el projecte per la presència de pom.xml i executa ./mvnw -DskipTests clean install. Se salten les proves de manera deliberada: la construcció de la plataforma no és el lloc on s'executa la suite del mòdul 6, això passa a la canalització (08-05). Però el buildpack necessita dues coses que cal declarar.

system.properties —a l'arrel del repositori— fixa la versió de Java. Sense ell, el buildpack fa servir una versió per defecte que pot no ser la 21 i l'aplicació fallarà amb UnsupportedClassVersionError:

java.runtime.version=21
maven.version=3.9.9

Procfile —també a l'arrel, sense extensió i amb aquesta majúscula exacta— declara els tipus de procés:

web: java -Dserver.port=$PORT -XX:MaxRAMPercentage=75 -jar target/ciclourbana.jar

Cada part importa:

  • web: és un nom reservat: identifica el procés que rep trànsit HTTP extern. Qualsevol altre nom (worker, release) no rep peticions.
  • $PORT és obligatori i no negociable. Heroku assigna a cada dyno un port arbitrari en temps d'arrencada i el publica en aquesta variable; l'encaminador hi envia el trànsit. Una aplicació que escolti al 8080 fix no rep res i al cap de 60 segons Heroku la mata amb l'error R10 Boot timeout. Aquest és, amb diferència, la fallada més comuna del primer desplegament.
  • MaxRAMPercentage=75 aplica aquí el mateix que al contenidor de 07-04: un dyno Basic té 512 MB i superar-los produeix errors R14 Memory quota exceeded i una degradació brutal per swap.
  • target/ciclourbana.jar és el camí dins del slug; coincideix amb el finalName del pom.xml.

Com a alternativa a -Dserver.port, Spring Boot pren la variable SERVER_PORT per relaxed binding (02-05), així que web: java -jar target/ciclourbana.jar funciona si es defineix SERVER_PORT=$PORT. La forma explícita del Procfile és preferible perquè deixa la dependència a la vista.

I una comprovació prèvia que estalvia un viatge: l'application.yml no ha de fixar server.port: 8080 de manera que guanyi a la línia d'ordres. No ho fa —la línia d'ordres té més precedència (02-04)— però convé verificar-ho abans de culpar la plataforma.

  1. Crear l'aplicació i desplegar

# Des de l'arrel del repositori
heroku create ciclourbana-ribalta
# Creating ⬢ ciclourbana-ribalta... done
# https://ciclourbana-ribalta-1a2b3c4d5e6f.herokuapp.com/
# https://git.heroku.com/ciclourbana-ribalta.git

L'ordre fa tres coses: reserva el nom (globalment únic a tota la plataforma; si està agafat, tria'n un altre), assigna un domini herokuapp.com amb TLS ja funcionant, i afegeix un remot de Git anomenat heroku al teu repositori local. Comprova-ho amb git remote -v.

git add system.properties Procfile
git commit -m "Preparar desplegament a Heroku"
git push heroku main

Aquest git push és el desplegament. El que passa a continuació, llegit al log de construcció:

remote: -----> Building on the Heroku-24 stack
remote: -----> Determining which buildpack to use for this app
remote: -----> Java app detected
remote: -----> Installing JDK 21... done
remote: -----> Executing Maven
remote:        $ ./mvnw -DskipTests clean install
remote:        [INFO] BUILD SUCCESS
remote: -----> Discovering process types
remote:        Procfile declares types -> release, web
remote: -----> Compressing... done, 92.4M
remote: -----> Launching... done, v3
remote:        https://ciclourbana-ribalta-1a2b3c4d5e6f.herokuapp.com/ deployed to Heroku

Com llegir-ho, línia a línia:

Línia Què significa Què revisar si falla
Java app detected Va trobar el pom.xml Si no apareix, el pom.xml no és a l'arrel
Installing JDK 21 Va llegir system.properties Si instal·la una altra versió, el fitxer falta o té una errada
Executing Maven Construcció real Aquí surten els errors de compilació i de dependències
Procfile declares types Va llegir el Procfile Si diu (none), el fitxer no és a l'arrel o es diu procfile
Compressing... 92.4M Mida del slug El límit dur és 500 MB; si s'hi acosta, revisa què s'està empaquetant
Launching... v3 Número de publicació És l'identificador per revertir

Compte amb la branca. Només es desplega el que s'empeny al remot heroku. Si treballes a desenvolupament, git push heroku desenvolupament:main és la manera de desplegar aquesta branca sobre la principal de Heroku.

Encara no funciona: falta la base de dades.

  1. La base de dades: Heroku Postgres i DATABASE_URL

heroku addons:create heroku-postgresql:essential-0 --app ciclourbana-ribalta
# Creating heroku-postgresql:essential-0 on ⬢ ciclourbana-ribalta... ~$5/month
# Database has been created and is available
heroku pg:info --app ciclourbana-ribalta

L'add-on crea una instància gestionada de PostgreSQL i defineix automàticament una config var anomenada DATABASE_URL. Aquí arriba el detall que trenca el primer desplegament de tota aplicació Spring Boot a Heroku:

postgres://usuari123:[email protected]:5432/d9fk2l1m3n

Aquest format no és una URL JDBC vàlida. Segueix la convenció dels dotze factors —un únic valor amb esquema, credencials i destinació— però el driver de PostgreSQL espera jdbc:postgresql://host:port/base amb usuari i contrasenya per separat. Si l'aplicació intenta fer servir DATABASE_URL tal qual, l'arrencada falla amb Driver claims to not accept jdbcUrl.

Hi ha dues formes correctes de resoldre-ho.

Opció A (recomanada): definir les tres variables explícites. Es llegeixen els valors de l'add-on i es tradueixen a les propietats estàndard de Spring:

# Veure el valor actual
heroku config:get DATABASE_URL --app ciclourbana-ribalta

# Traduir a les propietats de Spring
heroku config:set \
  SPRING_DATASOURCE_URL="jdbc:postgresql://ec2-10-20-30-40.compute-1.amazonaws.example:5432/d9fk2l1m3n?sslmode=require" \
  SPRING_DATASOURCE_USERNAME="usuari123" \
  SPRING_DATASOURCE_PASSWORD="contrasenyaSecreta" \
  --app ciclourbana-ribalta

És explícit, es depura fàcilment i no afegeix codi. El seu inconvenient és real: Heroku rota les credencials de la base de dades en manteniments i actualitzacions, i quan ho fa canvia DATABASE_URL però no les teves variables copiades. Cal estar atent als avisos de manteniment.

Opció B: transformar DATABASE_URL en arrencar. Un EnvironmentPostProcessor converteix el valor abans que es creï el DataSource:

package com.ciclourbana.comu.config;

import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;

/**
 * Tradueix la DATABASE_URL de Heroku (postgres://usuari:clau@host:port/base)
 * a les propietats estandard de Spring. S'executa molt aviat a l'arrencada,
 * abans que es construeixi el DataSource.
 */
public class TraductorDatabaseUrl implements EnvironmentPostProcessor {

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment entorn, SpringApplication app) {
        String valor = entorn.getProperty("DATABASE_URL");
        if (valor == null || valor.startsWith("jdbc:")) {
            return;                                  // no som a Heroku, o ja ve traduida
        }
        URI uri = URI.create(valor);
        String[] credencials = uri.getUserInfo().split(":", 2);

        Map<String, Object> propietats = new HashMap<>();
        propietats.put("spring.datasource.url",
                "jdbc:postgresql://%s:%d%s?sslmode=require"
                        .formatted(uri.getHost(), uri.getPort(), uri.getPath()));
        propietats.put("spring.datasource.username", credencials[0]);
        propietats.put("spring.datasource.password", credencials[1]);

        entorn.getPropertySources()
              .addFirst(new MapPropertySource("heroku-datasource", propietats));
    }
}

I es registra a src/main/resources/META-INF/spring.factories:

org.springframework.boot.env.EnvironmentPostProcessor=\
com.ciclourbana.comu.config.TraductorDatabaseUrl

Detalls que expliquen el codi: es fa servir EnvironmentPostProcessor i no un @Bean perquè s'ha d'executar abans que l'autoconfiguració construeixi el DataSource (02-06); addFirst dona a aquestes propietats la màxima precedència; el return primerenc fa que la classe sigui innòcua fora de Heroku, de manera que el mateix artefacte serveix per a tot arreu —principi de 08-01—; i sslmode=require és obligatori, perquè Heroku Postgres només accepta connexions xifrades.

Còpies de seguretat. L'add-on fa backups automàtics, i a més es poden gestionar a mà:

heroku pg:backups:schedule DATABASE_URL --at "03:00 Europe/Madrid" --app ciclourbana-ribalta
heroku pg:backups:capture --app ciclourbana-ribalta      # copia puntual
heroku pg:backups                                        # llistar
heroku pg:backups:download b012                          # descarregar un bolcat
heroku pg:backups:restore b012 DATABASE_URL              # restaurar (DESTRUCTIU)

Recorda el principi de 08-01: una còpia que mai no s'ha restaurat no és una còpia. Prova pg:backups:restore en una altra app, no a la de producció.

Pla Cost aprox. Connexions Emmagatzematge Ús
essential-0 5 $/mes 20 1 GB Pràctica i demostració
essential-2 20 $/mes 40 32 GB Producció petita
standard-0 50 $/mes 120 64 GB Producció amb rèplica i PITR

Fixa't en la columna de connexions i recorda el càlcul de 08-01: amb essential-0 i 20 connexions, dos dynos amb el pool per defecte de 10 esgoten el límit sense deixar marge per a migracions ni administració. Si has d'escalar a dos dynos, baixa maximum-pool-size a 5.

  1. Configuració i secrets amb config vars

Aquí és on els perfils de 07-02 encaixen amb la plataforma:

heroku config:set \
  SPRING_PROFILES_ACTIVE=prod \
  JWT_SECRET="$(openssl rand -base64 48)" \
  TZ=Europe/Madrid \
  JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError" \
  --app ciclourbana-ribalta

heroku config --app ciclourbana-ribalta      # llistar
heroku config:unset VARIABLE_OBSOLETA        # eliminar

Punts importants:

  • SPRING_PROFILES_ACTIVE=prod activa l'application-prod.yml versionat (sense secrets) de 07-02: Swagger tancat, logs en INFO, ddl-auto: validate, CORS restringit.
  • openssl rand -base64 48 genera el secret HS256 al teu terminal i l'envia sense que quedi escrit en cap fitxer. No reutilitzis mai el secret de desenvolupament.
  • Cada config:set crea una publicació nova i reinicia els dynos. Agrupa els canvis en una sola ordre per no provocar diversos reinicis seguits.
  • TZ=Europe/Madrid resol allò de l'apartat 7.5 de 08-01: els dynos corren en UTC.

Advertiment de seguretat. Les config vars estan xifrades en repòs, però són visibles per a qualsevol col·laborador de l'app i apareixen a heroku config. Aplica mínim privilegi a l'equip, i rota el secret JWT si algú deixa el projecte o si sospites exposició:

heroku config:set JWT_SECRET="$(openssl rand -base64 48)" --app ciclourbana-ribalta

Conseqüència pràctica de rotar: tots els testimonis emesos amb la clau anterior deixen de validar, així que els ciutadans s'hauran d'autenticar de nou. És una molèstia acceptable i la raó per la qual convé que el JWT tingui vida curta i hi hagi un refresh token.

  1. Migracions de Flyway a la release phase

CicloUrbana té set migracions (V1…V7) i ddl-auto: validate (04-08). Hi ha dues formes d'aplicar-les, i és el mateix dilema de l'apartat 7.2 de 08-01.

En arrencar és el que ja fa l'aplicació: spring.flyway.enabled: true i Flyway migra a l'arrencada del context. Funciona amb un dyno. Amb diversos, tots intenten migrar alhora i Flyway serialitza amb un bloqueig: correcte, però amb dos efectes molestos —els dynos que esperen consumeixen la seva finestra d'arrencada de 60 segons, i si la migració falla tots els dynos entren en bucle de reinici, deixant l'app caiguda encara que la versió anterior funcionés.

A la release phase és la manera segura. Es declara un tipus de procés release al Procfile:

release: java -Dspring.flyway.enabled=true -Dspring.main.web-application-type=none -jar target/ciclourbana.jar
web: java -Dserver.port=$PORT -Dspring.flyway.enabled=false -jar target/ciclourbana.jar

Com funciona: després de construir el slug i abans de commutar el trànsit a la nova publicació, Heroku executa el procés release en un dyno d'un sol ús, amb les config vars de l'app. web-application-type=none fa que l'aplicació arrenqui sense Tomcat: s'aixeca el context, Flyway migra i el procés acaba. Si surt amb codi diferent de zero, la publicació es cancel·la i els dynos continuen executant la versió anterior.

En arrencar Release phase
Amb diversos dynos Tots esperen el bloqueig Ja està migrat quan arrenquen
Si la migració falla Tots els dynos cauen en bucle La publicació s'avorta, la versió prèvia continua viva
Visibilitat del resultat Barrejada al log del dyno Un pas propi, amb el seu propi codi de sortida
Temps d'arrencada Migració + context dins dels 60 s Només el context
Migració llarga Pot esgotar l'arrencada Té el seu propi temps

La release phase és clarament preferible, i és l'equivalent exacte de la tasca puntual d'ECS (08-03) i del Job de Kubernetes (08-04): el factor 12 dels dotze factors, processos d'administració executats com a processos efímers del mateix codi.

Detall pràctic: en desactivar Flyway al procés web, ddl-auto: validate continua actuant de xarxa de seguretat —si per la raó que sigui l'esquema no correspon, l'aplicació no arrenca en lloc de fallar consulta a consulta.

  1. Escalat, dynos i el reinici diari

heroku ps --app ciclourbana-ribalta          # estat actual
heroku ps:scale web=2 --app ciclourbana-ribalta
heroku ps:type web=standard-1x --app ciclourbana-ribalta
heroku ps:restart --app ciclourbana-ribalta
Tipus de dyno RAM Cost aprox./mes S'adorm Ús
Eco 512 MB 5 $ (paquet d'hores) Sí, als 30 min Pràctiques
Basic 512 MB 7 $ No Demostracions
Standard-1X 512 MB 25 $ No Producció petita, mètriques incloses
Standard-2X 1 GB 50 $ No Quan 512 MB estrenyen
Performance-M 2,5 GB 250 $ No Càrrega alta

512 MB són justos per a Spring Boot. CicloUrbana amb Hibernate, Spring Security i el pool arrenca al voltant de 250-350 MB de heap més metaespai. Amb MaxRAMPercentage=75 hi cap, però sense marge per a un pic. Si apareixen errors R14 al log, la resposta correcta és Standard-2X, no baixar el percentatge fins a l'absurd.

El reinici diari (dyno cycling). Heroku reinicia tots els dynos almenys una vegada cada 24 hores, a més de quan canvia una config var, quan es desplega o quan la màquina amfitriona ho necessita. No és una fallada: és la plataforma imposant el factor 9, llencabilitat. Conseqüències per al que ja tenim construït:

  • Res en memòria no sobreviu. Ja ho complim: sense sessió gràcies al JWT (05-04).
  • Res al disc no sobreviu (apartat 11).
  • Les tasques programades de 07-03 es veuen afectades. Una tasca amb @Scheduled(fixedDelay = ...) reinicia el seu comptador a cada reinici del dyno; i amb dos dynos, tots dos tenen el planificador actiu, així que el CaducadorLloguers i el RecalculadorOcupacio s'executarien per duplicat. ShedLock salva exactament aquesta situació: la taula shedlock de la migració V7 fa que només el primer dyno que prengui el bloqueig executi la tasca i l'altre la salti. Sense ShedLock, escalar a web=2 significaria duplicar informes i recalcular l'ocupació dues vegades per minut.

Un matís sobre les tasques amb cron: si un reinici coincideix just amb l'hora programada, aquesta execució es pot perdre. Per a tasques crítiques convé un planificador extern —Heroku Scheduler és un add-on— que invoqui un endpoint o llanci un procés puntual, en lloc de dependre que el dyno estigui viu en aquell segon exacte.

  1. Logs i sistema de fitxers efímer

heroku logs --tail --app ciclourbana-ribalta
heroku logs --num 500 --source app --app ciclourbana-ribalta
heroku logs --dyno web.1 --tail

El sistema de fitxers d'un dyno és efímer: cada dyno té la seva pròpia còpia del slug amb una capa escrivible que es destrueix a cada reinici, i dos dynos no comparteixen res. D'aquí se segueixen tres regles:

  1. Els logs van a stdout. És el factor 11 de 08-01 i el que Logback ja fa per defecte a CicloUrbana. Si l'aplicació escrivís a logs/ciclourbana.log, aquest fitxer desapareixeria a cada reinici i seria diferent a cada dyno.
  2. Els fitxers pujats per usuaris no es poden desar al dyno. Si demà CicloUrbana admet fotos d'incidències, van a un emmagatzematge d'objectes (S3 o equivalent), no al disc.
  3. La retenció de Heroku és de 1.500 línies o una setmana, el que arribi abans. Per a producció real cal un add-on d'agregació (Papertrail, Logtail) o reenviament a un sistema propi — el tema complet de 09-05.

Un detall de format: els logs de Heroku estan multiplexats entre dynos i processos, i una traça d'excepció de Java apareix com a desenes de línies independents. És un bon argument per adoptar logs en JSON (09-05) i perquè el FiltreRastreig amb MDC de 03-06 hi sigui: amb l'identificador de rastreig a cada línia, reconstruir una petició entre milers de línies barrejades deixa de ser un exercici de paciència.

  1. Domini propi i TLS

El domini herokuapp.com funciona amb HTTPS des del primer moment. Per al domini de l'ajuntament:

heroku domains:add ciclourbana.ribalta.example --app ciclourbana-ribalta
# Configure your app's DNS provider to point to the DNS Target:
#   ciclourbana.ribalta.example -> tranquil-otter-9x8y7z6w.herokudns.example
heroku certs:auto:enable --app ciclourbana-ribalta
heroku certs:auto --app ciclourbana-ribalta      # veure l'estat

Al proveïdor DNS es crea un CNAME cap a la destinació indicada. Mai un registre A: l'adreça IP de Heroku canvia. Amb el CNAME propagat, Automated Certificate Management sol·licita i renova el certificat per Let's Encrypt automàticament. Requereix un pla de pagament (Basic o superior).

Dos ajustos a l'aplicació tanquen el cercle amb 08-01 i 05-05:

server:
  forward-headers-strategy: framework

L'encaminador de Heroku termina el TLS i parla HTTP amb el dyno, afegint X-Forwarded-Proto: https. Sense aquesta propietat, les capçaleres Location dels 201 Created i les URL de springdoc sortirien amb http:// i un amfitrió intern. I el CORS de 05-05 ha de permetre l'origen definitiu https://ciclourbana.ribalta.example, no el domini de proves.

  1. Alternativa: desplegar la imatge de contenidor

A 07-04 vam construir una imatge acurada: multietapa, per capes, amb usuari no root i JAVA_TOOL_OPTIONS. Heroku pot desplegar aquesta imatge en lloc de construir amb el buildpack:

heroku stack:set container --app ciclourbana-ribalta
heroku container:login
heroku container:push web --app ciclourbana-ribalta
heroku container:release web --app ciclourbana-ribalta

Requereix un heroku.yml a l'arrel:

build:
  docker:
    web: Dockerfile
    release: Dockerfile
release:
  command:
    - java -Dspring.flyway.enabled=true -Dspring.main.web-application-type=none -jar /app/ciclourbana.jar
run:
  web: java -Dserver.port=$PORT -jar /app/ciclourbana.jar
Buildpack (JAR) Contenidor (imatge)
Què controles Poc: versió de Java i poca cosa més Tot: base, paquets, usuari, zona horària
Reproductibilitat Construeix Heroku, entorn opac La imatge és idèntica a tot arreu
Paritat amb pre/prod Depèn de la plataforma Total: la mateixa imatge que ECS o Kubernetes
Esforç Zero configuració Mantenir el Dockerfile
Encaix amb 08-03 i 08-04 Cap Directe

Si el pla a mitjà termini és sortir a ECS o Kubernetes, desplegar la imatge des del principi fa que el pas següent no sigui un salt: canvia la plataforma, no l'artefacte. Compte amb $PORT també aquí: l'ENTRYPOINT de la imatge de 07-04 escolta al 8080 fix, així que cal respectar el run: web: del heroku.yml o parametritzar el port.

  1. Comprovar la salut i monitorar

curl -s https://ciclourbana.ribalta.example/actuator/health | jq
# {"status":"UP"}

curl -s https://ciclourbana.ribalta.example/actuator/health/readiness
curl -s https://ciclourbana.ribalta.example/actuator/info | jq '.build'
# { "version": "2.4.0", "time": "2026-09-01T09:14:22Z" }

Aquest /actuator/info amb build-info (07-01) respon la pregunta de 08-01: quina versió està corrent realment a Ribalta.

Un detall de configuració específic de Heroku: el port de gestió separat (8081) de 07-01 no funciona aquí, perquè un dyno només pot exposar un port, el de $PORT. A Heroku cal deixar Actuator al port principal sota la cadena de seguretat que ja vam escriure, amb /actuator/health i /actuator/info públics i la resta exigint ADMIN:

# application-heroku.yml (o dins del perfil prod, condicionat)
management:
  server:
    port: ${MANAGEMENT_PORT:}   # buit = mateix port que l'aplicacio

Complements de monitoratge: els dynos Standard inclouen mètriques (memòria, temps de resposta, throughput) al panell; existeixen add-ons d'APM; i heroku ps més el log mostren els codis d'error de la plataforma, que convé conèixer:

Codi Significat Causa habitual
R10 Boot timeout: no va escoltar a $PORT en 60 s El Procfile no passa $PORT, o l'arrencada triga massa
R14 Quota de memòria superada Heap mal dimensionat per al tipus de dyno
H12 Request timeout als 30 s Consulta lenta o crida externa sense timeout (07-06)
H10 App crashed Excepció a l'arrencada; mira el log complet

  1. Costos i neteja

Advertiment de cost. Tot el d'aquesta lliçó es factura per hora prorratejada des del moment en què existeix, amb trànsit o sense.

Recurs Pla mínim Cost aprox./mes
Dyno Basic 1 dyno 7 $
Dyno Standard-1X 1 dyno 25 $
Heroku Postgres essential-0 5 $
Certificat automàtic Inclòs amb pla de pagament 0 $
Add-on de logs Pla bàsic 0-7 $
Total mínim realista ~12-32 $/mes

Neteja obligatòria en acabar la pràctica. Destruir l'app elimina també els seus add-ons i les seves còpies de seguretat:

# 1. (Opcional) Descarregar una copia abans de destruir res
heroku pg:backups:capture --app ciclourbana-ribalta
heroku pg:backups:download --app ciclourbana-ribalta

# 2. Veure el que es facturara
heroku addons --app ciclourbana-ribalta
heroku ps --app ciclourbana-ribalta

# 3. Destruir (demana confirmacio escrivint el nom)
heroku apps:destroy --app ciclourbana-ribalta --confirm ciclourbana-ribalta

# 4. Verificar que no queda res
heroku apps
heroku addons

heroku apps:destroy és irreversible i s'endú la base de dades i les seves còpies. Si el domini propi hi estava apuntant, esborra també el CNAME al proveïdor DNS per no deixar un registre penjant cap a una destinació inexistent.

  1. Els límits d'un PaaS

Un PaaS és l'elecció correcta fins que deixa de ser-ho. Els senyals:

Límit Manifestació Quan apareix
Cost per unitat de capacitat Quatre Standard-2X costen més que la infraestructura equivalent En escalar de debò
Sense xarxa privada pròpia No pots aïllar la base de dades en subxarxes teves ni connectar amb sistemes interns de l'ajuntament Requisits de xarxa o compliment
Control limitat de la JVM i del sistema No hi ha ajustos fins de l'amfitrió, ni versions concretes de biblioteques del sistema Problemes de rendiment específics
Reinici diari forçat Incompatible amb processos llargs que no tolerin interrupcions Treballs per lots pesants
Tancament de connexions als 30 s (H12) Peticions llargues o SSE necessiten un altre disseny Descàrregues grans, streaming
Dependència del proveïdor Add-ons, Procfile, release phase i CLI són seus En voler migrar
Regió i compliment Dades municipals que han de residir a la UE Requisit legal des del dia u

L'últim punt és especialment rellevant per a CicloUrbana: les dades dels ciutadans de Ribalta estan subjectes al RGPD i l'ajuntament pot exigir allotjament a la Unió Europea. Heroku té regió europea, però és una decisió que cal prendre en crear l'app (heroku create --region eu), no després.

Quan algun d'aquests senyals apareix, la destinació natural és un model de contenidors gestionats amb xarxa pròpia: exactament el que fa la lliçó següent.

Errors Comuns i Consells

Escoltar en un port fix. Sense $PORT, el dyno no rep trànsit i mor amb R10 als 60 segons. És l'error número u.

procfile en minúscula o dins d'una subcarpeta. Heroku no el troba, el log diu Procfile declares types -> (none) i l'app no arrenca. S'ha de dir Procfile, a l'arrel, sense extensió.

Fer servir DATABASE_URL com si fos JDBC. La fallada és Driver claims to not accept jdbcUrl. Tradueix amb variables explícites o amb l'EnvironmentPostProcessor, i no oblidis sslmode=require.

Oblidar SPRING_PROFILES_ACTIVE=prod. L'app arrenca amb la configuració de desenvolupament a Internet: Swagger obert, logs DEBUG i potser H2. És l'escenari exacte de l'exercici 3 de 07-02.

Escalar a dos dynos sense revisar el pool. Amb essential-0 (20 connexions) i maximum-pool-size: 10, dos dynos esgoten el límit i el tercer procés —inclosa la release phase— falla amb too many clients.

Escalar a dos dynos sense ShedLock. Els informes surten duplicats i l'ocupació es recalcula dues vegades. La migració V7 de 07-03 ja hi és: només cal verificar que les tasques porten @SchedulerLock.

Escriure fitxers al dyno. Desapareixen al pròxim reinici i no es comparteixen entre dynos.

Consell: fes servir un pipeline amb pre i prod. Dues apps encadenades i heroku pipelines:promote mouen el slug ja construït d'una a l'altra, sense reconstruir. És el principi de 08-01 implementat per la plataforma.

Consell: heroku releases i heroku rollback són el teu pla de reversió. heroku releases llista les publicacions numerades i heroku rollback v41 torna a l'anterior en segons. Amb la salvetat coneguda: la base de dades no torna, així que l'esquema ha de continuar sent compatible cap enrere.

Consell: heroku run bash per inspeccionar. Obre un dyno puntual amb el slug i les config vars. Perfecte per depurar i també un recordatori de per què els secrets són secrets: qui pot executar això, els veu tots.

Exercicis

Exercici 1

Prepara el repositori de CicloUrbana per a Heroku sense desplegar res encara: escriu system.properties i un Procfile amb els processos web i release, decideix quines config vars calen i en quin ordre s'executa tot des de git push fins que l'app atén la seva primera petició. Explica què passaria si faltés cadascun dels dos fitxers.

Exercici 2

Es desplega CicloUrbana i el log mostra, en aquest ordre: Java app detected, BUILD SUCCESS, Launching... v3, i tot seguit un bucle de at=error code=H10 desc="App crashed" amb l'excepció java.lang.IllegalStateException: Cannot load driver class: org.postgresql.Driver ... jdbcUrl is required with driverClassName. L'app té l'add-on heroku-postgresql:essential-0 creat i heroku config mostra DATABASE_URL. Diagnostica, corregeix de les dues formes possibles i explica quina triaries per a un projecte que planeja migrar a AWS d'aquí a sis mesos.

Exercici 3

CicloUrbana porta un mes a Heroku amb web=1, dyno Basic i essential-0. L'ajuntament demana alta disponibilitat, així que s'escala a web=2. En 48 hores apareixen tres problemes: (a) l'informe nocturn d'ocupació arriba dues vegades per correu, (b) el log mostra FATAL: sorry, too many clients already de manera intermitent i (c) durant els desplegaments alguns ciutadans reben 503. Explica la causa de cadascun i proposa la correcció completa, indicant què canvia a l'aplicació, què a la configuració i què al pla de la plataforma.

Solucions

Solució 1

system.properties a l'arrel:

java.runtime.version=21
maven.version=3.9.9

Procfile a l'arrel:

release: java -Dspring.flyway.enabled=true -Dspring.main.web-application-type=none -jar target/ciclourbana.jar
web: java -Dserver.port=$PORT -XX:MaxRAMPercentage=75 -jar target/ciclourbana.jar

Config vars necessàries:

Variable Valor Per què
SPRING_PROFILES_ACTIVE prod Activa application-prod.yml (07-02)
SPRING_DATASOURCE_URL jdbc:postgresql://...?sslmode=require Traducció de DATABASE_URL
SPRING_DATASOURCE_USERNAME / _PASSWORD de l'add-on Ídem
JWT_SECRET openssl rand -base64 48 Signatura HS256 (05-04); mai el de desenvolupament
TZ Europe/Madrid Els dynos corren en UTC (07-03)
JAVA_TOOL_OPTIONS -XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError Evitar R14

Seqüència completa: git push heroku main → el buildpack detecta el pom.xml → instal·la el JDK 21 llegit de system.properties → executa ./mvnw -DskipTests clean install → comprimeix el slug → llegeix el Procfile i descobreix els tipus release i web → executa el procés release en un dyno d'un sol ús, que aixeca el context sense Tomcat, aplica V1…V7 i acaba amb codi 0 → si el codi és 0, activa la publicació v3 → arrenca el dyno web, que escolta a $PORT, valida l'esquema amb ddl-auto: validate i queda llest → l'encaminador comença a enviar-li trànsit.

Si falta system.properties: el buildpack instal·la el seu JDK per defecte. Si és anterior al 21, la construcció falla en compilar codi de Java 21, o —pitjor— construeix i l'arrencada mor amb UnsupportedClassVersionError.

Si falta el Procfile: el buildpack de Java intenta endevinar l'ordre d'arrencada a partir del JAR. Pot arribar a funcionar, però sense $PORT, així que el dyno no escolta on ha d'escoltar i mor amb R10 Boot timeout. I no hi hauria release phase, amb la qual cosa les migracions s'haurien d'aplicar en arrencar.

Solució 2

Diagnòstic. La construcció va anar bé i l'app va arrencar, així que el problema és de configuració en temps d'execució. El missatge jdbcUrl is required ve d'HikariCP: no va trobar spring.datasource.url. L'add-on va definir DATABASE_URL, però Spring Boot no la reconeix com a propietat seva —espera SPRING_DATASOURCE_URL— i encara que la llegís, postgres://usuari:clau@host:5432/base no és una URL JDBC: li falta el prefix jdbc: i porta credencials incrustades que el driver no accepta aquí.

Correcció A — variables explícites:

heroku config:get DATABASE_URL --app ciclourbana-ribalta
# postgres://u9k2:[email protected]:5432/d3n1
heroku config:set \
  SPRING_DATASOURCE_URL="jdbc:postgresql://ec2-10-20-30-40.compute-1.amazonaws.example:5432/d3n1?sslmode=require" \
  SPRING_DATASOURCE_USERNAME="u9k2" \
  SPRING_DATASOURCE_PASSWORD="pw7x" \
  --app ciclourbana-ribalta

Correcció B — EnvironmentPostProcessor: la classe TraductorDatabaseUrl de l'apartat 7, registrada a META-INF/spring.factories, que tradueix a l'arrencada i no fa res fora de Heroku.

Quina triar amb AWS a sis mesos vista: l'A. Raonament: l'opció B introdueix codi específic de Heroku dins de l'artefacte, just el que 08-01 demana evitar. És una peça que caldria mantenir, provar i, previsiblement, esborrar en la migració. L'opció A resol el problema fora del binari, amb les tres propietats estàndard de Spring, que són exactament les mateixes que es faran servir amb RDS: migrar consistirà a canviar-ne els valors. L'inconvenient conegut —Heroku rota les credencials— es mitiga documentant-ho al runbook i subscrivint-se als avisos de manteniment de l'add-on.

Si l'horitzó fos quedar-se anys a Heroku amb rotacions freqüents, la B seria defensable; fins i tot llavors, aïllada en un paquet d'integració i activada per una condició explícita.

Solució 3

(a) L'informe arriba dues vegades. Tots dos dynos tenen @EnableScheduling actiu i cadascun executa la seva pròpia còpia de la tasca. És el problema de 07-03 amb diverses instàncies, ara en producció. La causa concreta és que la tasca de l'informe no porta @SchedulerLock, o que falta la configuració de ShedLock. Correcció a l'aplicació:

@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Madrid")
@SchedulerLock(name = "informeOcupacioDiari",
               lockAtMostFor = "PT30M", lockAtLeastFor = "PT5M")
public void generarInformeDiari() { ... }

lockAtMostFor allibera el bloqueig si el dyno mor a mitges; lockAtLeastFor evita que un segon dyno l'executi si el primer va acabar en mil·lisegons per rellotge desajustat. La taula shedlock ja existeix des de la migració V7, així que no cal res de nou a la base de dades. Verificació: el log del dyno que no executa ha de mostrar la traça de ShedLock indicant que no va obtenir el bloqueig.

(b) too many clients already. El pla essential-0 permet 20 connexions i maximum-pool-size és 10: dos dynos consumeixen les 20 exactes, sense deixar-ne cap per a la release phase, per a heroku pg:psql ni per al monitoratge de l'add-on. Per això és intermitent: falla just quan alguna cosa més demana connexió. És el càlcul de 08-01:

2 dynos × 10 + release (1..2) + admin (1..2) = 22..24 > 20

Correcció en dos fronts. A la configuració:

spring:
  datasource:
    hikari:
      maximum-pool-size: ${HIKARI_POOL_SIZE:6}
      minimum-idle: 2
      connection-timeout: 3000

Amb 2 × 6 = 12 queden 8 connexions de marge. I al pla: pujar a essential-2 (40 connexions) si el rendiment amb 6 no n'hi ha prou. Recorda el principi de 08-01: un pool gran no és més ràpid; amb 6 connexions per dyno i consultes indexades, CicloUrbana atén folgadament el trànsit d'una ciutat petita.

(c) 503 durant els desplegaments. En desplegar, Heroku envia SIGTERM als dynos antics i arrenca els nous, però l'encaminador pot continuar enviant peticions al dyno que s'està aturant durant una finestra breu. Si el procés deixa d'acceptar connexions de cop, aquestes peticions fallen. Correccions:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 25s

L'aturada ordenada (01-05) fa que les peticions en curs acabin. Heroku concedeix 30 segons entre SIGTERM i SIGKILL, així que 25 s deixa marge. Complements: escalar a web=3 perquè durant el relleu sempre hi hagi capacitat sobrant, i evitar reinicis innecessaris agrupant els config:set en una única ordre en lloc d'encadenar-ne diversos, cadascun amb la seva publicació i el seu reinici.

Resum del pla. A l'aplicació: @SchedulerLock a les tasques i shutdown: graceful amb el seu temps. A la configuració: maximum-pool-size baixat a 6 i connection-timeout explícit. Al pla de la plataforma: essential-2 si el pool reduït estreny, i valorar web=3. I una conclusió de fons: els tres símptomes van aparèixer el dia que hi va haver més d'una instància, que és el moment en què els factors 6, 8 i 9 deixen de ser teoria.

Conclusió

CicloUrbana ja és a Internet. Qualsevol amb l'adreça pot consultar les estacions de Ribalta, autenticar-se i llogar una bicicleta, sobre HTTPS, amb una base de dades gestionada i còpies de seguretat automàtiques, i tot això s'ha aconseguit sense administrar ni un sol servidor. Saps què et dona fet un PaaS i quin control cedeixes a canvi, i manejes el seu vocabulari complet —app, dyno i els seus tipus, slug, buildpack, Procfile, config var, release, release phase, pipeline i review app—, amb l'advertiment clar que Heroku ja no té nivell gratuït i la taula d'alternatives on aquests mateixos conceptes apareixen amb un altre nom.

Has preparat el projecte amb system.properties i un Procfile que respecta la regla que trenca més desplegaments —escoltar a $PORT—, has llegit el log de construcció línia a línia sabent què revisar a cadascuna, i has resolt el problema clàssic de la DATABASE_URL de Heroku, que segueix els dotze factors però no és una URL JDBC vàlida, amb les dues solucions i el criteri per triar-ne una. Els perfils de 07-02 van encaixar amb les config vars, el secret del JWT es va generar fora del repositori i saps per què i com es rota. Les migracions de Flyway van passar a la release phase, que avorta la publicació si fallen en lloc de deixar tots els dynos en bucle: el factor 12 en la seva forma més concreta.

També coneixes el que la plataforma imposa: dynos amb memòria justa on MaxRAMPercentage deixa de ser un detall, un reinici diari que converteix la llencabilitat en un fet i que obliga que ShedLock estigui ben posat abans d'escalar a dos dynos, un sistema de fitxers efímer que confirma per què els logs van a stdout, i el càlcul del pool d'HikariCP davant del límit de connexions del pla. Has afegit el domini de l'ajuntament amb certificat automàtic i forward-headers-strategy perquè les URL surtin bé darrere del proxy, has comprovat amb /actuator/health i /actuator/info quina versió corre realment, i —molt important— has destruït l'app en acabar la pràctica, perquè tot això es factura per hora.

I saps on són els límits: cost per unitat de capacitat en escalar, absència de xarxa privada pròpia, control reduït de l'entorn, el tall als 30 segons, la dependència del proveïdor i la qüestió de la regió per a dades municipals subjectes al RGPD. Quan aquests senyals apareixen, el pas següent és una infraestructura real que continuï sent gestionada. La lliçó següent, Desplegant a AWS, la construeix: una imatge publicada a ECR, PostgreSQL a RDS dins de subxarxes privades on Internet no arriba, secrets a Secrets Manager, un servei d'ECS Fargate amb la seva definició de tasca, i un balancejador amb certificat d'ACM que comprova la salut contra /actuator/health/readiness. Amb el mateix advertiment de sempre, i aquí més de debò: RDS i el balancejador es facturen per hora encara que ningú no faci servir l'aplicació.

Curs de Spring Boot

Mòdul 1: Introducció a Spring Boot

Mòdul 2: Conceptes bàsics de Spring Boot

Mòdul 3: Construint serveis web RESTful

Mòdul 4: Accés a dades amb Spring Boot

Mòdul 5: Seguretat a Spring Boot

Mòdul 6: Proves a Spring Boot

Mòdul 7: Funcions avançades de Spring Boot

Mòdul 8: Desplegament d'aplicacions Spring Boot

Mòdul 9: Rendiment i monitoratge

Mòdul 10: Millors pràctiques i consells

© Copyright 2026. Tots els drets reservats