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.
# ghchronicle, with InfluxDB and Grafana.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. Grafana is on http://localhost:3000,# admin and the password below, with the dashboard already in it: the# collector publishes it on start and points it at the store beside it.
name: ghchronicle
services:
influxdb: image: influxdb:3-core # Without a credential, because a store that exists for the first time # when the collector first writes to it has nobody to have made one. command: - influxdb3 - serve - --node-id=node0 - --object-store=file - --data-dir=/var/lib/influxdb3 - --without-auth volumes: - influx:/var/lib/influxdb3 healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8181/health"] interval: 5s timeout: 3s retries: 30
grafana: image: grafana/grafana:12.3.0 environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} ports: - "3000:3000" volumes: - grafana:/var/lib/grafana healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] interval: 5s timeout: 3s retries: 30
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: influxdb: condition: service_healthy grafana: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} GRAFANA_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: influxdb: url: http://influxdb:8181 bucket: github grafana: url: http://grafana:3000 user: admin password: $${GRAFANA_PASSWORD} publish_on_start: true state_file: /var/lib/ghchronicle/state.json
volumes: influx: state: grafana:# ghchronicle, with InfluxDB.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. Nothing is published to Grafana;# the store is yours to point one at.
name: ghchronicle
services:
influxdb: image: influxdb:3-core # Without a credential, because a store that exists for the first time # when the collector first writes to it has nobody to have made one. command: - influxdb3 - serve - --node-id=node0 - --object-store=file - --data-dir=/var/lib/influxdb3 - --without-auth volumes: - influx:/var/lib/influxdb3 healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8181/health"] interval: 5s timeout: 3s retries: 30
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: influxdb: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: influxdb: url: http://influxdb:8181 bucket: github state_file: /var/lib/ghchronicle/state.json
volumes: influx: state:# ghchronicle, with PostgreSQL and Grafana.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. Grafana is on http://localhost:3000,# admin and the password below, with the dashboard already in it: the# collector publishes it on start and points it at the store beside it.
name: ghchronicle
services:
postgres: image: postgres:18.6-alpine environment: POSTGRES_USER: ghchronicle POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} POSTGRES_DB: ghchronicle volumes: # /var/lib/postgresql, not /var/lib/postgresql/data: the 18+ images # moved where they keep the cluster, and a volume on the old path is # refused outright with "in 18+, these Docker images are configured to # store database data in a subdirectory". - postgres:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U ghchronicle"] interval: 5s timeout: 3s retries: 30
grafana: image: grafana/grafana:12.3.0 environment: GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} ports: - "3000:3000" volumes: - grafana:/var/lib/grafana healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"] interval: 5s timeout: 3s retries: 30
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: postgres: condition: service_healthy grafana: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} GRAFANA_PASSWORD: ${GRAFANA_PASSWORD:-ghchronicle} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: postgres: dsn: postgres://ghchronicle:$${POSTGRES_PASSWORD}@postgres:5432/ghchronicle?sslmode=disable grafana: url: http://grafana:3000 user: admin password: $${GRAFANA_PASSWORD} publish_on_start: true datasource: sslmode: disable state_file: /var/lib/ghchronicle/state.json
volumes: postgres: state: grafana:# ghchronicle, with PostgreSQL.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. Nothing is published to Grafana;# the store is yours to point one at.
name: ghchronicle
services:
postgres: image: postgres:18.6-alpine environment: POSTGRES_USER: ghchronicle POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} POSTGRES_DB: ghchronicle volumes: # /var/lib/postgresql, not /var/lib/postgresql/data: the 18+ images # moved where they keep the cluster, and a volume on the old path is # refused outright with "in 18+, these Docker images are configured to # store database data in a subdirectory". - postgres:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U ghchronicle"] interval: 5s timeout: 3s retries: 30
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: postgres: condition: service_healthy environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ghchronicle} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: postgres: dsn: postgres://ghchronicle:$${POSTGRES_PASSWORD}@postgres:5432/ghchronicle?sslmode=disable state_file: /var/lib/ghchronicle/state.json
volumes: postgres: state:# ghchronicle, on its own.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. It writes what it collects to its own# log, which is enough to watch it work. Point sinks at a store of your# own when you have one.
name: ghchronicle
services:
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: stdout: true state_file: /var/lib/ghchronicle/state.json
volumes: state:# ghchronicle, on its own.## Put two lines in a .env file beside this one:## GITHUB_TOKEN=github_pat_...# GITHUB_USER=your-login## then `docker compose up -d`. It writes what it collects to its own# log, which is enough to watch it work. Point sinks at a store of your# own when you have one.
name: ghchronicle
services:
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped environment: GITHUB_TOKEN: ${GITHUB_TOKEN:?put your token in a .env file beside this} volumes: - state:/var/lib/ghchronicle configs: - source: ghchronicle target: /config.yaml
configs: ghchronicle: # The doubled $$ are deliberate. A single $ is read by compose, which # would put the token into the file it hands the container; doubled, # compose writes the reference through and the collector expands it from # its own environment when it starts. content: | github: token: $${GITHUB_TOKEN} targets: user: ${GITHUB_USER:?put the account to collect in a .env file beside this} sinks: stdout: true state_file: /var/lib/ghchronicle/state.json
volumes: state:docker compose up -dEl 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.
Un contenedor, a mano
Sección titulada «Un contenedor, a mano»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.yamlCon 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.
La imagen
Sección titulada «La imagen»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 ... shno funciona. No haysh. Lee los logs en su lugar.- Todo lo que el contenedor escriba debe pertenecer al uid 65532 o ser escribible por él.
Verificar la imagen
Sección titulada «Verificar la imagen»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:
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/nullVerification 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 certificatesLa 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.
Qué tiene que ser escribible
Sección titulada «Qué tiene que ser escribible»El fichero de configuración se monta de solo lectura. Seis cosas no:
| Ruta | Necesaria para |
|---|---|
state_ | Siempre. Sin una ruta persistente el recorrido de estrellas se repite en cada reinicio |
<nombre>- | Siempre. La caché, que vive junto al fichero de estado y no tiene ajuste propio |
sinks. | El servicio permanente, con un destino que lo lleve. Una ejecución -once no abre ninguno |
<nombre>- | El servicio permanente, que lo mantiene mientras corre, y -migrate -yes |
sinks. | Solo con el destino de fichero |
log. | Solo 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.
El puerto
Sección titulada «El puerto»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.
Con compose
Sección titulada «Con compose»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/ghchroniclevolumes: state:services: influxdb: image: influxdb:3-core volumes: - influx:/var/lib/influxdb3 ports: - "8181:8181"
ghchronicle: image: ghcr.io/jmrplens/ghchronicle command: ["-config", "/config.yaml"] restart: unless-stopped depends_on: - influxdb environment: GITHUB_TOKEN: ${GITHUB_TOKEN} INFLUX_TOKEN: ${INFLUX_TOKEN} volumes: - ./config.yaml:/config.yaml:ro - state:/var/lib/ghchronicle
volumes: influx: state:El colector alcanza la base de datos por nombre de servicio, así que
sinks.influxdb.url es http://influxdb:8181.
Tras una actualización
Sección titulada «Tras una actualización»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:
docker compose pull ghchronicledocker compose up -d ghchronicledocker compose logs ghchronicle | grep 'migration pending'docker compose stop ghchronicledocker compose run --rm ghchronicle -config /config.yaml -migratedocker compose run --rm ghchronicle -config /config.yaml -migrate -yesdocker compose start ghchronicleEl 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.
Una pasada y salir
Sección titulada «Una pasada y salir»El contenedor acepta las mismas opciones que el binario, así que un planificador puede ejecutarlo sin un servicio permanente.
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 -onceMonta 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.