Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ jobs:
- uses: actions/checkout@v4
- name: Run check-consistency.sh
run: bash scripts/check-consistency.sh
- name: Test deployment image retention
run: python3 -m unittest discover -s scripts/tests -p 'test_cleanup_images.py' -v
- name: Validate self-host Compose
run: |
bash scripts/selfhost.sh init
Expand Down
17 changes: 17 additions & 0 deletions infra/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,22 @@ TimescaleDB extension 应存在;hypertable 至少包括 bars/ticks。架构和

## 清理

生产部署脚本 `scripts/deploy.sh` 需要宿主机 `python3`、`flock` 及支持 `up --wait`
的 Docker Compose。部署之间使用文件锁互斥;服务在 300 秒内通过健康检查后,才清理
Compose 中明确声明的 Inalpha 应用镜像仓库。每个仓库保留最近两个构建版本,同时保留
所有容器(包括已停止容器)引用、本次部署前引用、以及构建未满 24 小时的镜像。
失败部署不清理;清理失败会输出告警。数据库、volume、容器及其他仓库镜像不参与清理。
无仓库信息的悬空镜像会保留,需要人工确认来源后处理。

手动检查时默认只预览,确认后添加 `--apply`:

```bash
python3 scripts/cleanup-images.py --repository ghcr.io/mirror29/inalpha-dashboard
```

生产 Compose 的全部服务均配置 `json-file` 日志轮转,每个文件最多 `10m`,保留
`3` 个文件,每容器约 30 MB。新配置在容器下次创建或重建时生效,普通 restart
不会改变已有容器的日志配置。更新配置后应随计划内部署启用,不必重启 Docker daemon。

`docker compose down` 只停止容器并保留 volume。`docker compose down -v` 会永久删除开发
数据库 volume,只能在明确要重建本地数据时使用。
13 changes: 13 additions & 0 deletions infra/docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,18 @@ name: inalpha-prod
# - 应急本地构建:`up -d --build`(小内存机注意 OOM)。
# image 路径由 IMAGE_PREFIX/IMAGE_TAG 注入(见 .env.prod.example);CI 见 build-images.yml。

x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"

x-python-build: &python-build
context: ../services
dockerfile: ../infra/docker/Dockerfile.python

x-svc-common: &svc-common
logging: *logging
restart: unless-stopped
depends_on:
postgres:
Expand All @@ -30,6 +37,7 @@ x-svc-common: &svc-common

services:
postgres:
logging: *logging
# 数据库镜像必须钉版本:latest-pg17 浮动 tag 在 pull/重建时可能拉到新版
# TimescaleDB,轻则起不来重则静默破坏已有 hypertable。升级走显式改版本号。
image: timescale/timescaledb:2.27.2-pg17
Expand All @@ -52,6 +60,7 @@ services:
# 不映射端口到公网;仅容器内网可达

redis:
logging: *logging
image: redis:8-alpine
container_name: inalpha-redis
restart: unless-stopped
Expand All @@ -70,6 +79,7 @@ services:

# 一次性:跑 DB 迁移到最新,app 服务依赖它 completed_successfully
migrate:
logging: *logging
image: ${IMAGE_PREFIX:-ghcr.io/__CHANGE_ME__}/inalpha-paper:${IMAGE_TAG:-latest}
build:
<<: *python-build
Expand Down Expand Up @@ -190,6 +200,7 @@ services:
start_period: 30s

mastra:
logging: *logging
image: ${IMAGE_PREFIX:-ghcr.io/__CHANGE_ME__}/inalpha-mastra:${IMAGE_TAG:-latest}
build:
context: ../packages/orchestration
Expand Down Expand Up @@ -220,6 +231,7 @@ services:
# 操作者控制台(Next16 standalone)。BFF 经容器名直连后端,零 CORS、JWT 同源(共用 .env.prod)。
# 公网仅经 Cloudflare Tunnel 暴露 dashboard.inalpha.dev → dashboard:3001;后端服务一律不暴露。
dashboard:
logging: *logging
image: ${IMAGE_PREFIX:-ghcr.io/__CHANGE_ME__}/inalpha-dashboard:${IMAGE_TAG:-latest}
build:
context: ../apps/dashboard
Expand Down Expand Up @@ -269,6 +281,7 @@ services:

# 公网入口:Cloudflare Tunnel(无需在 VPS 开入站端口,TLS/防护走 CF)
cloudflared:
logging: *logging
profiles: ["tunnel"]
image: cloudflare/cloudflared:2026.6.0
container_name: inalpha-cloudflared
Expand Down
108 changes: 108 additions & 0 deletions scripts/cleanup-images.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env python3

import argparse
import json
import re
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path


def docker(*args):
return subprocess.check_output(["docker", *args], text=True).strip()


def container_images():
containers = docker("ps", "-aq").split()
if not containers:
return set()
return set(docker("inspect", "--format", "{{.Image}}", *containers).split())


def repositories(image):
tags = image.get("RepoTags") or []
digests = image.get("RepoDigests") or []
return {tag.rsplit(":", 1)[0] for tag in tags} | {
digest.split("@", 1)[0] for digest in digests
}


def created_at(value):
normalized = re.sub(
r"\.(\d+)", lambda match: "." + match[1][:6].ljust(6, "0"), value
)
return datetime.fromisoformat(normalized.replace("Z", "+00:00"))


def candidates(images, allowed, protected, keep, cutoff):
grouped = {repo: [] for repo in allowed}
for image in images:
for repo in repositories(image) & allowed:
grouped[repo].append(image)
retained = set(protected)
for group in grouped.values():
ordered = sorted(group, key=lambda image: image["Created"], reverse=True)
retained.update(image["Id"] for image in ordered[:keep])
return [
image
for image in images
if repositories(image)
and repositories(image) <= allowed
and image["Id"] not in retained
and created_at(image["Created"]) < cutoff
]


def remove_image(image, protected):
image_id = image["Id"]
refs = image.get("RepoTags") or [image_id]
for ref in refs:
if image_id in protected | container_images():
print("SKIP in-use:", image_id)
return
actual = docker("image", "inspect", "--format", "{{.Id}}", ref)
if actual != image_id:
raise RuntimeError("Image reference changed; stopping cleanup: " + ref)
subprocess.run(["docker", "image", "rm", "--no-prune", ref], check=True)


def main():
parser = argparse.ArgumentParser(
description="Preview or remove old Inalpha images."
)
parser.add_argument("--repository", action="append", required=True)
parser.add_argument("--protect-file", type=Path)
parser.add_argument("--keep", type=int, default=2)
parser.add_argument("--min-age-hours", type=int, default=24)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
if args.keep < 2 or args.min_age_hours < 24:
parser.error("Keep at least two versions and a minimum age of 24 hours.")
allowed = set(args.repository)
services = {"data", "paper", "research", "factor", "evolver", "mastra", "dashboard"}
if any(
repo.rsplit("/", 1)[-1] not in {"inalpha-" + s for s in services}
for repo in allowed
):
parser.error("Only explicit Inalpha application repositories are supported.")
protected = container_images()
if args.protect_file:
protected.update(args.protect_file.read_text().split())
ids = sorted(set(docker("image", "ls", "-aq", "--no-trunc").split()))
images = json.loads(docker("image", "inspect", *ids)) if ids else []
selected = candidates(
images,
allowed,
protected,
args.keep,
datetime.now(timezone.utc) - timedelta(hours=args.min_age_hours),
)
print("APPLY" if args.apply else "DRY RUN", "candidate images:", len(selected))
for image in selected:
print(image["Id"], ", ".join(image.get("RepoTags") or []))
if args.apply:
remove_image(image, protected)


if __name__ == "__main__":
main()
37 changes: 35 additions & 2 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,55 @@ if ! grep -qE '^CLOUDFLARE_TUNNEL_TOKEN=.+$' "$HOST_ENV_FILE"; then
exit 1
fi

command -v python3 >/dev/null
command -v flock >/dev/null
mkdir -p "$ROOT/.cache"
exec 9>"$ROOT/.cache/deploy.lock"
flock -n 9 || { echo "已有部署正在运行" >&2; exit 1; }

if [ "$do_git_pull" -eq 1 ]; then
echo "==> git pull --ff-only"
git pull --ff-only
fi

protected_images="$(mktemp)"
trap 'rm -f "$protected_images"' EXIT
container_ids="$(docker ps -aq)"
if [ -n "$container_ids" ]; then
docker inspect --format '{{.Image}}' $container_ids > "$protected_images"
fi
image_refs="$("${DC[@]}" config --images)"
cleanup_args=()
while IFS= read -r ref; do
repository="${ref%@*}"
if [[ "${repository##*/}" == *:* ]]; then
repository="${repository%:*}"
fi
case "${repository##*/}" in
inalpha-data|inalpha-paper|inalpha-research|inalpha-factor|inalpha-evolver|inalpha-mastra|inalpha-dashboard)
cleanup_args+=(--repository "$repository") ;;
esac
done <<< "$image_refs"

if [ "$mode" = "image" ]; then
echo "==> 拉取镜像(GHCR)"
"${DC[@]}" pull
echo "==> 起栈(migrate 先跑 alembic upgrade head,再起各服务)"
"${DC[@]}" up -d
"${DC[@]}" up -d --wait --wait-timeout 300
else
echo "==> 就地构建并起栈(--build;小内存机注意 OOM)"
"${DC[@]}" up -d --build
"${DC[@]}" up -d --build --wait --wait-timeout 300
fi

echo "==> 当前状态"
"${DC[@]}" ps

if [ "${#cleanup_args[@]}" -gt 0 ]; then
echo "==> 清理旧应用镜像(保留最近两个版本、部署前版本及容器引用)"
if ! python3 scripts/cleanup-images.py "${cleanup_args[@]}" \
--protect-file "$protected_images" --apply; then
echo "警告:服务已部署,但镜像清理失败;请检查后重试。" >&2
fi
fi

echo "==> 完成。排障:'${DC[*]} logs -f <service>';健康看上方 ps 的 STATUS。"
Loading
Loading