1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# =============================================================================
# `errserve` para una infraestructura que YA tiene Traefik.
#
# TRAEFIK_NETWORK=<red de Traefik> docker compose up -d # o podman-compose
#
# Solo el servicio: Traefik va aparte, en su propio compose o donde viva. Lo que
# hay que darle a este contenedor es (a) la red por la que Traefik lo alcance y
# (b) el router con el middleware `errors` (los labels de abajo, comentados).
#
# El ejemplo local, con Traefik y un backend de demostración incluidos, está en
# `examples/traefik/`; este fichero no lo duplica.
# =============================================================================
name: errserve
services:
errserve:
# La imagen publicada está en **GHCR** (`docker pull ghcr.io/atareao/errserve`):
# no hay nada en Docker Hub. Versiona la etiqueta en producción
# (`ghcr.io/atareao/errserve:0.3.0`); `latest` solo para probar. Si la has
# construido en local con `just build`, la etiqueta es esta misma.
#
# Si el `pull` responde `denied` sin credenciales, el paquete de GHCR sigue
# privado: nace así aunque el repositorio sea público y hay que hacerlo
# público a mano una vez (ver «Publicación» en README.md).
image: ${ERRSERVE_IMAGE:-ghcr.io/atareao/errserve:latest}
container_name: errserve
restart: unless-stopped
# --- Parada limpia --------------------------------------------------------
# El binario es PID 1 del contenedor, y el kernel **no entrega a PID 1** las
# señales con acción por defecto: un `SIGTERM` no lo termina, así que
# `podman stop`/`docker stop` agota su tiempo de gracia y acaba en `SIGKILL`.
# Medido con este mismo fichero: sin `init` la parada tarda **10,4 s** y
# sale el aviso `StopSignal SIGTERM failed… resorting to SIGKILL`; con él,
# **0,4 s** y sin aviso. `init: true` pone a tini delante —lo mismo que hace
# la receta `just run` con `--init`—, que reenvía la señal al proceso. No es
# por el estado (este servicio no guarda ninguno, y sin esto no se pierde
# nada): es por no esperar diez segundos en cada `down`.
init: true
# --- Configuración, solo por entorno --------------------------------------
# Son los valores por defecto de `src/config.rs`, escritos aquí para que
# quien lea el fichero no tenga que abrirlos. Una variable presente pero
# VACÍA es error de arranque, no "usa el defecto"; y `THEME` o `LOG_FORMAT`
# con un valor que no esté en su lista también lo son.
environment:
PORT: "8080" # puerto de escucha dentro del contenedor
LOG_LEVEL: "info" # filtro de tracing (debug, info, warn, error)
LOG_FORMAT: "text" # text | json (json para recolectores)
CONTENT_DIR: "content" # relativo a WORKDIR (/app/content en la imagen)
DEFAULT_ERROR_PAGE: "404" # fuera de 400..=599 es error de arranque
THEME: "default" # default | minimal | terminal | paper | amber | compact
# --- Puertos --------------------------------------------------------------
# En producción NO se publica ninguno: Traefik llega al contenedor por la
# red interna, y publicarlo dejaría el servicio accesible por su cuenta.
# Para depurar el acceso directo (el caso que justifica el proyecto, y el
# único en el que nuestro código decide), descomenta esto y usa
# `curl -i localhost:8080/404`:
#ports:
# - "127.0.0.1:8080:8080"
healthcheck:
# `/healthz` responde siempre 200 y no pasa por la resolución de páginas
# de error. `wget` es el de busybox, ya presente en la imagen: no se
# instala nada. La imagen ya declara este mismo healthcheck en su
# Dockerfile; repetirlo aquí lo deja a la vista y, sobre todo, cubre a
# quien construya la imagen en formato OCI (podman/buildah sin
# `--format docker`), que **descarta el HEALTHCHECK del Dockerfile**.
test:
interval: 30s
timeout: 3s
retries: 3
start_period: 2s
# --- Endurecimiento -------------------------------------------------------
# Las tres van puestas, y las tres son gratis aquí:
#
# * `read_only`: el binario está enlazado estáticamente (musl: no carga
# nada en tiempo de ejecución) y las páginas viven **dentro** de la
# imagen (`CONTENT_DIR=content`), así que el proceso no escribe en
# disco: solo abre ficheros en lectura. **No hace falta `tmpfs`**: no
# hay temporales, ni caché, ni socket que crear —`wget` del healthcheck
# escribe en `/dev/null`, que siempre existe aunque el sistema de
# ficheros sea de solo lectura—. Añadir uno daría al contenedor un
# sitio donde escribir sin que nada lo necesite.
# * `cap_drop: [ALL]`: **ninguna** capacidad le hace falta. No abre
# puertos privilegiados (el 8080 es interno, quien mapea al host es el
# motor), no toca la red más allá de escuchar, no monta nada y no
# cambia de usuario: el proceso ya arranca como uid/gid 10001, sin
# shell de login (el `USER` del Dockerfile).
# * `no-new-privileges:true`: cierra la puerta a que un binario setuid
# que hubiera dentro de la imagen gane privilegios sobre el proceso que
# lo lanza.
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
networks:
- proxy
# =========================================================================
# Traefik — LABELS LISTOS PARA DESCOMENTAR
#
# Requisitos en el Traefik que ya tengas:
# --providers.docker=true
# --providers.docker.exposedByDefault=false (recomendado: solo publica
# lo que lleve traefik.enable)
# y que Traefik esté en la MISMA red que este contenedor (la de abajo).
#
# El puerto del servicio es el **8080 interno del contenedor**, no un puerto
# del host: Traefik habla con el contenedor por la red del compose.
#
# Con esto montado, el servicio cubre dos sitios a la vez:
# * el router de respaldo (`errserve-catchall`): cualquier Host que no case
# con otro router, sin upstream cuyo código conservar. Aquí el código lo
# decide `errserve` — es el caso que justifica el proyecto;
# * `errpages`, el middleware para colgar de los routers de tus
# aplicaciones (añade `middlewares: errpages@docker` al router, o el
# label `traefik.http.routers.<tu-app>.middlewares`). Ahí el estado lo
# conserva Traefik y el cuerpo y las cabeceras son nuestros.
#
#labels:
# traefik.enable: "true"
#
# # --- El servicio -------------------------------------------------------
# traefik.http.services.errserve.loadbalancer.server.port: "8080"
#
# # --- El middleware ----------------------------------------------------
# # `/{status}` es la plantilla que produce la petición `/404`: el código
# # viaja en el path. No la cambies sin cambiar también el servicio.
# traefik.http.middlewares.errpages.errors.status: "400-599"
# traefik.http.middlewares.errpages.errors.service: errserve
# traefik.http.middlewares.errpages.errors.query: "/{status}"
#
# # --- El router de respaldo --------------------------------------------
# # Prioridad explícita y baja: sin ella Traefik ordena por longitud de la
# # regla y este catch-all ganaría a tus routers.
# # La regla es `HostRegexp(.+)` (sintaxis de Go, v3). NO uses
# # `HostRegexp(`{host:.+}`)` de la guía de error-pages: es sintaxis v2 y en
# # v3 no casa con nada (medido), con lo que te quedarías con el 404 en
# # texto plano de Traefik. Alternativa v3: `PathPrefix(`/`)`.
# # Y lleva una segunda conjunción, `&& !Path(`/metrics`)`, que tampoco es
# # opcional: el respaldo atiende cualquier Host, así que sin ella publica
# # los endpoints de servicio del binario —y `/metrics` expone el contador
# # de páginas servidas, que es el perfil de fallos de tu infraestructura—
# # a quien pregunte por un Host que no exista. Con la exclusión, ese caso
# # lo contesta Traefik con su propio 404 en texto plano (19 B) y el
# # respaldo sigue sirviendo `404` y `503` reales en el mismo Host.
# traefik.http.routers.errserve-catchall.rule: HostRegexp(`.+`) && !Path(`/metrics`)
# traefik.http.routers.errserve-catchall.entrypoints: web
# traefik.http.routers.errserve-catchall.service: errserve
# traefik.http.routers.errserve-catchall.middlewares: errpages
# traefik.http.routers.errserve-catchall.priority: "1"
#
# # Si prefieres no publicar el router de respaldo y solo usarlo como
# # servicio de errores de tus aplicaciones, borra las cuatro líneas de
# # `errserve-catchall` y quédate con el servicio y el middleware.
# =========================================================================
# Sin volúmenes: el binario es estático y las páginas van dentro de la imagen
# (`CONTENT_DIR=content`), así que el contenedor no guarda estado y se puede
# recrear cuando haga falta. No hay nada que persistir.
networks:
# La red por la que Traefik alcanza este contenedor: la MISMA que Traefik ya
# usa. Por defecto se asume el nombre `traefik`; si el tuyo es otro, exporta
# `TRAEFIK_NETWORK=<nombre>` (lo ves con `docker network ls`). Al ser
# `external`, este compose no la crea: tiene que existir antes.
proxy:
external: true
name: ${TRAEFIK_NETWORK:-traefik}