diff --git a/.dockerignore b/.dockerignore index d7524e6..58017d8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,3 +16,4 @@ scripts/ README.md Dockerfile docker-compose.yml +.podman-compose.generated.yml diff --git a/.gitignore b/.gitignore index 3a19d4c..061a118 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ data/ mariadb-data/ mariadb_data/ +.podman-compose.generated.yml diff --git a/README.md b/README.md index 788037a..a454f28 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,20 @@ Error: "lab-backend" is not a valid container, cannot be used as a dependency .. ./scripts/podman-deploy.sh diagnose # 部署失败时输出诊断信息(镜像/容器/日志) ``` -脚本做了三件关键的事,规避 podman-compose 的兼容性问题: +脚本做了四件关键的事,规避 podman-compose 的兼容性问题: 1. `podman build` 显式构建 `lab-backend:latest` / `lab-frontend:latest`,避免短名拉取; -2. `up -d --no-deps` 按「数据库 → 后端 → 前端」逐个启动并等待就绪,绕过 compose 依赖查找; -3. `down` 直接按容器名清理,避免对不存在容器的报错噪音。 +2. 启动前自动生成 `.podman-compose.generated.yml`(从 `docker-compose.yml` 中剔除 `depends_on`), + 避免旧版 podman-compose 在 `up` 时等待依赖健康检查而卡住; +3. 按「数据库 → 后端 → 前端」逐个启动并等待就绪(MariaDB 用 `healthcheck.sh`,后端/前端用 HTTP 探测); +4. `down` 直接按容器名清理,避免对不存在容器的报错噪音。 + +**防卡死**:所有外部命令都带超时,任何一步超时会打印原因并退出,不会无限等待。 +可用环境变量调节:`CMD_TIMEOUT`(单条命令,默认 60s)、`BUILD_TIMEOUT`(构建,默认 1800s)、 +`READY_TRIES`(就绪轮询次数,默认 60 次 × 2s);用 `DEBUG=1 ./scripts/podman-deploy.sh up` +可以看到每一步实际执行的命令,定位卡在哪一步。 + +镜像已构建过时可跳过构建:`./scripts/podman-deploy.sh up --no-build`。 若已有历史失败状态,先清理再重来: diff --git a/scripts/podman-deploy.sh b/scripts/podman-deploy.sh index cda9d5b..5a906b8 100755 --- a/scripts/podman-deploy.sh +++ b/scripts/podman-deploy.sh @@ -3,13 +3,19 @@ # 使用 Podman 部署实验室预约平台 # # 用法: -# ./scripts/podman-deploy.sh up # 构建镜像并按 数据库->后端->前端 顺序启动(默认) -# ./scripts/podman-deploy.sh build # 只构建镜像 -# ./scripts/podman-deploy.sh down # 停止并删除容器(保留数据库数据卷) -# ./scripts/podman-deploy.sh down-v # 停止并删除数据目录(重建演示数据) -# ./scripts/podman-deploy.sh status # 查看容器状态 -# ./scripts/podman-deploy.sh logs # 跟随查看日志 -# ./scripts/podman-deploy.sh diagnose # 输出镜像/容器/日志等诊断信息 +# ./scripts/podman-deploy.sh up [--no-build] # 构建镜像并按 数据库->后端->前端 顺序启动 +# ./scripts/podman-deploy.sh build # 只构建镜像 +# ./scripts/podman-deploy.sh down # 停止并删除容器(保留数据库数据目录) +# ./scripts/podman-deploy.sh down-v # 停止并删除容器与数据目录(重建演示数据) +# ./scripts/podman-deploy.sh status # 查看容器状态 +# ./scripts/podman-deploy.sh logs # 跟随查看后端日志 +# ./scripts/podman-deploy.sh diagnose # 输出镜像/容器/日志等诊断信息 +# +# 可选环境变量: +# CMD_TIMEOUT 单条 podman/compose 命令超时秒数(默认 60,防止卡死) +# BUILD_TIMEOUT 镜像构建超时秒数(默认 1800) +# READY_TRIES 等待就绪的轮询次数,每轮 2 秒(默认 60) +# DEBUG=1 输出每一步执行的命令(set -x) # # 背景:Podman 的 compose 实现(podman-compose)与 Docker Compose 在 # 「镜像名解析、depends_on 依赖查找」上存在差异,直接 up 常见报错: @@ -18,38 +24,103 @@ # 本脚本的处理方式: # 1) 先用 podman build 显式构建 lab-backend / lab-frontend 镜像,避免短名拉取; # 2) 再用 `up -d --no-deps` 逐个启动服务并等待就绪,绕过 compose 的依赖查找; -# 3) down 时直接按容器名清理,避免 podman-compose 对不存在容器的报错噪音。 +# 3) down 时直接按容器名清理,避免 podman-compose 对不存在容器的报错噪音; +# 4) 启动前生成去除 depends_on 的临时 compose 文件,避免旧版 podman-compose +# 在 `up` 时等待依赖健康检查而卡死; +# 5) 所有外部命令都带超时,任何一步卡住都会明确报出并退出,不会无限等待。 # ===================================================================== set -euo pipefail +if [ "${DEBUG:-0}" = "1" ]; then set -x; fi + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" cd "${PROJECT_DIR}" +# ---------- 可调参数 ---------- +CMD_TIMEOUT="${CMD_TIMEOUT:-60}" +BUILD_TIMEOUT="${BUILD_TIMEOUT:-1800}" +READY_TRIES="${READY_TRIES:-60}" + # ---------- 服务/容器名 ---------- MARIADB_CONTAINER="lab-mariadb" BACKEND_CONTAINER="lab-backend" FRONTEND_CONTAINER="lab-frontend" NETWORK_NAME="lab-reservation_lab-net" +# ---------- 日志与错误追踪 ---------- +log() { + printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" +} + +# 出错时给出失败信息,便于定位卡住/失败的位置 +on_error() { + local exit_code=$? + log "执行失败(退出码 ${exit_code}),出错位置约在脚本第 ${1:-?} 行" + log "可执行 '$0 diagnose' 查看容器与日志,或 DEBUG=1 $0 up 观察每一步命令" + exit "${exit_code}" +} +trap 'on_error ${LINENO}' ERR + +# ---------- 带超时执行命令 ---------- +# 说明:macOS 默认没有 timeout 命令,这里提供纯 bash 兜底实现。 +# 返回 124 表示超时。 +run_with_timeout() { + local seconds="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout "${seconds}" "$@" + return $? + fi + if command -v gtimeout >/dev/null 2>&1; then + gtimeout "${seconds}" "$@" + return $? + fi + "$@" & + local pid=$! + local waited=0 + while kill -0 "${pid}" 2>/dev/null; do + if [ "${waited}" -ge "${seconds}" ]; then + kill -TERM "${pid}" 2>/dev/null || true + sleep 2 + kill -KILL "${pid}" 2>/dev/null || true + wait "${pid}" 2>/dev/null || true + return 124 + fi + sleep 1 + waited=$((waited + 1)) + done + wait "${pid}" +} + # ---------- 检查 podman ---------- if ! command -v podman >/dev/null 2>&1; then - echo "错误:未找到 podman,请先安装:https://podman.io/docs/installation" >&2 + log "错误:未找到 podman,请先安装:https://podman.io/docs/installation" exit 1 fi # ---------- 选择可用的 compose 命令 ---------- -if podman compose version >/dev/null 2>&1; then - COMPOSE=(podman compose) -elif command -v podman-compose >/dev/null 2>&1; then - COMPOSE=(podman-compose) -else - echo "错误:未找到 podman compose / podman-compose,请安装 podman-compose:" >&2 - echo " pipx install podman-compose 或 pip3 install --user podman-compose" >&2 +COMPOSE=() +COMPOSE_FILE_ARGS=() +compose_available() { + if run_with_timeout 30 podman compose version >/dev/null 2>&1; then + COMPOSE=(podman compose) + return 0 + fi + if command -v podman-compose >/dev/null 2>&1; then + COMPOSE=(podman-compose) + return 0 + fi + return 1 +} +if ! compose_available; then + log "错误:未找到可用的 podman compose / podman-compose,请安装 podman-compose:" + log " pipx install podman-compose 或 pip3 install --user podman-compose" exit 1 fi +log "使用 compose 命令:${COMPOSE[*]}" -# ---------- 读取 .env 端口配置(不 source,避免值中含空格被当作命令执行) ---------- +# ---------- 读取 .env 配置(不 source,避免值中含空格被当作命令执行) ---------- read_env() { if [ -f .env ]; then grep -E "^$1=" .env 2>/dev/null | tail -n 1 | cut -d= -f2- | tr -d '"' || true @@ -65,93 +136,131 @@ DATA_DIR="${DATA_DIR%/}" # ---------- 通用检查函数(inspect 在 podman / docker 下语义一致) ---------- image_exists() { - podman image inspect "$1" >/dev/null 2>&1 + run_with_timeout "${CMD_TIMEOUT}" podman image inspect "$1" >/dev/null 2>&1 } container_exists() { - podman inspect "$1" >/dev/null 2>&1 + run_with_timeout "${CMD_TIMEOUT}" podman inspect "$1" >/dev/null 2>&1 } # ---------- 构建镜像 ---------- build_images() { - echo "==> 构建后端镜像 lab-backend:latest(首次构建需拉取 Maven 依赖,可能较慢)" - podman build -t lab-backend:latest . - echo "==> 构建前端镜像 lab-frontend:latest" - podman build -t lab-frontend:latest ./frontend - if ! image_exists lab-backend:latest || ! image_exists lab-frontend:latest; then - echo "错误:镜像构建失败,请查看上方构建日志" >&2 + log "构建后端镜像 lab-backend:latest(首次构建需拉取 Maven 依赖,可能较慢)" + if ! run_with_timeout "${BUILD_TIMEOUT}" podman build -t lab-backend:latest .; then + log "错误:后端镜像构建失败或超时(BUILD_TIMEOUT=${BUILD_TIMEOUT}s)" exit 1 fi - echo "==> 镜像构建完成" + log "构建前端镜像 lab-frontend:latest" + if ! run_with_timeout "${BUILD_TIMEOUT}" podman build -t lab-frontend:latest ./frontend; then + log "错误:前端镜像构建失败或超时(BUILD_TIMEOUT=${BUILD_TIMEOUT}s)" + exit 1 + fi + if ! image_exists lab-backend:latest || ! image_exists lab-frontend:latest; then + log "错误:镜像未生成,请查看上方构建日志" + exit 1 + fi + log "镜像构建完成" } -# ---------- 启动单个服务(--no-deps 绕过 compose 依赖查找,失败则回退) ---------- +# ---------- 生成 Podman 专用 compose 文件 ---------- +# podman-compose 在 `up` 处理 depends_on 的 service_healthy 条件时可能长时间阻塞, +# 这里把 depends_on 段落剔除生成临时文件(服务启动顺序由脚本自己控制), +# 既保留 docker-compose.yml 作为唯一配置源,又规避 podman-compose 的兼容问题。 +GENERATED_COMPOSE=".podman-compose.generated.yml" +prepare_compose_file() { + local base="docker-compose.yml" + if [ ! -f "${base}" ]; then + log "错误:未找到 ${base}" + exit 1 + fi + awk ' + /^ depends_on:[[:space:]]*$/ { skip=1; next } + skip && /^ / { next } + { skip=0; print } + ' "${base}" > "${GENERATED_COMPOSE}" + if ! grep -q "container_name: ${BACKEND_CONTAINER}" "${GENERATED_COMPOSE}"; then + log "错误:生成 ${GENERATED_COMPOSE} 失败,请检查 ${base} 格式" + exit 1 + fi + log "已生成 ${GENERATED_COMPOSE}(已移除 depends_on,规避 podman-compose 依赖等待)" + COMPOSE_FILE_ARGS=(-f "${GENERATED_COMPOSE}") +} + +# ---------- 启动单个服务 ---------- start_service() { local service="$1" - if ! "${COMPOSE[@]}" up -d --no-deps "${service}" 2>/dev/null; then - "${COMPOSE[@]}" up -d "${service}" - fi + log "启动服务:${service}" + run_with_timeout "${CMD_TIMEOUT}" "${COMPOSE[@]}" "${COMPOSE_FILE_ARGS[@]}" up -d "${service}" } # ---------- 等待 MySQL/MariaDB 就绪 ---------- wait_mariadb() { - local tries=60 - echo " 等待 MariaDB 就绪..." - for _ in $(seq 1 "${tries}"); do - if podman exec "${MARIADB_CONTAINER}" healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; then - echo " MariaDB 已就绪" + local tries="${READY_TRIES}" waited=0 + log "等待 MariaDB 就绪(最多 $((tries * 2)) 秒)..." + while [ "${waited}" -lt "${tries}" ]; do + if run_with_timeout 15 podman exec "${MARIADB_CONTAINER}" \ + healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; then + log "MariaDB 已就绪" return 0 fi sleep 2 + waited=$((waited + 1)) done - echo " 警告:MariaDB 未在预期时间内就绪" >&2 + log "警告:MariaDB 未在预期时间内就绪,继续尝试启动后续服务" return 1 } # ---------- 等待 HTTP 服务响应 ---------- wait_http() { - local name="$1" url="$2" tries="${3:-60}" code - echo " 等待 ${name} 响应..." - for _ in $(seq 1 "${tries}"); do - code="$(curl -s -o /dev/null -w '%{http_code}' "${url}" || true)" + local name="$1" url="$2" tries="${3:-${READY_TRIES}}" waited=0 code + log "等待 ${name} 响应(最多 $((tries * 2)) 秒):${url}" + while [ "${waited}" -lt "${tries}" ]; do + code="$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 3 --max-time 8 "${url}" || true)" if [ -n "${code}" ] && [ "${code}" != "000" ]; then - echo " ${name} 已就绪:${url}(HTTP ${code})" + log "${name} 已就绪:${url}(HTTP ${code})" return 0 fi sleep 2 + waited=$((waited + 1)) done - echo " 警告:${name} 未在预期时间内响应:${url}" >&2 + log "警告:${name} 未在预期时间内响应:${url}" return 1 } # ---------- 打印部署结果 ---------- print_result() { - echo - echo "部署完成:" + log "部署完成:" echo " 前端地址:http://localhost:${WEB_PORT}" echo " 后端地址:http://localhost:${BACKEND_PORT}" echo " 演示账号:admin/admin123、teacher1/123456、student1/123456" echo - podman ps --filter "name=lab-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + show_status +} + +# ---------- 查看状态 ---------- +show_status() { + run_with_timeout "${CMD_TIMEOUT}" podman ps -a --filter "name=lab-" \ + --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" } # ---------- 诊断信息 ---------- diagnose() { - echo "== podman 版本 ==" - podman version --format '{{.Client.Version}} (server {{.Server.Version}})' 2>/dev/null || podman --version + log "== podman 版本 ==" + run_with_timeout "${CMD_TIMEOUT}" podman version 2>&1 | head -5 || podman --version echo - echo "== compose 命令 ==" + log "== compose 命令 ==" echo "${COMPOSE[*]}" echo - echo "== 相关镜像 ==" - podman images --filter "reference=lab-*" --format "table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Created}}" + log "== 相关镜像 ==" + run_with_timeout "${CMD_TIMEOUT}" podman images --format \ + "table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Created}}" 2>/dev/null | grep -E "lab-|REPOSITORY" || true echo - echo "== 相关容器 ==" - podman ps -a --filter "name=lab-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + log "== 相关容器 ==" + show_status || true echo for container in "${MARIADB_CONTAINER}" "${BACKEND_CONTAINER}" "${FRONTEND_CONTAINER}"; do if container_exists "${container}"; then - echo "== ${container} 最近日志 ==" - podman logs --tail 30 "${container}" 2>&1 || true + log "== ${container} 最近日志 ==" + run_with_timeout "${CMD_TIMEOUT}" podman logs --tail 30 "${container}" 2>&1 || true echo fi done @@ -160,12 +269,17 @@ diagnose() { # ---------- 停止并清理 ---------- down() { # 静默调用 compose down(不存在容器时的报错噪音直接忽略),再按容器名兜底清理 - "${COMPOSE[@]}" down >/dev/null 2>&1 || true + local compose_args=() + if [ -f "${GENERATED_COMPOSE}" ]; then + compose_args=(-f "${GENERATED_COMPOSE}") + fi + run_with_timeout "${CMD_TIMEOUT}" "${COMPOSE[@]}" "${compose_args[@]}" down >/dev/null 2>&1 || true for container in "${FRONTEND_CONTAINER}" "${BACKEND_CONTAINER}" "${MARIADB_CONTAINER}"; do - podman rm -f "${container}" >/dev/null 2>&1 || true + run_with_timeout "${CMD_TIMEOUT}" podman rm -f "${container}" >/dev/null 2>&1 || true done - podman network rm "${NETWORK_NAME}" >/dev/null 2>&1 || true - echo "已停止并删除容器(数据库数据卷保留)" + run_with_timeout "${CMD_TIMEOUT}" podman network rm "${NETWORK_NAME}" >/dev/null 2>&1 || true + rm -f "${GENERATED_COMPOSE}" + log "已停止并删除容器(数据库数据目录保留)" } down_volumes() { @@ -173,10 +287,11 @@ down_volumes() { # 兼容历史配置:清理名为 *mariadb-data 的命名卷 local volumes - volumes="$(podman volume ls --format '{{.Name}}' 2>/dev/null | grep -E 'mariadb-data$' || true)" + volumes="$(run_with_timeout "${CMD_TIMEOUT}" podman volume ls --format '{{.Name}}' 2>/dev/null \ + | grep -E 'mariadb-data$' || true)" if [ -n "${volumes}" ]; then echo "${volumes}" | while read -r volume; do - podman volume rm -f "${volume}" >/dev/null 2>&1 || true + run_with_timeout "${CMD_TIMEOUT}" podman volume rm -f "${volume}" >/dev/null 2>&1 || true done fi @@ -187,38 +302,60 @@ down_volumes() { "${PROJECT_DIR}"/*) if [ -d "${absolute_dir}" ]; then rm -rf "${absolute_dir}" - echo "已删除数据库数据目录 ${absolute_dir},下次启动将重建演示数据" + log "已删除数据库数据目录 ${absolute_dir},下次启动将重建演示数据" fi ;; "") - echo "提示:数据目录 ${DATA_DIR} 无法解析,请手动检查" + log "提示:数据目录 ${DATA_DIR} 无法解析,请手动检查" ;; *) - echo "提示:数据目录 ${DATA_DIR} 不在项目目录内,出于安全考虑未自动删除,请手动处理" + log "提示:数据目录 ${DATA_DIR} 不在项目目录内,出于安全考虑未自动删除,请手动处理" ;; esac } +# ---------- 启动流程 ---------- +up() { + local skip_build="${1:-}" + if [ "${skip_build}" != "--no-build" ]; then + build_images + else + log "跳过镜像构建(--no-build)" + if ! image_exists lab-backend:latest || ! image_exists lab-frontend:latest; then + log "错误:本地缺少 lab-backend / lab-frontend 镜像,请先执行 '$0 build'" + exit 1 + fi + fi + + prepare_compose_file + + log "1/3 启动数据库" + start_service mariadb + wait_mariadb || true + + log "2/3 启动后端" + start_service backend + if ! wait_http "后端" "http://localhost:${BACKEND_PORT}/api/labs"; then + log "后端未就绪,输出诊断信息:" + diagnose + exit 1 + fi + + log "3/3 启动前端" + start_service frontend + if ! wait_http "前端" "http://localhost:${WEB_PORT}/"; then + log "前端未就绪,输出诊断信息:" + diagnose + exit 1 + fi + + print_result +} + ACTION="${1:-up}" case "${ACTION}" in up) - build_images - echo "==> 1/3 启动数据库" - start_service mariadb - wait_mariadb || true - echo "==> 2/3 启动后端" - start_service backend - if ! wait_http "后端" "http://localhost:${BACKEND_PORT}/api/labs" 60; then - diagnose - exit 1 - fi - echo "==> 3/3 启动前端" - start_service frontend - if ! wait_http "前端" "http://localhost:${WEB_PORT}/" 60; then - diagnose - exit 1 - fi - print_result + up "${2:-}" ;; build) build_images @@ -230,16 +367,17 @@ case "${ACTION}" in down_volumes ;; status) - podman ps -a --filter "name=lab-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + show_status ;; logs) + log "按 Ctrl+C 退出日志跟随" podman logs -f --tail=100 "${BACKEND_CONTAINER}" ;; diagnose) diagnose ;; *) - echo "用法:$0 [up|build|down|down-v|status|logs|diagnose]" >&2 + echo "用法:$0 [up [--no-build]|build|down|down-v|status|logs|diagnose]" >&2 exit 1 ;; esac