xfy 7584e0ca1c docs(skills): 新增 deploy-to-xun 部署流程 skill
将本次实战验证的部署流程提取为可复用 skill,涵盖:
- 本地 arm64 用 Docker Rosetta 构建 x86 镜像(绕过 QEMU 跑 rustc 的 SIGSEGV)
- runner 镜像 localhost/ 前缀与 LANGUAGES 注册表硬编码名的 tag 映射
- xun 服务器 fish shell 陷阱(变量赋值/循环/退出码语法差异)
- 复用 nginx-proxy + acme-companion 自动签证的 compose 配置
- podman socket 路径映射与 scratch 镜像健康检查方式
- Code Runner 在 podman 上的 tmpfs 兼容性已知限制

每一步均在本次会话中实战验证通过(14 迁移成功、HTTPS 200、证书签发)。
2026-07-13 19:40:41 +08:00

13 KiB
Raw Blame History

name, description, allowed-tools, metadata
name description allowed-tools metadata
deploy-to-xun 将 yggdrasil 部署/更新到 xun 服务器时使用。本地 arm64 用 Docker Rosetta 构建 x86 镜像,导出传输到 xunPodman后用 docker compose 运行,复用现有 nginx-proxy 自动签发 HTTPS。触发关键词"部署"、"deploy"、"发布到 xun"、"更新 xun"、"上线"。
Bash
Read
Write
Edit
AskUserQuestion
Agent
trigger
部署到 xun、更新线上、Docker 构建 x86 镜像、docker compose 上线

Deploy to Xun: 部署/更新 yggdrasil 到 xun 服务器

从本地 arm64 构建器构建 linux/amd64 镜像,传输到 xunPodman 5.8.2 / Rocky x86_64并以 docker compose 运行。复用现有 nginx-proxy + acme-companion 做前置反代,自动签发 Let's Encrypt 证书。

目标环境速查

服务器别名 xunssh 直接连)
OS / 架构 Rocky Linux x86_64
容器运行时 Podman 5.8.2docker 是 podman 别名rootful
默认登录 shell fish(关键陷阱,见下)
socket 路径 /run/podman/podman.sockroot:root 660不是 /var/run/docker.sock
部署目录 /root/docker/yggdrasil/
前置代理 已有 nginx-proxy + acme-companion,占 80/443
代理网络 nginx-proxybridge容器设 VIRTUAL_HOST 即被自动发现
域名 rua.plusIPv4 已指向 xunacme HTTP-01 可签)
现有占用 mimo-blogxunrua.top、frps 等,勿动

本地构建机要求

  • Apple Silicon arm64 + Docker Desktop
  • 必须开启 RosettaUseVirtualizationFrameworkRosetta: trueDocker Desktop 设置)
    • 检查:grep UseVirtualizationFrameworkRosetta ~/Library/Group\ Containers/group.com.docker/settings-store.json
    • 改完需重启 Docker Desktop
  • 为什么必须 RosettaQEMU 用户态模拟跑 rustc 会 SIGSEGVqemu: uncaught target signal 11Rosetta 转译稳定且快 5-10 倍

fish 陷阱(最重要的约定)

xun 的默认登录 shell 是 fish,不是 bash。通过 ssh xun '...' 执行的命令在 fish 里解析,会撞上:

bash 语法 fish 报错 解决
VAR=$(cmd) Unsupported use of '=' set VAR (cmd),或避免在服务器端赋值
for x in a b; do ...; done Missing end to balance this for loop for x in a b; ...; end,或逐条单独 ssh 执行
echo $? $? is not the exit status $status,或别打印退出码
&& 链带赋值 中途失败静默 拆成多条独立 ssh 命令,每条单独验证

最稳的做法:避免在 ssh 命令里写 shell 逻辑。 每条 ssh xun 'cmd' 只跑一条 podman/compose 命令,结果用 echo 或退出码判断。需要循环时在本地跑 bash循环体内逐条 ssh。

构建(本地 arm64 → linux/amd64

主应用镜像

docker buildx build --platform linux/amd64 --load -t localhost/yggdrasil:latest .
  • Dockerfile 用 dpkg --print-architecture 检测架构amd64 腿原生构建 x86_64-unknown-linux-musl 静态二进制
  • 首次约 15-30 分钟Rosetta 下 cargo 全量编译),有 buildkit 缓存后分钟级
  • 产物 localhost/yggdrasil:latestscratch 运行时层约 16MB

5 个 Code Runner 沙箱镜像

runner Dockerfile FROM yggdrasil-runner-base:latest(无 localhost/ 前缀),所以必须先建 base 再建子镜像

# 1. base 先用 localhost/ 前缀建
docker buildx build --platform linux/amd64 --load \
  -t localhost/yggdrasil-runner-base:latest docker/runner-base
# 2. 再 tag 一个无前缀名,让子镜像 FROM 能解析
docker tag localhost/yggdrasil-runner-base:latest yggdrasil-runner-base:latest
# 3. 4 个子镜像(它们 FROM yggdrasil-runner-base:latest
for img in python node go rust; do
  docker buildx build --platform linux/amd64 --load \
    -t localhost/yggdrasil-runner-$img:latest docker/runner-$img
  docker tag localhost/yggdrasil-runner-$img:latest yggdrasil-runner-$img:latest
done

buildx v0.35 命名 bug-t localhost/yggdrasil-runner-python:latest 有时被解析成 localhost/yggdrasil-runner-pythonatestlatest 拼进名字)。镜像是好的(架构正确),用 docker tag 把乱名改回正确名即可。用 docker image inspect <name> --format '{{.Architecture}}' 确认是 amd64.Manifests 为空(单平台,非 manifest list

构建验证

# 全部 6 个镜像,架构必须是 amd64
for img in yggdrasil yggdrasil-runner-base yggdrasil-runner-python yggdrasil-runner-node yggdrasil-runner-go yggdrasil-runner-rust; do
  docker image inspect localhost/$img:latest --format "$img: {{.Architecture}} manifests={{.Manifests}}"
done

期望:每行 amd64manifests=[]

导出与传输

# 导出(主应用 + runners 分两个 tar
docker save localhost/yggdrasil:latest -o /tmp/yggdrasil-app.tar
docker save \
  localhost/yggdrasil-runner-base:latest \
  localhost/yggdrasil-runner-python:latest \
  localhost/yggdrasil-runner-node:latest \
  localhost/yggdrasil-runner-go:latest \
  localhost/yggdrasil-runner-rust:latest \
  -o /tmp/yggdrasil-runners.tar
gzip -f /tmp/yggdrasil-app.tar /tmp/yggdrasil-runners.tar

# 传输
scp /tmp/yggdrasil-app.tar.gz     xun:/root/docker/yggdrasil/
scp /tmp/yggdrasil-runners.tar.gz xun:/root/docker/yggdrasil/

服务器导入 + runner 去 localhost/ 前缀

ssh xun 'cd /root/docker/yggdrasil && gunzip -kf yggdrasil-app.tar.gz && gunzip -kf yggdrasil-runners.tar.gz'
ssh xun 'docker load -i /root/docker/yggdrasil/yggdrasil-app.tar'
ssh xun 'docker load -i /root/docker/yggdrasil/yggdrasil-runners.tar'

关键runner 去 localhost/ 前缀。 src/api/code_runner/languages.rsLANGUAGES 注册表硬编码镜像名 yggdrasil-runner-python:latest(无前缀、无 env 覆盖。podman 对无前缀名做回退解析,所以必须额外 tag 一份无前缀名。逐条 ssh 执行fish 不认 bash for 循环):

ssh xun 'docker tag localhost/yggdrasil-runner-base:latest yggdrasil-runner-base:latest'
ssh xun 'docker tag localhost/yggdrasil-runner-python:latest yggdrasil-runner-python:latest'
ssh xun 'docker tag localhost/yggdrasil-runner-node:latest yggdrasil-runner-node:latest'
ssh xun 'docker tag localhost/yggdrasil-runner-go:latest yggdrasil-runner-go:latest'
ssh xun 'docker tag localhost/yggdrasil-runner-rust:latest yggdrasil-runner-rust:latest'

验证(podman images --format 显示规范化的完整名,但短名可解析):

ssh xun 'podman inspect yggdrasil-runner-python:latest --format "found {{.Architecture}}"'
# 期望: found amd64

compose 文件

/root/docker/yggdrasil/docker-compose.yml

services:
  postgres:
    image: docker.io/library/postgres:16-alpine
    container_name: yggdrasil-postgres
    restart: always
    expose: ["5432"]
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    networks: [backend]

  app:
    image: localhost/yggdrasil:latest
    container_name: yggdrasil-app
    restart: always
    expose: ["3000"]
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      APP_BASE_URL: https://${VIRTUAL_HOST}
      COOKIE_SECURE: "true"
      TRUSTED_PROXY_COUNT: "1"
      RUST_LOG: info
      DOCKER_SOCKET_PATH: /var/run/docker.sock
      CODE_RUNNER_ALLOW_NETWORK: "false"
      CODE_RUNNER_MAX_CONCURRENT: "2"
      CODE_RUNNER_MAX_CPU_CORES: "1.0"
      CODE_RUNNER_MAX_MEMORY_MB: "512"
      VIRTUAL_HOST: ${VIRTUAL_HOST}
      VIRTUAL_PORT: ${VIRTUAL_PORT}
      LETSENCRYPT_HOST: ${LETSENCRYPT_HOST}
      LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
    volumes:
      - uploads_data:/app/uploads
      - backups_data:/app/backups
      - /run/podman/podman.sock:/var/run/docker.sock
    depends_on:
      postgres:
        condition: service_healthy
    networks: [backend, proxy]

volumes:
  postgres_data:
    name: yggdrasil_postgres_data
  uploads_data:
    name: yggdrasil_uploads_data
  backups_data:
    name: yggdrasil_backups_data

networks:
  backend:
    name: yggdrasil_network
  proxy:
    name: nginx-proxy
    external: true

/root/docker/yggdrasil/.env(密码随机生成,不提交):

# 本地生成后 scp 上去
PG_PWD=$(openssl rand -hex 16)
cat > /tmp/.env <<EOF
POSTGRES_USER=yggdrasil
POSTGRES_PASSWORD=$PG_PWD
POSTGRES_DB=yggdrasil
VIRTUAL_HOST=rua.plus
VIRTUAL_PORT=3000
LETSENCRYPT_HOST=rua.plus
LETSENCRYPT_EMAIL=defect.y@qq.com
EOF
scp /tmp/.env xun:/root/docker/yggdrasil/

关键设计点

配置 为什么
app exposeports 端口由 nginx-proxy 统一接管 80/443app 不直接暴露
proxy 网络 external: true 复用 mimo-blog 同款 nginx-proxy 网络
VIRTUAL_HOST 环境变量 docker-gen 自动生成 vhost + 触发 acme 签证
/run/podman/podman.sock:/var/run/docker.sock podman socket 映射到 app 期望的 docker.sock 路径
DOCKER_SOCKET_PATH=/var/run/docker.sock 告诉 bollard 连容器内的该路径(实际是 podman socket
CODE_RUNNER_MAX_CONCURRENT=2 3.6G 内存服务器,收紧并发避免沙箱压垮宿主
独立 Postgres 容器 + 独立卷 与 mimo-blog 的 blog-postgres 完全隔离

启动

ssh xun 'cd /root/docker/yggdrasil && docker compose --env-file .env config --quiet'  # 先验证语法
ssh xun 'cd /root/docker/yggdrasil && docker compose --env-file .env up -d'

app 启动时自动跑 14 个数据库迁移main.rs 启动钩子,MIGRATE_STARTUP_TIMEOUT_SECS=30),无需手动 migrate。Postgres 角色需 CREATEDBensure_database_exists 会自动建库)。

验证(全部必须通过)

容器内健康scratch 镜像没 wget/curl从 nginx-proxy 容器测)

ssh xun 'docker exec nginx-proxy curl -s -w "\nHTTP %{http_code}\n" http://yggdrasil-app:3000/healthz'
# 期望: {"status":"ok"}  HTTP 200
ssh xun 'docker exec nginx-proxy curl -s http://yggdrasil-app:3000/readyz'
# 期望: {"db":"ok","pool":{...},"status":"ready"}

scratch 镜像陷阱docker exec yggdrasil-app <cmd> 会报 executable file 'wget' not found——scratch 运行时没有任何 shell/工具。健康检查必须从另一个有 curl 的容器(如 nginx-proxy发起或从宿主通过容器 IP。

迁移日志

ssh xun 'docker logs yggdrasil-app 2>&1 | grep -iE "migrat|applied|listen|error|panic"'
# 期望: applying migration 001..014, "successfully applied 14 migration(s)", 无 error/panic

外部 HTTPS从本地 curl

curl -s https://rua.plus/healthz                      # {"status":"ok"}
curl -sI https://rua.plus/ | grep -iE "HTTP/|strict-transport"  # HTTP/2 200, HSTS
curl -sI http://rua.plus/healthz | grep -i location   # 301 -> https
echo | openssl s_client -connect rua.plus:443 -servername rua.plus 2>/dev/null \
  | openssl x509 -noout -issuer -dates                # issuer=Let's Encrypt

首位 admin 注册

浏览器访问 https://rua.plus/register首个注册用户自动成为 adminRegistration: first user becomes admin之后注册被拒

Code Runner 限制(已知)

Code Runner 的 5 个沙箱镜像和 API 已部署就绪,但 src/infra/docker.rs 的 tmpfs 选项 uid=1000,gid=1000 是 docker 扩展podman 报 unknown mount option "uid=1000",实际执行代码会失败。

  • 验证镜像本身可跑:echo 'print("hi")' | ssh xun 'docker run --rm -i --user 1000:1000 --workdir /code yggdrasil-runner-python:latest sh -c "cat > /code/main.py && python -u /code/main.py"'
  • 修复方向:改 docker.rs 的 tmpfs 策略(如镜像内预设 /code 权限,或用 podman 兼容挂载),修后需重新构建主应用镜像并重新部署

清理

ssh xun 'rm -f /root/docker/yggdrasil/yggdrasil-app.tar /root/docker/yggdrasil/yggdrasil-runners.tar*'
rm -f /tmp/yggdrasil-*.tar* /tmp/.env

检查清单

完成前确认:

  • 本地 6 个镜像均 amd64manifests=[]
  • 服务器 runner 无前缀名可解析(podman inspect yggdrasil-runner-python:latest 成功)
  • docker compose up -d 两个容器都 Up
  • 迁移日志出现 successfully applied 14 migration(s)
  • healthz 返回 {"status":"ok"}
  • readyz 返回 {"db":"ok",...}
  • https://rua.plus/ HTTP/2 200
  • 证书 issuer 是 Let's Encrypt
  • 服务器 tar 已清理

更新流程(已部署过,只更新镜像)

# 1. 本地重新构建主应用(有缓存,快)
docker buildx build --platform linux/amd64 --load -t localhost/yggdrasil:latest .
# 2. 导出传输
docker save localhost/yggdrasil:latest | gzip > /tmp/yggdrasil-app.tar.gz
scp /tmp/yggdrasil-app.tar.gz xun:/root/docker/yggdrasil/
# 3. 服务器导入 + 滚动重启
ssh xun 'cd /root/docker/yggdrasil && gunzip -kf yggdrasil-app.tar.gz && docker load -i yggdrasil-app.tar'
ssh xun 'cd /root/docker/yggdrasil && docker compose --env-file .env up -d app'
# 4. 清理 + 验证
ssh xun 'rm -f /root/docker/yggdrasil/yggdrasil-app.tar*'
curl -s https://rua.plus/healthz