# 공통 함수 — 번들의 모든 스크립트가 source 한다. 직접 실행하지 않는다. # shellcheck shell=bash set -euo pipefail DEPLOY_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" IMAGES_DIR="${IMAGES_DIR:-${DEPLOY_DIR}/images}" ENV_FILE="${ENV_FILE:-${DEPLOY_DIR}/.env}" # 색 없는 로그 — 폐쇄망 콘솔·로그 수집기에서 그대로 읽힌다. log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; } warn() { printf '[%s] WARN %s\n' "$(date +%H:%M:%S)" "$*" >&2; } die() { printf '[%s] FATAL %s\n' "$(date +%H:%M:%S)" "$*" >&2; exit 1; } # ── 컨테이너 엔진 ─────────────────────────────────────────────────────────── # ENGINE 으로 덮어쓸 수 있다. 권한 때문에 sudo 가 필요하면 ENGINE="sudo podman" 처럼 준다. # ENGINE_KIND 는 docker | podman 중 하나로 정규화된다. resolve_engine() { local want="${1:-${ENGINE_KIND:-}}" if [ -n "${ENGINE:-}" ]; then # 사용자가 준 명령에서 종류만 뽑는다 ("sudo podman" → podman). case " ${ENGINE} " in *" podman"*|podman*) ENGINE_KIND=podman ;; *" docker"*|docker*) ENGINE_KIND=docker ;; *) ENGINE_KIND="${want:-docker}" ;; esac else case "${want}" in podman) ENGINE="podman" ; ENGINE_KIND=podman ;; docker) ENGINE="docker" ; ENGINE_KIND=docker ;; *) if command -v docker >/dev/null 2>&1; then ENGINE="docker" ; ENGINE_KIND=docker elif command -v podman >/dev/null 2>&1; then ENGINE="podman" ; ENGINE_KIND=podman else die "docker 도 podman 도 없다. 엔진을 먼저 설치한다." fi ;; esac fi # shellcheck disable=SC2086 ${ENGINE} version >/dev/null 2>&1 \ || die "'${ENGINE}' 를 실행할 수 없다. 데몬이 떠 있는지, 권한(docker 그룹/sudo)이 있는지 확인한다." export ENGINE ENGINE_KIND } engine() { # shellcheck disable=SC2086 ${ENGINE} "$@" } # ── 공통 인자 ─────────────────────────────────────────────────────────────── # 모든 up/down 스크립트가 같은 인자를 받는다. # docker | podman 엔진 선택 (생략하면 스크립트 기본값) # --dry-run, -n 아무것도 만들지 않고 실행될 내용만 출력한다 DRY_RUN=false ENGINE_ARG="" parse_common_args() { local arg for arg in "$@"; do case "${arg}" in docker|podman) ENGINE_ARG="${arg}" ;; --dry-run|-n) DRY_RUN=true ;; *) die "알 수 없는 인자: ${arg}" ;; esac done } dry() { printf ' [dry-run] %s\n' "$*"; } # ── .env ─────────────────────────────────────────────────────────────────── require_env_file() { [ -f "${ENV_FILE}" ] || die ".env 가 없다: cp ${DEPLOY_DIR}/.env.example ${ENV_FILE} 후 값을 채운다." } load_env() { require_env_file set -a # shellcheck disable=SC1090 . "${ENV_FILE}" # Versioned updates keep site .env intact. Only the installed app image is overridden. if [ -f "${DEPLOY_DIR}/.bundle-state/images.env" ]; then local installed_app installed_app="$(sed -n 's/^APP_IMAGE=//p' "${DEPLOY_DIR}/.bundle-state/images.env")" case "${installed_app}" in ''|*[!A-Za-z0-9._/:@-]*) die "invalid installed app image state" ;; esac APP_IMAGE="${installed_app}" fi set +a configure_optional_services } configure_optional_services() { case "${LITELLM_ENABLED:-false}" in [Tt][Rr][Uu][Ee]) export LITELLM_ENABLED=true ;; [Ff][Aa][Ll][Ss][Ee]) export LITELLM_ENABLED=false ;; *) die "LITELLM_ENABLED 는 true 또는 false여야 한다." ;; esac local profile profiles="" local -a requested_profiles IFS=, read -r -a requested_profiles <<< "${COMPOSE_PROFILES:-}" for profile in "${requested_profiles[@]}"; do case "${profile}" in ''|litellm|'*') continue ;; esac profiles="${profiles:+${profiles},}${profile}" done if [ "${LITELLM_ENABLED}" = true ]; then profiles="${profiles:+${profiles},}litellm" fi export COMPOSE_PROFILES="${profiles}" } check_litellm_config() { [ "${LITELLM_ENABLED:-false}" = true ] || return 0 case "${LITELLM_MASTER_KEY:-}" in sk-?*) ;; *) die "LiteLLM 사용 시 LITELLM_MASTER_KEY에 sk-로 시작하는 인증키를 설정한다." ;; esac # LiteLLM 은 true/false 만 불리언으로 읽는다. 그 외 문자열은 CA 번들 경로로 취급돼 # boto3 Bedrock 경로에서 검증이 조용히 꺼질 수 있으므로 여기서 값을 좁힌다. case "${SSL_VERIFY:-true}" in true|false) ;; *) die "SSL_VERIFY 는 true 또는 false여야 한다." ;; esac } check_replica_counts() { local key value default for key in WEB_REPLICAS RQ_DEFAULT_WORKERS RQ_HIGH_WORKERS RQ_LOG_WORKERS; do default=1 [ "${key}" = WEB_REPLICAS ] && default=4 value="${!key:-${default}}" case "${value}" in ''|0*|*[!0-9]*) die "${key} 는 1 이상의 정수여야 한다 (지금: ${value})." ;; esac [ "${value}" -ge 1 ] || die "${key} 는 1 이상의 정수여야 한다 (지금: ${value})." done } env_value() { # env_value KEY — .env 에서 값 하나만 읽는다 (전체 export 없이). grep -m1 -E "^${1}=" "${ENV_FILE}" 2>/dev/null | cut -d= -f2- || true } # ── 필요한 이미지 ─────────────────────────────────────────────────────────── # 어떤 이미지가 있어야 하는지는 compose.yml 이 정본이다. 목록을 따로 들고 있지 않는다. required_images() { command -v python3 >/dev/null 2>&1 || die "python3 가 없어 compose 를 읽을 수 없다." python3 "${DEPLOY_DIR}/bin/_compose_to_plan.py" "${DEPLOY_DIR}/compose.yml" "${DEPLOY_DIR}" \ 2>/dev/null \ | python3 -c ' import json, sys plan = json.load(sys.stdin) seen = [] for name in plan["order"]: tag = plan["services"][name]["image"] if tag not in seen: seen.append(tag) print("\n".join(seen)) ' || die "compose.yml 에서 이미지 목록을 읽지 못했다." } # tar 안에 선언된 이미지 태그 (docker save 산출물의 manifest.json). tar_tags() { tar -xOf "$1" manifest.json 2>/dev/null | python3 -c ' import json, sys try: data = json.load(sys.stdin) except Exception: sys.exit(1) for m in data: for t in (m.get("RepoTags") or []): print(t) ' } # ── nginx · postgres 설정 ─────────────────────────────────────────────────── # nginx·postgres 이미지는 순정이라 우리 설정이 없다 — 호스트에서 bind mount 로 넣는다. # 그 설정의 정본이 conf/ 다. 개발용 __docker/compose.yml 도 같은 파일을 참조하므로 # 사본이 두 벌 생기지 않는다. 앱 이미지에는 들어 있지 않다 (런타임 마운트 전용). CONF_FILES=" nginx/app.conf nginx/http.conf nginx/https.conf nginx/upstream/docker.conf nginx/upstream/podman.conf postgres/init.sql " # up 스크립트가 기동 전에 부른다. require_conf() { local dest missing="" for dest in ${CONF_FILES}; do [ -f "${DEPLOY_DIR}/conf/${dest}" ] || missing="${missing} conf/${dest}" done if [ "${LITELLM_ENABLED:-false}" = true ] && [ ! -f "${DEPLOY_DIR}/conf/litellm/config.yaml" ]; then missing="${missing} conf/litellm/config.yaml" fi [ -z "${missing}" ] || die "번들에 설정 파일이 없다:${missing} 매체를 온전히 복사했는지 확인한다." } # ── 호스트 포트 노출 ──────────────────────────────────────────────────────── # compose 는 리스트 항목을 조건부로 뺄 수 없으므로, 기본 compose.yml 에는 인프라 포트를 # 두지 않고 .env 의 EXPOSE_*_PORT 가 채워졌을 때만 override 를 만들어 얹는다. # 값이 있으면 0.0.0.0 으로 연다 — VM 밖에서 바로 붙을 수 있다는 뜻이다. PORTS_OVERRIDE="" write_ports_override() { PORTS_OVERRIDE="${DEPLOY_DIR}/compose.ports.yml" local spec svc var container body="" opened="" for spec in "postgres:EXPOSE_POSTGRES_PORT:5432" \ "redis:EXPOSE_REDIS_PORT:6379" \ "minio:EXPOSE_MINIO_API_PORT:9000" \ "minio:EXPOSE_MINIO_CONSOLE_PORT:9001"; do svc="${spec%%:*}" var="$(printf '%s' "${spec}" | cut -d: -f2)" container="$(printf '%s' "${spec}" | cut -d: -f3)" eval "host=\${${var}:-}" [ -n "${host}" ] || continue case "${host}" in ''|*[!0-9]*) die "${var} 는 포트 번호여야 한다 (지금: ${host})." ;; esac body="${body} ${svc}: ports: - \"0.0.0.0:${host}:${container}\" " opened="${opened} ${svc}:${host}→${container}" done if [ -z "${body}" ]; then rm -f "${PORTS_OVERRIDE}" PORTS_OVERRIDE="" return 0 fi # compose 는 -f 를 여러 번 주면 같은 서비스의 ports 를 이어 붙인다. minio 처럼 항목이 # 둘인 서비스가 두 번 나오지 않도록 서비스별로 합쳐서 쓴다. { printf '# 자동 생성 파일 — .env 의 EXPOSE_*_PORT 로 up-*.sh 가 만든다.\n' printf '# 호스트 0.0.0.0 에 포트를 연다. 열지 않으려면 .env 에서 해당 값을 비운다.\n' printf '\nservices:\n' printf '%s' "${body}" | awk ' /^ [a-z-]+:$/ { svc = $0; if (!(svc in seen)) { order[++n] = svc; seen[svc] = "" } next } /^ - / { seen[svc] = seen[svc] $0 "\n" } END { for (i = 1; i <= n; i++) printf "%s\n ports:\n%s", order[i], seen[order[i]] } ' } > "${PORTS_OVERRIDE}" log "호스트 포트 노출 (0.0.0.0):${opened}" } # ── 로그 디렉터리 ─────────────────────────────────────────────────────────── # 로그는 이름 있는 볼륨이 아니라 호스트 디렉터리에 쌓인다 (스택을 지워도 남는다). # bind mount 는 호스트 uid/gid 를 그대로 넘긴다. 이미지 버전마다 실행 계정이 # 다를 수 있으므로 실제 이미지의 uid/gid 를 조회해 쓰기 권한을 맞춘다. # # 소유권을 자동으로 맞춘다. chown → sudo chown → chmod 0777 순으로 시도하고, 매번 # 실제 이미지를 띄워 정말 써지는지 확인한다. 그래도 안 되면 기동 전에 멈춘다 — # 그러지 않으면 컨테이너가 뜬 직후 로깅 초기화에서 PermissionError 로 죽는다. LOG_DIR_ABS="" # 컨테이너 사용자로 실제 파일을 만들어 본다. 모드만 보고 넘기지 않는다. _log_dir_writable() { # <디렉터리> <이미지> engine run --rm -v "$1:/logcheck" --entrypoint sh "$2" \ -c 'touch /logcheck/.write-test && rm -f /logcheck/.write-test' >/dev/null 2>&1 } prepare_log_dirs() { local base="${LOG_DIR:-./logs}" case "${base}" in /*) : ;; *) base="${DEPLOY_DIR}/${base#./}" ;; esac LOG_DIR_ABS="${base}" export LOG_DIR_ABS local spec dir owner image failed="" for spec in "app:${APP_IMAGE:-pi-continuum:76d67d4}" \ "processing:${PROCESSING_IMAGE:-alpha-processing:b11f26b}"; do dir="${base}/${spec%%:*}" image="${spec#*:}" mkdir -p "${dir}" || die "로그 디렉터리를 만들 수 없다: ${dir}" # 이미지가 아직 없으면(적재 전) 확인을 미룬다. engine image inspect "${image}" >/dev/null 2>&1 || continue _log_dir_writable "${dir}" "${image}" && continue # Config.User 는 이름일 수도 있다. entrypoint 를 우회하되 이미지 기본 사용자로 # id 를 실행해 숫자 UID와 GID를 각각 얻는다. 조회 실패 시 추측해서 chown 하지 않는다. owner=$(engine run --rm --network none --entrypoint sh "${image}" \ -c 'printf "%s:%s\n" "$(id -u)" "$(id -g)"') \ || die "로그 디렉터리 사용자 조회 실패: ${image} (${dir})" [[ "${owner}" =~ ^[0-9]+:[0-9]+$ ]] \ || die "이미지의 uid/gid가 올바르지 않다: ${image}" if chown "${owner}" "${dir}" 2>/dev/null && _log_dir_writable "${dir}" "${image}"; then log "로그 디렉터리 소유권 설정: ${dir} → ${owner} (${image})" continue fi if sudo -n chown "${owner}" "${dir}" 2>/dev/null && _log_dir_writable "${dir}" "${image}"; then log "로그 디렉터리 소유권 설정(sudo): ${dir} → ${owner} (${image})" continue fi # rootless podman 처럼 컨테이너 uid 가 호스트에서 다른 값으로 매핑되면 chown 이 # 통하지 않는다. 로그만 쌓이는 디렉터리라 쓰기 권한을 열어 준다. if chmod 0777 "${dir}" 2>/dev/null && _log_dir_writable "${dir}" "${image}"; then log "로그 디렉터리 권한 개방: ${dir} (chown 불가 → mode 0777)" continue fi failed="${failed} sudo chown ${owner} ${dir} # ${image}" done [ -z "${failed}" ] || die "컨테이너가 로그 디렉터리에 쓰지 못하고 자동 보정도 실패했다. 직접 만들어 주고 다시 실행한다:${failed}" log "로그 디렉터리: ${base}/app · ${base}/processing" } # ── 대기 ──────────────────────────────────────────────────────────────────── # wait_healthy <컨테이너명> [타임아웃초] wait_healthy() { local name="$1" timeout="${2:-300}" waited=0 state health log "healthy 대기: ${name} (최대 ${timeout}s)" while :; do state="$(engine inspect -f '{{.State.Status}}' "${name}" 2>/dev/null || echo missing)" health="$(engine inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "${name}" 2>/dev/null || echo none)" case "${health}" in healthy) log " ${name}: healthy"; return 0 ;; none) # healthcheck 가 없는 이미지는 running 을 성공으로 본다. [ "${state}" = "running" ] && { log " ${name}: running (healthcheck 없음)"; return 0; } ;; esac [ "${state}" = "exited" ] && die "${name} 이 떠 있지 않다 (exited). 로그: ${ENGINE} logs ${name}" [ "${waited}" -ge "${timeout}" ] && die "${name} 이 ${timeout}s 안에 healthy 가 되지 않았다 (state=${state} health=${health})." sleep 3; waited=$((waited + 3)) done } # wait_exit0 <컨테이너명> [타임아웃초] — 1회성 컨테이너(bootstrap, minio-init)용. wait_exit0() { local name="$1" timeout="${2:-1800}" waited=0 state code log "종료 대기: ${name} (최대 ${timeout}s)" while :; do state="$(engine inspect -f '{{.State.Status}}' "${name}" 2>/dev/null || echo missing)" if [ "${state}" = "exited" ]; then code="$(engine inspect -f '{{.State.ExitCode}}' "${name}")" [ "${code}" = "0" ] || die "${name} 이 exit ${code} 로 끝났다. 로그: ${ENGINE} logs ${name}" log " ${name}: exit 0" return 0 fi [ "${waited}" -ge "${timeout}" ] && die "${name} 이 ${timeout}s 안에 끝나지 않았다 (state=${state})." sleep 3; waited=$((waited + 3)) done }