# Docker

ghchronicle en Docker: la imagen distroless, qué hay que montar con escritura y un compose junto a InfluxDB.

Source: https://docs.jmrp.io/ghchronicle/es/install/docker/

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

## InfluxDB and Grafana

```yaml
# 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:
```

## InfluxDB

```yaml
# 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:
```

## PostgreSQL and Grafana

```yaml
# 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:
```

## PostgreSQL

```yaml
# 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:
```

## The collector on its own

```yaml
# 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:
```

```sh
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](https://docs.jmrp.io/ghchronicle/es/install/docker/#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](https://docs.jmrp.io/ghchronicle/es/configuration/cadences/#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

```sh
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](https://docs.jmrp.io/ghchronicle/es/install/docker/#qué-tiene-que-ser-escribible) explica por qué hace
falta cada cosa.

## 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 ... 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.

## 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](https://docs.sigstore.dev/cosign/system_config/installation/) 3:

```sh
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
```

```text
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.

## Qué tiene que ser escribible

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

| Ruta                 | Necesaria para                                                                         |
| -------------------- | -------------------------------------------------------------------------------------- |
| `state_file`         | Siempre. Sin una ruta persistente el recorrido de estrellas se repite en cada reinicio |
| `<nombre>-cache.bin` | Siempre. La caché, que vive junto al fichero de estado y no tiene ajuste propio        |
| `sinks.dedupe_file`  | El servicio permanente, con un destino que lo lleve. Una ejecución `-once` no abre ninguno |
| `<nombre>-lock`      | El servicio permanente, que lo mantiene mientras corre, y `-migrate -yes`              |
| `sinks.file.path`    | Solo con el destino de fichero                                                         |
| `log.file`           | 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.

```text
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:

```text
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](https://docs.jmrp.io/ghchronicle/es/configuration/#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](https://docs.jmrp.io/ghchronicle/es/sinks/#solo-se-escribe-lo-que-ha-cambiado).

## 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.

> **Escucha en 0.0.0.0 dentro de un contenedor**
>
> La configuración de ejemplo escucha en `127.0.0.1:9605`, que dentro de un
> contenedor es el loopback del propio contenedor y es inalcanzable desde la
> máquina anfitriona. Pon `sinks.prometheus.listen: 0.0.0.0:9605` y deja que
> `-p` decida quién llega.

## Con compose

- **Solo el colector**

  ```yaml title="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:
  ```

- **Con InfluxDB**

  ```yaml title="compose.yaml"
  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

Descarga la nueva imagen y vuelve a crear el contenedor. Con el valor por
omisión, [`migrate: auto`](https://docs.jmrp.io/ghchronicle/es/configuration/#migrate), 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](https://docs.jmrp.io/ghchronicle/es/install/upgrading/#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:

```sh
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.

## Una pasada y salir

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

```sh
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.
