Ir al contenido

Docker

Elige una pila, pon dos líneas en un .env al lado, y arráncala.

Cada combinación de aquí la escribe un único generador, y la CI falla cuando un fichero deja de coincidir con él.

Ventana de terminal
docker compose up -d

El volumen state de la pila conserva el estado y la caché de un arranque al siguiente, y el colector puede escribir en él desde el primero: la imagen trae el directorio sobre el que se monta, propiedad del uid con el que corre el colector, y Docker le da ese dueño a un volumen nuevo. Ver qué tiene que ser escribible.

La primera pasada corre todas las familias, porque aún no ha corrido ninguna, salvo que el colector arranca una sola familia de seis horas o más por pasada: traffic, stats, forks y el resto de las once llegan al almacén a lo largo de las dos primeras horas y media, y solo la primera vez. Ver las familias lentas se turnan.

Con Grafana está en http://localhost:3000, usuario admin y contraseña ghchronicle salvo que pongas GRAFANA_PASSWORD. El dashboard ya está ahí: el colector lo publica al arrancar y lo apunta al almacén de al lado, así que no hay nada que importar ni ningún datasource que rellenar.

El resto de esta página es la imagen en sí, para quien la ejecute de otra manera.

Ventana de terminal
docker run -d --name ghchronicle \
-v /etc/ghchronicle/config.yaml:/config.yaml:ro \
-v ghchronicle-state:/var/lib/ghchronicle \
-e GITHUB_TOKEN -e INFLUX_TOKEN \
-p 9605:9605 \
ghcr.io/jmrplens/ghchronicle -config /config.yaml

Con state_file: /var/lib/ghchronicle/state.json en la configuración, que es donde lo pone la configuración de ejemplo. El volumen es lo que conserva el estado y la caché de un contenedor al siguiente, y Docker lo crea al primer uso, propiedad del uid 65532 como el directorio sobre el que se monta; qué tiene que ser escribible explica por qué hace falta cada cosa.

Construida FROM gcr.io/distroless/static-debian13:nonroot sobre un binario estático con CGO_ENABLED=0. No lleva shell ni gestor de paquetes, así que la ejecución de código dentro del contenedor no tiene con qué pivotar, y la etiqueta nonroot fija el uid 65532, lo que lo mantiene fuera de root incluso cuando el orquestador no pone ningún securityContext propio.

Dos consecuencias que conviene saber antes de depurarlo:

  • docker exec ... sh no funciona. No hay sh. Lee los logs en su lugar.
  • Todo lo que el contenedor escriba debe pertenecer al uid 65532 o ser escribible por él.

Toda imagen publicada desde la 2.5.1, que es toda imagen que nombra una página de release, va firmada con cosign, sin claves y con la misma identidad que el fichero de sumas que comprueban las demás instalaciones: el workflow de release de este repositorio, ejecutado para una etiqueta de release, anotado en un registro público de transparencia. Con cosign 3:

Ventana de terminal
cosign verify \
--certificate-identity-regexp 'https://github.com/jmrplens/ghchronicle/.github/workflows/release.yml@refs/tags/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/jmrplens/ghchronicle:v2.6.5 > /dev/null
Verification for ghcr.io/jmrplens/ghchronicle:v2.6.5 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates

La copia de Docker Hub, docker.io/jmrplens/ghchronicle, se verifica con la misma orden. Lo que cosign comprueba es un digest, no la etiqueta: sin > /dev/null imprime además, en JSON, el digest que verificó (docker-manifest-digest), y una imagen descargada como ghcr.io/jmrplens/ghchronicle@sha256:... es la verificada aunque una etiqueta se mueva después. latest se mueve al digest de una release lo último, cuando el workflow de release ya ha verificado la firma de ese digest y ha ejecutado la imagen en las dos arquitecturas, así que nunca nombra una imagen sin comprobar; una release que falla antes lo deja en la anterior.

Usa cosign 3. Las firmas van en el formato de bundle que escribe, y un cosign más antiguo responde no signatures found para estas imágenes: medido, cosign 2.6.1 encuentra la firma solo si se le pasa --new-bundle-format, la 2.5.0 no la encuentra ni así, y la 2.4.2 no conoce la opción. Las imágenes hasta la 2.5.0 no llevan firma ninguna, así que para ellas esa respuesta es la verdadera.

El fichero de configuración se monta de solo lectura. Seis cosas no:

RutaNecesaria para
state_fileSiempre. Sin una ruta persistente el recorrido de estrellas se repite en cada reinicio
<nombre>-cache.binSiempre. La caché, que vive junto al fichero de estado y no tiene ajuste propio
sinks.dedupe_fileEl servicio permanente, con un destino que lo lleve. Una ejecución -once no abre ninguno
<nombre>-lockEl servicio permanente, que lo mantiene mientras corre, y -migrate -yes
sinks.file.pathSolo con el destino de fichero
log.fileSolo con un fichero de log configurado

Los tres primeros viven por omisión en el mismo directorio, y el candado también, así que un solo directorio montado los cubre, y tiene que ser un directorio. Cada uno de los tres se escribe a su lado y se renombra en su sitio, para que nadie lea nunca medio fichero, y sobre un fichero montado solo no se puede renombrar: medido con la imagen 2.6.1 construida desde su Dockerfile, un fichero de estado montado solo no se guardó nunca, y cada pasada lo dijo.

level=WARN msg="state not saved" err="rename /var/lib/ghchronicle/state.json.tmp /var/lib/ghchronicle/state.json: device or resource busy"

Un directorio en el que el colector no puede escribir da el mismo aviso, con el error que toque, y otro por la caché de al lado:

level=WARN msg="state not saved" err="open /var/lib/ghchronicle/state.json.tmp: permission denied"
level=WARN msg="cache file not saved" file=/var/lib/ghchronicle/state-cache.bin err="open /var/lib/ghchronicle/state-cache.bin.tmp: permission denied"

Desde la 2.6.1 la imagen trae /var/lib/ghchronicle como propiedad del uid 65532, y Docker llena un volumen con nombre nuevo desde el directorio sobre el que se monta, dueño incluido, así que el directorio que nombra la configuración de ejemplo no necesita que se le haga nada: medido con un volumen recién creado, y con uno vacío que la imagen 2.6.0 había dejado a root, el colector escribió su estado y su caché en los dos sin ningún aviso. Sin nada montado ahí también escribe, y el estado se va con el contenedor. Hasta la 2.6.0 la imagen no tenía ese directorio: sin nada montado ahí quedaba uno que el uid 65532 no podía crear, y un volumen con nombre nuevo ahí era de root, que es de donde salieron las dos líneas de arriba.

Quedan dos maneras de acabar con un directorio en el que el colector no puede escribir. Un volumen con nombre montado donde la imagen no tiene un directorio propio se crea como propiedad de root: entrégaselo una vez al uid 65532, con cualquier imagen que traiga un chown, como en docker run --rm -v <volumen>:/v alpine chown 65532:65532 /v. Un directorio del anfitrión se monta tal cual, sea de quien sea: hazle sudo chown 65532:65532 en el anfitrión. Una configuración que no nombra ningún state_file escribe en el directorio de trabajo del contenedor, que es escribible y se va con el contenedor.

Sin el estado, cada contenedor nuevo empieza de cero: el recorrido de estrellas, el historial entero de estrellas y el recorrido de coautorías otra vez, y todas las familias a la vez. Sin la caché, una pasada entera volviendo a preguntar a GitHub lo que ya sabía: la caché que hay a su lado. Sin el registro, una pasada entera de reescritura, que es justo lo que existe para evitar: solo se escribe lo que ha cambiado.

EXPOSE 9605 es el exportador de Prometheus, y es el único listener que el proceso abre. Publícalo solo si activaste el destino prometheus; todos los demás destinos son salientes.

compose.yaml
services:
ghchronicle:
image: ghcr.io/jmrplens/ghchronicle
command: ["-config", "/config.yaml"]
restart: unless-stopped
environment:
GITHUB_TOKEN: ${GITHUB_TOKEN}
INFLUX_TOKEN: ${INFLUX_TOKEN}
volumes:
- ./config.yaml:/config.yaml:ro
- state:/var/lib/ghchronicle
volumes:
state:

Descarga la nueva imagen y vuelve a crear el contenedor. Con el valor por omisión, migrate: auto, su arranque aplica por su cuenta, antes de su primera pasada, cada cambio de la nueva versión que no pierde nada, y avisa del resto en cada arranque con las dos órdenes que lo aplican: mira Migraciones. El contenedor acepta las mismas opciones que el binario, así que esas órdenes corren en un contenedor de un solo uso del mismo servicio, con su volumen, su entorno y su usuario, mientras el servicio está parado:

Ventana de terminal
docker compose pull ghchronicle
docker compose up -d ghchronicle
docker compose logs ghchronicle | grep 'migration pending'
docker compose stop ghchronicle
docker compose run --rm ghchronicle -config /config.yaml -migrate
docker compose run --rm ghchronicle -config /config.yaml -migrate -yes
docker compose start ghchronicle

El primer run imprime el plan y no cambia nada; el segundo lo aplica y vuelve a leer lo que despejó. Los dos montan el volumen que monta el servicio, así que encuentran el candado que este mantiene junto al fichero de estado: con el servicio aún en marcha, -migrate -yes se niega, nombra el proceso con el número que le da el contenedor del propio servicio, y no cambia nada. Medido con dos contenedores sobre un mismo volumen con nombre: un flock que tenía uno se le negó al otro, y se le concedió en cuanto el primero se fue. Un contenedor de una sola pasada que lanza un planificador, más abajo, no tiene ningún bloqueo mientras hace su pasada, así que pausa el planificador mientras duran las dos ejecuciones.

El contenedor acepta las mismas opciones que el binario, así que un planificador puede ejecutarlo sin un servicio permanente.

Ventana de terminal
docker run --rm \
-v /etc/ghchronicle/config.yaml:/config.yaml:ro \
-v ghchronicle-state:/var/lib/ghchronicle \
-e GITHUB_TOKEN \
ghcr.io/jmrplens/ghchronicle -config /config.yaml -once

Monta el volumen de estado también en este modo. Es lo que hace barata la segunda ejecución, y lo que mantiene cada familia en su cadencia: sin él cada ejecución recoge todas las familias.