Run Valkey as a rootless Podman Quadlet
Replaces the docker_container playbook, which published 127.0.0.1:6379 and kept its /data in an anonymous Docker volume -- the one the audit flagged because no backup script can see it (docs/podman-compat-audit.md 3.1). Valkey becomes the base Quadlet unit, the same shape as Postgres: it knows nothing about its clients, and a service attaches itself by dropping a file into valkey.container.d/ via valkey/roles/attach. Nothing on the host needs 6379, so there is no PublishPort at all rather than a narrower one. The name stays `valkey` for the directory, container, unit and Podman network, and only the alias clients connect on is `redis`: Nextcloud's config.php (written by the image from REDIS_HOST=redis), outline and overleaf reach it as `redis`, while paperless-ngx and yamtrack use the container name. Renaming would touch every REDIS_HOST for no gain. The uid/gid mapping is the detail worth spelling out, because the audit only records the uid. The Alpine image's `valkey` account is 999:1000, not 999:999 -- Alpine already has a gid 999, and the Dockerfile says so before creating the group with the next id. 999 is the uid the anonymous volume was owned by, so the uid matches what was already on disk, but mapping only the uid would leave every file valkey-server writes, dump.rdb included, group-owned by a subgid. Both ids map onto the connection user, so ~/podman-data/valkey stays tanshu:tanshu. Persistence stays on deliberately, and the mount with it. The contents are disposable -- nothing is copied off monoco, and a cold start is survivable -- but this is not purely a cache: Nextcloud keeps its PHP sessions and memcache.locking in it, and Yamtrack uses it as its Celery broker. With RDB on, a rebuild is a graceful stop that writes a final snapshot on SIGTERM that the next container loads, so a redeploy does not log every Nextcloud user out or discard queued tasks. Tmpfs=/data with `save ""` would remove the mount and the whole keep-id mapping with it, and is the right shape for a pure cache, but it would make every redeploy exactly that. The rootless-Podman prerequisites are not applied here: they are the shared task file at caddy/roles/podman, and a host that has run caddy/playbook.yml has them. Two ordering details are load-bearing: - The attach include asserts that <name>.network exists before writing a drop-in that names it. Quadlet rejects the whole valkey unit, with an error that never mentions the drop-in, when a referenced .network file is missing. - A service that attaches flushes handlers before starting the app. The restart that joins Valkey to the new network is a handler, so by default it runs at the end of the play, after the app has already come up and failed to resolve `redis`. nextcloud/roles/valkey is the working example. The play waits on `valkey-cli ping` inside the container rather than trusting systemctl is-active. Restart=always reports active even while the container crash-loops, which is exactly what a wrong keep-id looks like.
This commit is contained in:
1 parent
9bf2cbd7a9
commit
3587bc9001
7 files changed
+635
-52
No files matched your search
+28
-17
@@ -1,21 +1,32 @@
|
|||||||
---
|
---
|
||||||
- hosts: all
|
# Deploys Valkey as a rootless Podman Quadlet unit.
|
||||||
become: true
|
#
|
||||||
|
# Connects as the container user and runs everything as that user: the Quadlet
|
||||||
|
# units belong to it, so the connection's PAM session is what makes
|
||||||
|
# `systemctl --user` work without any environment juggling.
|
||||||
|
#
|
||||||
|
# gather_facts is required: podman_user and the home directory come from the
|
||||||
|
# user_id / user_dir facts.
|
||||||
|
#
|
||||||
|
# The rootless-Podman prerequisites (installing podman, enabling lingering, the
|
||||||
|
# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied
|
||||||
|
# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and
|
||||||
|
# a host that has already run caddy/playbook.yml has them:
|
||||||
|
#
|
||||||
|
# - name: Rootless Podman prerequisites
|
||||||
|
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml"
|
||||||
|
#
|
||||||
|
# The variables those tasks need are duplicated at the top of vars/default.yml,
|
||||||
|
# so this play stands on its own.
|
||||||
|
#
|
||||||
|
# Run with: ansible-playbook -u tanshu valkey/playbook.yml
|
||||||
|
- name: Deploy Valkey as a rootless Podman Quadlet
|
||||||
|
hosts: emily
|
||||||
|
gather_facts: true
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
tasks:
|
roles:
|
||||||
- name: Pull default valkey image
|
# The base Valkey unit. It does NOT know which app networks it serves.
|
||||||
docker_image:
|
# Each service attaches itself via valkey/roles/attach.
|
||||||
name: "{{ container_image }}"
|
- valkey
|
||||||
source: pull
|
|
||||||
force_source: yes
|
|
||||||
|
|
||||||
- name: Create valkey container
|
|
||||||
docker_container:
|
|
||||||
name: "{{ container_name }}"
|
|
||||||
image: "{{ container_image }}"
|
|
||||||
state: started
|
|
||||||
restart_policy: "unless-stopped"
|
|
||||||
published_ports:
|
|
||||||
- 127.0.0.1:6379:6379
|
|
||||||
+251
-33
@@ -1,46 +1,264 @@
|
|||||||
# Docker on Ubuntu 18.04
|
# Valkey (rootless Podman + Quadlet)
|
||||||
|
|
||||||
This playbook will install Docker an Ubuntu 18.04 machine, as explained in the guide on
|
Runs [Valkey](https://valkey.io/) (the Redis fork this repo already used) as a
|
||||||
[How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04).
|
**rootless Podman** container managed by a **systemd user unit** (Quadlet).
|
||||||
A number of containers will be created with the options specified in the `vars/default.yml` variable file.
|
Replaces the `docker_container` playbook, which published `127.0.0.1:6379` and
|
||||||
|
kept its `/data` in an **anonymous Docker volume** — `docker volume ls` state
|
||||||
|
that every backup script in `~/bin/` missed entirely
|
||||||
|
([docs/podman-compat-audit.md §3.1](../docs/podman-compat-audit.md)).
|
||||||
|
|
||||||
|
**Valkey is the base unit and knows nothing about its clients.** It does not list
|
||||||
|
any app network and does not need editing when a service is added or removed.
|
||||||
|
Each service attaches *itself*. That is the same design as
|
||||||
|
[Postgres](../postgres/readme.md), and the mirror image of
|
||||||
|
[Caddy](../caddy/readme.md).
|
||||||
|
|
||||||
|
The name is split on purpose: the directory, container, unit and Podman network
|
||||||
|
are `valkey`, exactly as the Docker-era playbook had them, and only the *alias*
|
||||||
|
clients connect on is `redis`. Renaming the container would touch every app's
|
||||||
|
`REDIS_HOST` for no gain.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
valkey/
|
||||||
|
├── playbook.yml
|
||||||
|
├── vars/default.yml
|
||||||
|
└── roles/
|
||||||
|
├── valkey/ # the base valkey.container unit, data dir, handlers
|
||||||
|
└── attach/ # includable task file: a service attaches itself to Valkey
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything is installed under `{{ quadlet_dir }}`
|
||||||
|
(`~/.config/containers/systemd/`). State lives under `{{ host_directory }}`
|
||||||
|
(`~/podman-data/valkey/`).
|
||||||
|
|
||||||
|
## How services attach themselves
|
||||||
|
|
||||||
|
Valkey joins the app network of every service that needs a cache. A service joins
|
||||||
|
by writing Quadlet *drop-ins* next to `valkey.container`:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/containers/systemd/
|
||||||
|
├── valkey.container # base unit, no app networks
|
||||||
|
├── valkey.container.d/ # ← drop-ins, one per client service
|
||||||
|
│ ├── 10-network-aliases.conf # [Container] NetworkAlias=redis (shared)
|
||||||
|
│ ├── 50-nextcloud.conf # [Container] Network=nextcloud.network
|
||||||
|
│ └── 50-affine.conf
|
||||||
|
├── nextcloud.network
|
||||||
|
└── affine.network
|
||||||
|
```
|
||||||
|
|
||||||
|
A service playbook does both halves — its own network, then the attach:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Register this service with Caddy
|
||||||
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||||
|
vars:
|
||||||
|
network_name: "{{ container_name }}"
|
||||||
|
|
||||||
|
- name: Attach this service to Valkey
|
||||||
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml"
|
||||||
|
vars:
|
||||||
|
network_name: "{{ container_name }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
The play must also run the `podman` role (for `podman_user` and `quadlet_dir`)
|
||||||
|
and define a `Restart Valkey` handler — see
|
||||||
|
[roles/valkey/handlers/main.yaml](roles/valkey/handlers/main.yaml) for the exact
|
||||||
|
snippet. [nextcloud](../nextcloud/readme.md) is the first service to do this and
|
||||||
|
is the working example.
|
||||||
|
|
||||||
|
Order matters in one direction only: the `attach` include needs
|
||||||
|
`<name>.network` to already exist, which is why Caddy's `register` comes first.
|
||||||
|
It fails with a readable message if it does not, because a drop-in naming a
|
||||||
|
missing `.network` file makes Quadlet reject the *whole* valkey unit with an
|
||||||
|
error that never mentions the drop-in.
|
||||||
|
|
||||||
|
### Names: `valkey` and `redis`
|
||||||
|
|
||||||
|
Both resolve on every attached network. `valkey` comes from
|
||||||
|
`ContainerName=valkey`; `redis` is added by `10-network-aliases.conf`, because
|
||||||
|
the clients are not consistent: Nextcloud's `config.php` (written by the image
|
||||||
|
from `REDIS_HOST=redis`), outline and overleaf connect to `redis`, while
|
||||||
|
paperless-ngx and yamtrack connect to `valkey` — the container name — because
|
||||||
|
that is the host their `REDIS_*` URL already had under Docker.
|
||||||
|
|
||||||
|
`NetworkAlias=` applies to *every* network the container joins, which is exactly
|
||||||
|
what a hub wants, and it lives in one shared drop-in with a fixed value for the
|
||||||
|
same reasons as Postgres's `db` alias — see
|
||||||
|
[postgres/readme.md](../postgres/readme.md), "Names: `postgres` and `db`".
|
||||||
|
|
||||||
|
### One way to attach, and one rule
|
||||||
|
|
||||||
|
[roles/attach](roles/attach/tasks/main.yaml) is the shared include, and is what
|
||||||
|
every service uses. A service may instead write the same two files from its own
|
||||||
|
role, but then it **must write identical bytes** on purpose: the drop-ins live in
|
||||||
|
one directory, so anything less means whichever play runs last rewrites the
|
||||||
|
other's file on every run. If you add a second form, make it match.
|
||||||
|
|
||||||
|
Whichever form you use, **flush handlers before the app starts on a first
|
||||||
|
attach.** The restart that joins Valkey to the new network is a handler, so by
|
||||||
|
default it runs at the end of the play — after the app has already come up and
|
||||||
|
failed to resolve `redis`. `nextcloud/roles/valkey` is the working example.
|
||||||
|
|
||||||
|
### The trade-off
|
||||||
|
|
||||||
|
A new drop-in changes the generated unit, so Valkey is **recreated** to join the
|
||||||
|
network. That is cheaper here than Postgres's restart: the recreate is a graceful
|
||||||
|
stop, valkey-server writes a final `dump.rdb` on SIGTERM, and the new container
|
||||||
|
loads it back from the bind mount. Attached apps keep their keys across the
|
||||||
|
restart — what they lose is the seconds the container is down. Only a hard kill
|
||||||
|
or a power loss costs the changes since the last save point.
|
||||||
|
|
||||||
|
Persistence is on **deliberately**, and `host_directory` stays even though
|
||||||
|
everything in it is disposable. Two clients do not use Valkey as a pure cache:
|
||||||
|
Nextcloud keeps its PHP sessions and `memcache.locking` there, so an eviction
|
||||||
|
logs every active user out and drops their file locks, and Yamtrack uses it as
|
||||||
|
its Celery broker, so an eviction discards queued tasks. The stateless
|
||||||
|
alternative — `Tmpfs=/data` with `save ""` — is the right shape for a cache
|
||||||
|
and would take `UserNS=keep-id` and the whole uid/gid mapping with it, but it
|
||||||
|
would make every redeploy exactly that logout and dropped queue. Not copying the
|
||||||
|
old anonymous volume (below) is a separate question: a cold start is survivable,
|
||||||
|
it is just not free.
|
||||||
|
|
||||||
|
## Who runs as whom
|
||||||
|
|
||||||
|
`UserNS=keep-id:uid=999,gid=1000`, which are the two ids the *server* writes as:
|
||||||
|
the image's entrypoint starts as root, `chown`s the owner of anything in its
|
||||||
|
WORKDIR that is not already `valkey`'s, and drops to the account with `setpriv`.
|
||||||
|
The uid is `999` — the same uid the old anonymous volume was owned by
|
||||||
|
([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)), so the
|
||||||
|
mapping matches what was already on disk — but the gid is `1000`, not `999`:
|
||||||
|
Alpine already has a gid 999, which is why the image's Dockerfile creates the
|
||||||
|
`valkey` group with the next id. Mapping the uid alone would leave every file
|
||||||
|
`valkey-server` writes group-owned by a subgid.
|
||||||
|
|
||||||
|
`keep-id` maps both ids onto `podman_user`, which means the data root — and
|
||||||
|
everything in it — is owned by `tanshu:tanshu` rather than by a subuid or a
|
||||||
|
subgid. `roles/valkey` creates `~/podman-data/valkey` up front for the same
|
||||||
|
reason Postgres's role creates its version directory: a directory created by
|
||||||
|
container-root under `keep-id` is stranded as e.g. `165536`, readable but not
|
||||||
|
removable by the host user.
|
||||||
|
|
||||||
|
Like Caddy, `podman_user` is derived from the connection
|
||||||
|
(`ansible_facts['user_id']`) and the play carries no `become`. This playbook does
|
||||||
|
not run the podman role, so it has no guard against connecting as `root`; the
|
||||||
|
prerequisites are applied separately (see Running).
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
- `create_containers`: number of containers to create.
|
| Variable | Purpose |
|
||||||
- `default_container_name`: default name for new containers.
|
|---|---|
|
||||||
- `default_container_image`: default image for new containers.
|
| `podman_user`, `podman_data_dir`, `quadlet_dir` | Rootless-Podman contract, duplicated from [caddy/vars/default.yml](../caddy/vars/default.yml) |
|
||||||
- `default_container_command`: default command to run on new containers.
|
| `container_name` | Container / unit name, and the prefix of the drop-in directory |
|
||||||
|
| `image` | `docker.io/valkey/valkey:alpine`, fully qualified on purpose |
|
||||||
|
| `app_uid` / `app_gid` | The ids the server writes as — `999` / `1000` in the Alpine image |
|
||||||
|
| `host_directory` | Where state lives (`~/podman-data/valkey`) |
|
||||||
|
| `data_mount` | The image's WORKDIR and VOLUME — `/data` |
|
||||||
|
| `container_port` | `6379`, the port inside the container clients connect to |
|
||||||
|
| `valkey_dropin_dir` | Where services attach their networks |
|
||||||
|
|
||||||
|
`-alpine` floats within the image's tag for security patches; there is no pinned
|
||||||
|
digest, the same as the other service playbooks.
|
||||||
|
|
||||||
## Running this Playbook
|
### No published port
|
||||||
|
|
||||||
Quick Steps:
|
There is deliberately no `PublishPort`. The Docker-era playbook published
|
||||||
|
`127.0.0.1:6379:6379`, which meant anything that could reach the loopback
|
||||||
|
interface on the host could read and flush every app's cache. Clients reach it
|
||||||
|
over the app networks now, so there is no host port at all. Verify with
|
||||||
|
`ss -tlnp | grep 6379` — it should print nothing.
|
||||||
|
|
||||||
### 1. Obtain the playbook
|
## Running
|
||||||
```shell
|
|
||||||
git clone https://github.com/do-community/ansible-playbooks.git
|
|
||||||
cd ansible-playbooks/docker_ubuntu1804
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Customize Options
|
The play targets `emily` directly (`hosts: emily`):
|
||||||
|
|
||||||
```shell
|
|
||||||
nano vars/default.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
```yml
|
|
||||||
#vars/default.yml
|
|
||||||
---
|
|
||||||
create_containers: 4
|
|
||||||
default_container_name: docker
|
|
||||||
default_container_image: ubuntu
|
|
||||||
default_container_command: sleep 1d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Run the Playbook
|
|
||||||
|
|
||||||
```command
|
```command
|
||||||
ansible-playbook -l [target] -i [inventory file] -u [remote user] playbook.yml
|
ansible-playbook -u tanshu valkey/playbook.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
For more information on how to run this Ansible setup, please check this guide: [How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04).
|
The inventory that defines `emily` lives in `prepare-server/`, whose `ansible.cfg`
|
||||||
|
names it — run from there, or pass an inventory explicitly.
|
||||||
|
|
||||||
|
This play applies **only** the Valkey unit and its data directory. The
|
||||||
|
rootless-Podman prerequisites — installing podman, `loginctl enable-linger`, the
|
||||||
|
low-port sysctl and the Quadlet directory — are the shared task file at
|
||||||
|
[caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), and
|
||||||
|
are not run here. A host that has already run `caddy/playbook.yml` has them.
|
||||||
|
|
||||||
|
`systemctl --user enable valkey` is neither needed nor possible: the unit is
|
||||||
|
*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself.
|
||||||
|
|
||||||
|
**Deploy this before any service that attaches to it.** The attach include only
|
||||||
|
writes drop-ins next to `valkey.container`; if the unit has never been deployed,
|
||||||
|
the first `Restart Valkey` handler fails against a unit that does not exist. The
|
||||||
|
prerequisite order is `caddy` → `postgres` → `valkey` → the service.
|
||||||
|
|
||||||
|
## Migrating from the Docker version
|
||||||
|
|
||||||
|
There is **nothing to migrate.** The Docker-era Valkey kept `/data` in an
|
||||||
|
anonymous volume — the one the audit flagged because no backup script could see
|
||||||
|
it — and everything in it is disposable: it holds the RDB snapshot and, if it was
|
||||||
|
ever enabled, the AOF. Starting empty costs one cold cache, with the same
|
||||||
|
user-visible edges as any eviction ([The trade-off](#the-trade-off)): Nextcloud
|
||||||
|
sessions gone and queued Celery tasks dropped. Nothing that has to be carried
|
||||||
|
across.
|
||||||
|
|
||||||
|
That is the opposite of `hiwikijs`, the other anonymous volume, whose content is
|
||||||
|
irreplaceable ([docs/podman-compat-audit.md §3.1](../docs/podman-compat-audit.md)).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Optional: look at what the old container had, before Docker goes away.
|
||||||
|
ssh monoco 'docker inspect valkey --format "{{range .Mounts}}{{.Name}} {{.Destination}}{{end}}"'
|
||||||
|
ssh monoco 'docker volume ls'
|
||||||
|
|
||||||
|
# Nothing is copied to emily. The volume is abandoned deliberately, and can be
|
||||||
|
# removed once the old host is being decommissioned:
|
||||||
|
# ssh monoco 'docker volume rm <name from the line above>'
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** start the old Valkey container again once apps are on `emily`: it would
|
||||||
|
serve a second, divergent cache.
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
```
|
||||||
|
systemctl --user status valkey
|
||||||
|
systemctl --user restart valkey
|
||||||
|
journalctl --user -u valkey -f
|
||||||
|
podman logs valkey
|
||||||
|
podman exec -it valkey valkey-cli
|
||||||
|
podman exec valkey valkey-cli ping
|
||||||
|
podman exec valkey valkey-cli info keyspace
|
||||||
|
podman inspect valkey --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The play waits on `valkey-cli ping` inside the container rather than trusting
|
||||||
|
systemd. `Restart=always` means the unit reports `active` even while the
|
||||||
|
container crash-loops, so a wrong `keep-id` (an entrypoint permission error, over
|
||||||
|
and over) would otherwise look like success.
|
||||||
|
|
||||||
|
### Debugging a unit that will not generate
|
||||||
|
|
||||||
|
```
|
||||||
|
/usr/lib/systemd/user-generators/podman-user-generator --user --dryrun
|
||||||
|
systemd-analyze --user --generators=true verify valkey.service
|
||||||
|
```
|
||||||
|
|
||||||
|
The most common cause is a `Network=` naming a `.network` file that does not
|
||||||
|
exist — which is why `attach` checks first.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- [ ] `systemctl --user status valkey` is active, and stays active after a reboot
|
||||||
|
- [ ] `podman exec valkey valkey-cli ping` prints `PONG`
|
||||||
|
- [ ] `ss -tlnp | grep 6379` on the host prints nothing
|
||||||
|
- [ ] `ls -ln ~/podman-data/valkey` shows `tanshu` as owner **and** group, not a
|
||||||
|
`165536+` subuid/subgid
|
||||||
|
- [ ] From each client app's container: `getent hosts valkey` and `getent hosts redis`
|
||||||
|
- [ ] Each attaching app can read **and** write through the cache — in Nextcloud
|
||||||
|
that means an upload, which exercises `memcache.locking` and the PHP session
|
||||||
|
handler the image configures from `REDIS_HOST`
|
||||||
|
- [ ] A `SAVE` produces `dump.rdb` under `~/podman-data/valkey`, owned by
|
||||||
|
`tanshu:tanshu` — the check that catches a wrong `app_gid`
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
# Attach a service's network to Valkey.
|
||||||
|
#
|
||||||
|
# Valkey is the *base* unit and knows nothing about its clients, the same way
|
||||||
|
# Caddy knows nothing about its upstreams. A service that needs a cache adds
|
||||||
|
# itself by dropping files next to valkey.container, and Quadlet merges them in.
|
||||||
|
#
|
||||||
|
# Include this from a service playbook:
|
||||||
|
#
|
||||||
|
# - name: Attach this service to Valkey
|
||||||
|
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml"
|
||||||
|
# vars:
|
||||||
|
# network_name: "{{ network_name }}"
|
||||||
|
#
|
||||||
|
# The including play must:
|
||||||
|
#
|
||||||
|
# 1. have created {{ network_name }}.network already — caddy/roles/register
|
||||||
|
# does that, and every service here that needs a cache is also behind
|
||||||
|
# Caddy, so its register include comes first;
|
||||||
|
# 2. run the `podman` role (or include its task file and define the same four
|
||||||
|
# vars), for podman_user and quadlet_dir;
|
||||||
|
# 3. define a "Restart Valkey" handler — see
|
||||||
|
# roles/valkey/handlers/main.yaml for the exact snippet; and
|
||||||
|
# 4. flush handlers before starting the app, on a first attach. This include
|
||||||
|
# notifies that restart, so by default it runs at the end of the play —
|
||||||
|
# after the app has already started and failed to reach `redis`. A
|
||||||
|
# `meta: flush_handlers` immediately after this include is enough; see
|
||||||
|
# nextcloud/roles/valkey for the working example.
|
||||||
|
#
|
||||||
|
# It runs as the connection user, which must be the rootless podman_user.
|
||||||
|
#
|
||||||
|
# A service may instead write these two files from its own role; the bytes have
|
||||||
|
# to match this file exactly, or the two will overwrite each other on every run.
|
||||||
|
|
||||||
|
- name: "Check that the {{ network_name }} network unit exists"
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ quadlet_dir }}/{{ network_name }}.network"
|
||||||
|
register: valkey_attach_network
|
||||||
|
|
||||||
|
# A drop-in naming a .network file that does not exist makes Quadlet fail to
|
||||||
|
# generate the whole valkey unit, with a message that does not mention the
|
||||||
|
# drop-in at all. Fail here instead, where the cause is obvious.
|
||||||
|
- name: "Fail if {{ network_name }} has no network unit"
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: valkey_attach_network.stat.exists
|
||||||
|
fail_msg: >-
|
||||||
|
{{ quadlet_dir }}/{{ network_name }}.network does not exist, so attaching
|
||||||
|
it to Valkey would break the valkey unit. Create the network first
|
||||||
|
(caddy/roles/register does it), then include this file.
|
||||||
|
|
||||||
|
- name: Ensure the Valkey Quadlet drop-in directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ quadlet_dir }}/{{ valkey_container | default('valkey') }}.container.d"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ podman_user }}"
|
||||||
|
group: "{{ podman_user }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: "Attach {{ network_name }} to Valkey"
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: >-
|
||||||
|
{{ quadlet_dir }}/{{ valkey_container | default('valkey') }}.container.d/50-{{ network_name }}.conf
|
||||||
|
owner: "{{ podman_user }}"
|
||||||
|
group: "{{ podman_user }}"
|
||||||
|
mode: '0644'
|
||||||
|
content: |
|
||||||
|
# Added by the {{ network_name }} playbook. Do not edit by hand.
|
||||||
|
#
|
||||||
|
# Deleting this file removes the network from {{ valkey_container | default('valkey') }} on
|
||||||
|
# the next daemon-reload; nothing else changes.
|
||||||
|
[Container]
|
||||||
|
Network={{ network_name }}.network
|
||||||
|
notify: Restart Valkey
|
||||||
|
|
||||||
|
# NetworkAlias= applies to every network the container joins, which is exactly
|
||||||
|
# right for a hub: this repo's cache clients reach Valkey as `redis` (nextcloud,
|
||||||
|
# which writes REDIS_HOST=redis into its config, plus outline and overleaf),
|
||||||
|
# while the rest use the container name. It is a fixed value in one shared file,
|
||||||
|
# not a per-service option, so the same bytes are written no matter how many
|
||||||
|
# services attach — two services writing their own aliases would make Quadlet
|
||||||
|
# pass --network-alias twice.
|
||||||
|
- name: "Give {{ valkey_container | default('valkey') }} the shared 'redis' alias"
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: >-
|
||||||
|
{{ quadlet_dir }}/{{ valkey_container | default('valkey') }}.container.d/10-network-aliases.conf
|
||||||
|
owner: "{{ podman_user }}"
|
||||||
|
group: "{{ podman_user }}"
|
||||||
|
mode: '0644'
|
||||||
|
content: |
|
||||||
|
# Shared container aliases for {{ valkey_container | default('valkey') }}.
|
||||||
|
# Managed by Ansible (the service playbooks that use it). Do not edit.
|
||||||
|
[Container]
|
||||||
|
NetworkAlias=redis
|
||||||
|
notify: Restart Valkey
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
# All handlers run as the connection user ({{ podman_user }}), so systemctl
|
||||||
|
# --user works without setting XDG_RUNTIME_DIR.
|
||||||
|
|
||||||
|
- name: Reload systemd user units
|
||||||
|
ansible.builtin.command: systemctl --user daemon-reload
|
||||||
|
changed_when: true
|
||||||
|
listen: Reload systemd user units
|
||||||
|
|
||||||
|
- name: Restart Valkey
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: "{{ unit_name }}"
|
||||||
|
scope: user
|
||||||
|
state: restarted
|
||||||
|
daemon_reload: true
|
||||||
|
listen: Restart Valkey
|
||||||
|
|
||||||
|
# Reference implementation for service playbooks. A service that attaches its
|
||||||
|
# network with valkey/roles/attach must notify "Restart Valkey", and defines its
|
||||||
|
# own copy of this handler, e.g.:
|
||||||
|
#
|
||||||
|
# - name: Restart Valkey
|
||||||
|
# ansible.builtin.systemd_service:
|
||||||
|
# name: valkey.service
|
||||||
|
# scope: user
|
||||||
|
# state: restarted
|
||||||
|
# daemon_reload: true
|
||||||
|
# listen: Restart Valkey
|
||||||
|
#
|
||||||
|
# A new drop-in changes the generated unit, so Valkey has to be recreated to join
|
||||||
|
# the network — Quadlet has no live equivalent of `podman network connect`.
|
||||||
|
#
|
||||||
|
# The cost is different from Postgres's: Valkey is a cache, so every attached app
|
||||||
|
# sees a cold cache rather than a period without its database. Correctness is
|
||||||
|
# unaffected; the first requests after the restart are simply slower.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
# Start Valkey
|
||||||
|
#
|
||||||
|
# Everything here runs as {{ podman_user }} — the connection user. No become, no
|
||||||
|
# XDG_RUNTIME_DIR: SSH gave us a real PAM session, so systemd's user manager is
|
||||||
|
# reachable via the standard environment.
|
||||||
|
|
||||||
|
# Created here rather than left to the container's entrypoint. That entrypoint
|
||||||
|
# runs as container-root, which under keep-id is a subuid on the host, so a
|
||||||
|
# directory it created would be owned by e.g. 165536 and the host user could not
|
||||||
|
# `rm -rf` it without `podman unshare`. Creating it up front keeps everything in
|
||||||
|
# the data root owned by {{ podman_user }}.
|
||||||
|
#
|
||||||
|
# Mode 0755 rather than Postgres's 0700: Valkey's entrypoint runs `find` in this
|
||||||
|
# directory as root before dropping to uid {{ app_uid }}, and nothing sensitive
|
||||||
|
# lives here — it is a cache.
|
||||||
|
- name: Ensure the Valkey data directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ host_directory }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ podman_user }}"
|
||||||
|
group: "{{ podman_user }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Ensure the Quadlet drop-in directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ valkey_dropin_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ podman_user }}"
|
||||||
|
group: "{{ podman_user }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Install the base Valkey Quadlet unit
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: valkey.container.j2
|
||||||
|
dest: "{{ quadlet_dir }}/{{ container_name }}.container"
|
||||||
|
owner: "{{ podman_user }}"
|
||||||
|
group: "{{ podman_user }}"
|
||||||
|
mode: '0644'
|
||||||
|
notify:
|
||||||
|
- Reload systemd user units
|
||||||
|
- Restart Valkey
|
||||||
|
|
||||||
|
# The wait is part of the block on purpose. `Restart=always` means systemd
|
||||||
|
# reports the unit active even while the container crash-loops — a wrong keep-id
|
||||||
|
# shows up as an entrypoint permission error over and over — so is-active alone
|
||||||
|
# would call a broken cache healthy, and every app that uses it would fail later
|
||||||
|
# with a DNS or connection error that points nowhere near the cause.
|
||||||
|
- name: Start Valkey and wait for it to answer
|
||||||
|
block:
|
||||||
|
- name: Start Valkey
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: "{{ unit_name }}"
|
||||||
|
scope: user
|
||||||
|
state: started
|
||||||
|
daemon_reload: true
|
||||||
|
|
||||||
|
# valkey-cli is the server's own client and is guaranteed present, the same
|
||||||
|
# way pg_isready is in the Postgres image.
|
||||||
|
- name: Wait for Valkey to answer PING
|
||||||
|
ansible.builtin.command: "podman exec {{ container_name }} valkey-cli ping"
|
||||||
|
register: valkey_ping
|
||||||
|
until: valkey_ping.stdout == 'PONG'
|
||||||
|
retries: 12
|
||||||
|
delay: 5
|
||||||
|
changed_when: false
|
||||||
|
rescue:
|
||||||
|
# systemd reports "control process exited with error code" and nothing else,
|
||||||
|
# which is useless for the usual failures (a bad uid mapping, an unwritable
|
||||||
|
# data directory, a missing image). Pull the real reason out.
|
||||||
|
- name: Collect Valkey diagnostics
|
||||||
|
ansible.builtin.command: "{{ item }}"
|
||||||
|
loop:
|
||||||
|
- "systemctl --user status {{ unit_name }} --no-pager"
|
||||||
|
- "journalctl --user -u {{ unit_name }} --no-pager -n 40"
|
||||||
|
register: valkey_diag
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Show Valkey diagnostics
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "{{ valkey_diag.results | map(attribute='stdout') | list }}"
|
||||||
|
|
||||||
|
- name: Fail
|
||||||
|
ansible.builtin.fail:
|
||||||
|
msg: "Valkey did not come up — see the diagnostics above."
|
||||||
|
|
||||||
|
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
||||||
|
# `systemctl --user enable` is neither needed nor possible on a generated unit.
|
||||||
|
- name: Verify the Valkey unit is active
|
||||||
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
|
register: valkey_active
|
||||||
|
changed_when: false
|
||||||
|
failed_when: valkey_active.stdout != 'active'
|
||||||
|
|
||||||
|
- name: List the services attached to Valkey
|
||||||
|
ansible.builtin.find:
|
||||||
|
paths: "{{ valkey_dropin_dir }}"
|
||||||
|
patterns: "*.conf"
|
||||||
|
register: valkey_dropins
|
||||||
|
|
||||||
|
- name: Report the networks Valkey is attached to
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
podman inspect {{ container_name }}
|
||||||
|
--format '{% raw %}{{ range $k, $v := .NetworkSettings.Networks }}{{ $k }} {{ end }}{% endraw %}'
|
||||||
|
register: valkey_networks_now
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Show attached services
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
Valkey networks:
|
||||||
|
{{ valkey_networks_now.stdout | default('(container not running)') }}.
|
||||||
|
Attached by:
|
||||||
|
{{ valkey_dropins.files | map(attribute='path') | map('basename') | join(', ')
|
||||||
|
| default('(nothing yet)', true) }}.
|
||||||
|
|
||||||
|
# Say this out loud rather than let it be discovered by a failing app: with no
|
||||||
|
# drop-in, Valkey has no Network= at all, so it sits on the default Podman
|
||||||
|
# network and no app network can resolve it.
|
||||||
|
- name: Note that no service has attached yet
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
No service has attached to Valkey. It is on the default Podman network
|
||||||
|
only, and no app can resolve it until a service playbook includes
|
||||||
|
valkey/roles/attach (see readme.md).
|
||||||
|
when: valkey_dropins.matched == 0
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Valkey — rootless Podman Quadlet unit.
|
||||||
|
# Managed by Ansible (valkey/roles/valkey). Do not edit by hand.
|
||||||
|
#
|
||||||
|
# Valkey deliberately does NOT list the app networks it serves. Each service
|
||||||
|
# attaches itself by dropping a file into:
|
||||||
|
#
|
||||||
|
# {{ valkey_dropin_dir }}/
|
||||||
|
#
|
||||||
|
# which Quadlet merges into this unit. Removing a service therefore never means
|
||||||
|
# editing this file. See readme.md, "How services attach themselves".
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Valkey in-memory data store
|
||||||
|
Documentation=https://valkey.io/docs/
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
ContainerName={{ container_name }}
|
||||||
|
Image={{ image }}
|
||||||
|
|
||||||
|
# Map the container's valkey account (uid {{ app_uid }}, gid {{ app_gid }}) onto
|
||||||
|
# {{ podman_user }} on the host, so {{ host_directory }} stays owned by
|
||||||
|
# {{ podman_user }} — owner *and* group — and never by a subuid or a subgid.
|
||||||
|
# The two ids differ because Alpine already has a gid 999; see vars/default.yml.
|
||||||
|
# The image's entrypoint runs as container-root, chowns the owner of anything in
|
||||||
|
# its WORKDIR that is not already valkey's, then drops to
|
||||||
|
# {{ app_uid }}:{{ app_gid }} — so everything it creates lands on
|
||||||
|
# {{ podman_user }} too.
|
||||||
|
UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }}
|
||||||
|
|
||||||
|
# No PublishPort, on purpose. The Docker-era playbook published
|
||||||
|
# 127.0.0.1:6379; here Valkey is reachable only from the app networks that
|
||||||
|
# attach themselves to it, so there is no host port to firewall or to collide
|
||||||
|
# with a second cache.
|
||||||
|
Volume={{ host_directory }}:{{ data_mount }}
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
TimeoutStartSec=120
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
+56
-2
@@ -1,4 +1,58 @@
|
|||||||
---
|
---
|
||||||
container_name: valkey
|
# ---------------------------------------------------------------------------
|
||||||
container_image: valkey/valkey:alpine
|
# Rootless Podman / Quadlet
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that
|
||||||
|
# file wholesale, because it also sets `host_directory` — to the Caddy data
|
||||||
|
# directory — and every playbook defines that for itself. Keep these three in
|
||||||
|
# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start,
|
||||||
|
# belongs to the podman tasks, which this play does not run: Valkey publishes
|
||||||
|
# nothing host-side.)
|
||||||
|
#
|
||||||
|
# podman_user is derived from the connection: this play carries no become, so
|
||||||
|
# the Quadlet units belong to whoever Ansible connects as. Requires gather_facts
|
||||||
|
# (set in playbook.yml).
|
||||||
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Valkey
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
container_name: valkey
|
||||||
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
|
# Fully qualified on purpose: rootless Podman resolves short names against
|
||||||
|
# unqualified-search-registries, and there is no TTY to answer a prompt when the
|
||||||
|
# systemd unit starts. The Docker-era playbook said `valkey/valkey:alpine`.
|
||||||
|
image: docker.io/valkey/valkey:alpine
|
||||||
|
|
||||||
|
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
||||||
|
|
||||||
|
# The image's WORKDIR and VOLUME: the RDB snapshot and, if it is ever enabled,
|
||||||
|
# the AOF live here. The contents are disposable — nothing needs to be copied
|
||||||
|
# off monoco — but the mount is not: RDB persistence is on by default, and the
|
||||||
|
# graceful stop of a rebuild writes a final snapshot here that the next
|
||||||
|
# container loads. Keep it unless you also set `save ""`. See readme.md, "The
|
||||||
|
# trade-off".
|
||||||
|
data_mount: /data
|
||||||
|
|
||||||
|
# The uid and gid of the image's `valkey` account. The uid is 999 — the one the
|
||||||
|
# anonymous Docker volume was owned by (docs/podman-compat-audit.md §3.2) — but
|
||||||
|
# the gid is *1000*, not 999: Alpine already has a gid 999, and the image's
|
||||||
|
# Dockerfile says so outright ("alpine already has a gid 999, so we'll use the
|
||||||
|
# next id") before dropping to the account with `setpriv --regid=valkey`.
|
||||||
|
# Mapping both ids onto podman_user is what keeps {{ host_directory }} — owner
|
||||||
|
# and group alike — with the connection user, instead of stranding the group on
|
||||||
|
# a subgid the moment valkey-server writes a snapshot.
|
||||||
|
app_uid: 999
|
||||||
|
app_gid: 1000
|
||||||
|
|
||||||
|
# No PublishPort, on purpose: the Docker-era playbook published 127.0.0.1:6379,
|
||||||
|
# and nothing on the host needs it. Clients reach Valkey over the app networks
|
||||||
|
# that attach themselves; {{ container_port }} is the port inside the container.
|
||||||
|
container_port: 6379
|
||||||
|
|
||||||
|
# Quadlet drop-in directory for the Valkey unit. Services attach themselves
|
||||||
|
# here; see the "How services attach themselves" section of readme.md.
|
||||||
|
valkey_dropin_dir: "{{ quadlet_dir }}/{{ container_name }}.container.d"
|
||||||
Reference in new issue
Block a user