
如果你使用 DeepSeek、元宝 AI、Copilot、ChatGPT 等 AI 工具,协助配置轩辕镜像、编写 docker pull 命令、修改 Docker Compose 镜像地址、配置镜像加速、排查镜像拉取失败、分析报错日志等问题,请先让 AI 阅读并遵守轩辕镜像的规则文档。
只需在 AI 对话中先发送下面这段话即可:
请先阅读并遵守:https://xuanyuan.cloud/agents.md
未读文档前不要生成 pull 命令或排错方案。查看 agents.md 用法指南与完整示范。国内用户首推 元宝 AI、DeepSeek 的深度思考模式,不推荐豆包 AI;Cursor 等编辑器可在对话 @ 该链接,或加入 User Rules。 若 AI 无法访问外链,可 打开说明文档 复制全文粘贴。文档会随站点更新,复制内容可能过期,建议定期检查。
A multisite Drupal install on this cluster, built on the https://hub.docker.com/r/wodby/drupal base image. One pod (php-fpm + nginx sidecars) serves every site; the multisite router (web/sites/sites.php) maps host → site at request time.
PR → merge → publish → deploy, ref-driven images (branch ⇒ :latest, version
tag ⇒ :vX.Y.Z), with a changelog gate on every PR. Full flow in
CONTRIBUTING.md; agent-focused summary in AGENTS.md;
release notes in CHANGELOG.md.
A custom image (kubed/drupal:11) is built on top of wodby/drupal:11. The build process:
hooks/, src/, and templates/ into the image (/hooks, /src, /templates)chmod +x /hooks/*.sh/hooks/install.sh as the wodby user — installs contrib modules via ComposerBuilt and pushed by .github/workflows/image.yml. Bumping newTag in components/base/kustomization.yaml is what triggers the install Job to roll over.
Local build: docker-compose.yaml at the repo root lets you build and smoke-test the image locally (docker compose build). The multisite rendering pipeline (src/render-config.php) requires /sites/*.yaml files from a Kubernetes Secret, so only image build and basic container checks work without the cluster.
. ├── sites/<id>/ per-site YAML bundles (one folder per site) ├── components/ kustomize components — pure K8s wiring ├── hooks/ lifecycle shell scripts baked into the image ├── src/ PHP scripts baked into /src in the image │ ├── merge-sites.php flat-merges component fragments → one YAML per site in /tmp/sites │ ├── render-config.php renders settings.local.php from /tmp/sites/*.yaml (merged) │ ├── restore-guard.php guards DB restore against re-import (exits 1 if tables already exist) │ ├── setup-site.php drush site:install + bootstrap, per site │ ├── site-fields.php shell-friendly YAML reader used by hooks in place of yq │ └── setup/ install sub-scripts (modules, admin, mcp, keycloak, …) └── templates/ Twig partials consumed by render-config.php
The app boots in three phases — an init container (config render), a one-shot install Job (site install + setup), and the main container (php-fpm). See the Hooks table below for which script runs in each phase.
| Script | When | Purpose |
|---|---|---|
install.sh | Docker build | composer require for every contrib module |
before-start.sh | Init container | php /src/merge-sites.php then php /src/render-config.php |
after-start.sh | postStart | drush cache:rebuild |
on-create.sh | Install Job | restore-handoff + per-site setup-site.php loop |
cron.sh | Daily CronJob | Drupal cron + DB dump for backups |
restore.sh | Install Job (when restore component is on) | gpg-decrypt + psql import a backup |
Pure K8s wiring — every component is optional unless marked (required):
| Component | Purpose |
|---|---|
base (required) | Deployment skeleton + drupal-env ConfigMap (PHP_DATE_TIMEZONE only) |
service-account (required) | LDAP user, GCP service account, k8s ServiceAccount, generated API token merged into drupal-ldap-creds |
db (required) | Postgres database + role (postgresql.kubed.io), DB_HOST/NAME/DRIVER env, DB_USER/PASSWORD from secretKeyRef |
redis | Cache backend env (REDIS_HOST/PORT/DB); per-site cache_prefix is generator-owned |
keycloak | OpenidClient CRD only — runtime config lives in each site's YAML |
lifecycle (required) | One-shot install Job + per-site setup-site.php runner; init container for the renderer |
expose (required) | nginx sidecar + Service |
ingress | Ingress + cert-manager Certificate |
storage/nfs | NFS-backed /mnt/uploads volume + DRUPAL_FILE_PRIVATE_PATH=/mnt/uploads |
storage/pvc | Local-path PVC /mnt/uploads + DRUPAL_FILE_PRIVATE_PATH=/mnt/uploads (mutually exclusive with storage/nfs) |
backups | Daily encrypted DB dump → GCS via rclone |
restore | One-shot DB restore from the backups bucket. Comment out after restoring. |
config | Debug-only ConfigMap mount of hooks/ + src/ + templates/ for live iteration |
Every site is a folder under sites/. Adding a site = adding one folder and one line in kustomization.yaml.
sites/<id>/ contains these files (not all are required for every site type):
sites/<id>/ ├── drupal.yaml the site's full config — template with {{ .field }} placeholders ├── 1pass.yaml (optional) ExternalSecret staging 1Password values into drupal-site-<id>-secrets └── kustomization.yaml a Kustomize Component; contributes drupal.yaml's body into the shared ExternalSecret (drupal-site-default, defined in components/base) via a configMapGenerator + replacement that patches it into spec.target.template.data."<id>.yaml"
At cluster apply time the flow is:
1Password "OpenAI Key/credential" ──(1pass.yaml)──► drupal-site-<id>-secrets (per-site Secret) │ drupal-ldap-creds, drupal-gcp-creds, drupal-keycloak-client │ │ │ └──────(shared ExternalSecret drupal-site-default)┴──► template renders drupal.yaml (components/base/external-secret.yaml) │ ▼ drupal-sites Secret (key: "<id>.yaml") — shared across all sites │ ▼ Pod mounts drupal-sites at /sites on the init container │ ▼ merge-sites.php → /tmp/sites/<id>.yaml (merged) │ ▼ render-config.php → web/sites/<id>/settings.local.php
drupal-sites is one shared Secret with one data key per site (default.yaml, bikes.yaml, …). Each site's Kustomize Component patches its YAML body into the single shared drupal-site-default ExternalSecret (defined in components/base) via a configMapGenerator + replacement (create: true).
Three patterns exist in sites/:
| Site | Pattern | Key traits |
|---|---|---|
default | Base instance | The Drupal install that runs the server. Every other site merges on top of it via merge-sites.php. Has its own Postgres DB and is the root of all storage/redis/mail cascades. |
kubed | Full instance | class: instance (default when class is omitted). Gets its own Postgres database and Redis cache prefix; setup-site.php runs a separate drush site:install. Use sites/kubed/ as a working reference for adding a new independent site. |
bikes | Domain subsite | class: subsite. Uses the Domain Access module. Shares default's database, users, and config tree — no separate drush site:install. Requires domain.enabled: true on the parent. Differentiates only by hostname and per-domain config overrides (site:, theme:, domain:). |
drupal.yamlTop-level keys the generator + setup scripts understand:
| Key | Notes |
|---|---|
id, host | Required. The id is used as the Postgres database name (for non-default sites, unless database.name overrides it) and the redis cache_prefix base. |
account.{name,pass,mail,api_token} | Drupal admin user. Templated from drupal-ldap-creds. |
reverse_proxy.enabled | Whether to emit the reverse-proxy section in settings.local.php. |
redis.enabled | Install the redis module + emit the cache_prefix. Connection details come from env. |
mail.{enabled,host,port,from,username,password} | Symfony Mailer SMTP config. |
storage.s3.{enabled,bucket,region,endpoint,access_key,secret_key,root,path} | Flysystem S3. root defaults to /; path defaults to /<id>/public. Both cascade from the default site. |
storage.ftp.{enabled,host,port,username,password,root,path,passive,ssl} | Flysystem FTP. Same cascade contract. |
storage.webdav.{enabled,url} | Flysystem WebDAV. Marked as broken upstream — leave disabled. |
storage.default_scheme | Override Drupal's default file scheme (public). |
keycloak.{enabled,client_id,client_secret,base_url,realm,always_save_userinfo,override_registration_settings} | OIDC client wiring. |
ai.{enabled,providers.<name>.{enabled,api_key}} | AI module + per-provider modules + Key entities. |
ckeditor.{enabled,plugins.<name>} | CKEditor 5 plugin pack and per-plugin sub-modules. |
Generator-owned (cannot be set from YAML):
db — connection comes from DB_* env vars; non-default sites use their own database (named from database.name in the YAML, falling back to the site id) and the default site rides wodby's settings.php.redis.{host,port,db,prefix} — connection comes from REDIS_* env vars; prefix is always <id>_.storage.private_path — always ${DRUPAL_FILE_PRIVATE_PATH:-/mnt/files}/<id>/private. Per-site tenancy inside one pod-level volume.trusted_host_patterns — built from every site's host so any pod can serve any site.storage.s3 and storage.ftp cascade every field except enabled from sites/default/drupal.yaml into every other site's YAML. The practical effect: a non-default site can enable s3+ftp with just
yamlstorage: s3: { enabled: true } ftp: { enabled: true }
and inherit credentials, host, region, bucket, root from default. The renderer computes the per-site prefix (s3) / root (ftp) as <cascaded root>/<id>/public. Override path: on a site to opt out of the default /<id>/public template.
A working reference for a full instance is sites/kubed/.
Before you deploy for the first time: drupal-sites must exist in the cloud namespace. Bootstrap once:
shkubectl create secret generic drupal-sites -n cloud
This is deliberately not managed by Kustomize — see kustomization.yaml for why.
Walkthrough — let's say you want to add kubed.kellyferrone.com:
Create the folder under sites/:
sites/kubed/
sites/kubed/drupal.yaml — start by copying sites/default/drupal.yaml, then:
id: kubed, host: kubed.kellyferrone.com.storage.s3 / storage.ftp blocks).db, redis.host/port/db/prefix, storage.private_path).enabled: toggles — those are per-site, not cascaded.sites/kubed/1pass.yaml — stages 1Password values into drupal-site-kubed-secrets. Same shape as sites/default/1pass.yaml, but point at this site's items in the homelab vault. Omit entirely if the new site has no 1Password-sourced secrets.
sites/kubed/kustomization.yaml — a Kustomize Component containing a configMapGenerator + replacement. The replacement patches kubed.yaml into the shared drupal-site-default ExternalSecret's spec.target.template.data (using create: true). Two name changes from the default copy: the configMapGenerator name becomes drupal-site-kubed-tpl and the data key becomes kubed.yaml instead of default.yaml. If the new site has its own 1password secrets, include 1pass.yaml in resources:.
Add the line in kustomization.yaml:
yamlcomponents: - sites/default - sites/kubed # ← here
Deploy:
shkubectl up apps/drupal
The shared ExternalSecret will gain a kubed.yaml key in drupal-sites. The next pod cycle's init container will merge and render web/sites/kubed/settings.local.php, and the install Job will run setup-site.php /tmp/sites/kubed.yaml, which runs drush site:install against the kubed Postgres database.
Point DNS at the cluster for kubed.kellyferrone.com (or however your ingress resolution works). Drupal will pick the site by host via web/sites/sites.php.
Shared cluster Postgres. The db component provisions the database + role via postgresql.kubed.io CRDs. Credentials come from drupal-ldap-creds. Per-site isolation is by separate Postgres databases: the default site uses the database provisioned for the shared drupal role; every other site gets its own database named after the site id (declared via the database.name key in the site's YAML, falling back to the site id). Drupal/drush only safely support the public schema (drush site:install / sql:drop target public regardless of any schema setting — drupal.org #***), so each site lives in the public schema of its own database rather than a shared database with per-site schemas.
See: apps/postgresql
Cluster Redis, DB index 4. The redis component sets REDIS_HOST/PORT/DB env. The renderer wires $settings['redis.connection'] for non-default sites; on the default site wodby's image-baked settings.php already does that, and the renderer only stamps $settings['cache_prefix'] = '<id>_'. The shared DB index is safe for multi-tenant because every key is prefixed.
redis.data:6379See: apps/redis
The keycloak component provisions the OpenidClient CRD only. Runtime config (base URL, realm, client_id, client_secret, prompt, registration overrides) all lives in each site's drupal.yaml keycloak: block. The client_secret is templated in via the drupal-keycloak-client ESO extract. setup/keycloak.php creates the Drupal openid_connect_client config entity on first install; subsequent runs are idempotent.
See: apps/keycloak, modules/keycloak
The service-account component provisions a Drupal LDAP user, an OpenLDAP Entry, a Kubernetes ServiceAccount (no token mounted), a GCP service account (drupal-gcp-creds — backups + s3 HMAC), and an ESO-generated random API token merged into drupal-ldap-creds.api_token. The LDAP credential is reused for: Postgres login, SMTP submission, FTP backend, and the Drupal admin password.
See: https://github.com/kubed-io/openldap
Per-site mail: block enables symfony_mailer and writes the SMTP transport config to settings.local.php. The SMTP password comes from drupal-ldap-creds.password via the YAML template.
docker-mailserver.connect:587mail.fromSee: apps/mailserver
Storage has two independent axes:
private:// — the pod-level volume mount (storage/nfs or storage/pvc) and a per-site subdirectory <base>/<id>/private that the renderer creates at boot.public:// — flysystem stream wrappers configured per-site under storage.s3 / storage.ftp / storage.webdav. Choose any combination per site; each one lands at <root>/<id>/public by default.The bucket / share / WebDAV endpoint is shared across sites; per-site tenancy is purely in the subpath.
Enable in a site's YAML:
yamlai: enabled: true providers: openai: { enabled: true, api_key: '{{ .openai_api_key }}' }
The api_key template variable resolves from drupal-site-<id>-secrets (which 1pass.yaml populates from a 1Password item). setup/modules.php installs key, ai, ai_agents, mcp plus the per-provider plugin. setup/mcp.php wires token auth using drupal-ldap-creds.api_token and enables the content, jsonapi, ai_function_calling, ai_agent_calling MCP plugins.
The MCP endpoint is at /mcp/post on each site's host. Per-site admin UI lives at /admin/config/mcp.
Vector search (RAG)
Optional semantic search over content, for retrieval-augmented generation. Disabled by default. Enable per-site under ai.vector:
yamlai: enabled: true vector: enabled: true schema: vector # pgvector tables live in this schema of the Drupal DB metric: cosine # right metric for OpenAI (normalised) embeddings dimensions: 1536 # must match the embeddings model (3-small=1536, 3-large=3072) server: ai_vector # search_api server machine name index: content # search_api index machine name indexed_bundles: - node:article
Stack: ai_search (bundled with drupal/ai) → Search API → the ai_vdb_provider_postgres VDB provider → Postgres + pgvector. Embeddings use whatever ai.settings has wired for the embeddings operation (the renderer/setup do not duplicate the model choice).
setup/modules.php enables search_api, ai_search, ai_vdb_provider_postgres when ai.vector.enabled.setup/ai-search.php configures the VDB connection (reusing the Drupal DB_* env), then creates the Search API server + index. Idempotent.drush search-api:index content.Prerequisite — pgvector in the Postgres image. The vector extension must exist in the database before any of this works. The default cluster Postgres image does not ship it. Steps:
pgvector/pgvector:pg17) — a Server-CRD change.schemas + extensions blocks in components/db/db.yaml so the vector extension is created.ai.vector.enabled: true and redeploy.The
ai_vdb_provider_postgresconnection keys written bysetup/ai-search.phpare best-effort; verify againstdrush cget ai_vdb_provider_postgres.settingson first install and reconcile if they differ (the module is experimental).
Retrieve the API token for a site:
shkubectl get secret drupal-ldap-creds -n cloud -o jsonpath='{.data.api_token}' | base64 -d
MCP client auth
The mcp module's auth provider only accepts the HTTP Basic scheme. After base64-decoding the credential it branches on whether the result contains a colon: user:pass → Basic auth, no colon → the decoded value is treated as the raw API token. So token auth is Basic + base64 of the bare token — no username: prefix, no colon, no trailing newline (Bearer is rejected outright).
Format the token (from $API_TOKEN) for the Authorization header:
shprintf '%s' "$API_TOKEN" | base64
Use it in an MCP client (e.g. .mcp.json):
json"drupal": { "url": "https://drupal.kellyferrone.com/mcp/post", "type": "http", "headers": { "Authorization": "Basic <base64-of-bare-token>" } }
backups runs daily at 03:00. The Drupal container runs as an init container (cron.sh: drush cron + DB dump to /mnt/files/backups/drupal.sql — the whole DB, all schemas), then the rclone container GPG-encrypts and uploads to gcs:backups.kellyferrone.com/drupal/<timestamp>.sql.gz.gpg. GCP creds from drupal-gcp-creds; GPG public key + email from drupal-gpg (ESO-pulled from gcpsm). Public file assets land in their backends natively (s3 → GCS, ftp → NAS); the NFS/PVC private volume is your responsibility to back up separately.
Comment in components/restore in kustomization.yaml. On the next install Job run, an rclone init container pulls the latest backup (or the object named by FILENAME in components/restore/conf.env), gpg-decrypts + untars it, and stages /mnt/files/backups/drupal.sql. on-create.sh detects the file and hands off to restore.sh before the per-site setup-site.php loop. Comment the component back out after a successful restore.
sh# drush against the live default site ./app.sh drush status # tail php-fpm logs ./app.sh logs # shell into the pod ./app.sh shell # re-run the install Job (deletes the prior Job so it rolls over on next deploy) kubectl delete job -n cloud drupal-install kubectl up apps/drupal
Composer patches live in patches/ and are applied at image build
time by hooks/install.sh via cweagans/composer-patches.
content_entity_seeder MCP tool stored multi-property field values (body/text) as the literal string Array because it unwrapped only one level of the doubly-nested values context. Upstream: https://www.drupal.org/project/ai_agents/issues/3586031. Drop the patch once a release ships the fix.您可以使用以下命令拉取该镜像。请将 <标签> 替换为具体的标签版本。如需查看所有可用标签版本,请访问 标签列表页面。
来自真实用户的反馈,见证轩辕镜像的优质服务
以下是 kubed/drupal 相关的常用 Docker 镜像,适用于 不同场景 等不同场景: