From 89c6cbb5ada5447eba0915671bad0a0c4d7d3049 Mon Sep 17 00:00:00 2001 From: ysj7705 Date: Mon, 5 Oct 2026 12:31:16 +0900 Subject: [PATCH] Fix image deletion: per-tag delete, repository delete, daily GC. - Deleting a tag now untags only that tag; the manifest is removed only when no other tag references the same digest (previously deleting by digest also removed every sibling tag). - Add repository deletion. The registry API cannot drop a repository from the catalog, so empty repositories stayed in the UI forever with nothing left to delete. The app now mounts registry storage and removes the repository's metadata directories after deleting its manifests. - Run `registry garbage-collect` daily (GC_HOUR_UTC) so deleted blobs free disk. - Disable Watchtower for the locally built app image. Co-Authored-By: Claude Opus 5.5 --- .env.example | 3 + README.md | 16 ++++ app/app/pages/images.vue | 93 ++++++++++++++++----- app/server/api/repo-tags.delete.ts | 24 +----- app/server/api/repositories/index.delete.ts | 27 ++++++ app/server/utils/delete-permission.ts | 17 ++++ app/server/utils/registry.ts | 36 ++++++++ app/server/utils/repository-storage.ts | 61 ++++++++++++++ docker-compose.yml | 22 +++++ 9 files changed, 258 insertions(+), 41 deletions(-) create mode 100644 app/server/api/repositories/index.delete.ts create mode 100644 app/server/utils/delete-permission.ts create mode 100644 app/server/utils/repository-storage.ts diff --git a/.env.example b/.env.example index fbc4a0a..4424718 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,6 @@ APP_PORT=3080 # Internal registry URL used by the Nuxt app (compose service name) REGISTRY_INTERNAL_URL=http://registry:5000 + +# Daily registry garbage-collect hour in UTC (20 = 05:00 KST). Empty value disables it. +GC_HOUR_UTC=20 diff --git a/README.md b/README.md index 19f54f3..ab03734 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ docker push localhost:5050/demo/alpine:latest - 주의: registry 컨테이너에는 `REGISTRY_*` 환경변수를 직접 넣지 않습니다(공식 이미지가 설정 오버라이드로 해석함). `REGISTRY_PUBLIC_URL`은 app에만 전달됩니다. - `SESSION_SECRET` — 관리 UI 세션 서명 키 (운영에서 반드시 변경) - `ADMIN_USERNAME` / `ADMIN_PASSWORD` — 최초 admin 시드 +- `GC_HOUR_UTC` — 매일 blob 가비지 컬렉션을 돌릴 시각(UTC, 기본 `20` = 05:00 KST). 빈 값이면 끔 `AUTH_PUBLIC_URL`을 바꾼 뒤에는 registry 컨테이너를 재시작하세요. @@ -97,6 +98,21 @@ ACL 패턴 예: - `team/**` — 하위 전체 - `*` — 모든 저장소 +## 이미지 삭제 + +- **태그 삭제** — 해당 태그만 지웁니다. 같은 digest를 가리키는 다른 태그는 남고, 더 이상 어떤 태그도 가리키지 않을 때만 manifest까지 삭제합니다. +- **저장소 삭제** — 모든 manifest를 지운 뒤 저장소 메타데이터 디렉터리를 스토리지에서 직접 제거합니다. + Registry API에는 저장소 삭제가 없어서, 태그를 모두 지워도 `_catalog`에 빈 저장소가 계속 남기 때문입니다. + 이를 위해 app 컨테이너에 `registry-data` 볼륨을 함께 마운트합니다(`REGISTRY_STORAGE_PATH`). +- **디스크 회수** — 삭제는 참조만 끊고 blob은 남습니다. registry 컨테이너가 매일 `GC_HOUR_UTC`에 + `registry garbage-collect`를 실행해 참조 없는 blob을 정리합니다. 바로 정리하려면: + + ```bash + docker compose exec registry registry garbage-collect /etc/docker/registry/config.yml + ``` + + GC 중 push가 겹치면 업로드 중인 레이어가 지워질 수 있으니 push가 없는 시간에 실행하세요. + ## 로컬 개발 (앱만) ```bash diff --git a/app/app/pages/images.vue b/app/app/pages/images.vue index 42ff13e..66d5d58 100644 --- a/app/app/pages/images.vue +++ b/app/app/pages/images.vue @@ -34,16 +34,29 @@
- +
+ + +
@@ -94,7 +107,22 @@ -

태그가 없습니다.

+
+

+ 태그가 없는 저장소입니다. 레지스트리는 빈 저장소를 자동으로 지우지 않으므로 직접 삭제하세요. +

+ +
@@ -107,6 +135,7 @@ import { ChevronRight, CircleAlert, Copy, + LoaderCircle, Package, PackageOpen, RefreshCw, @@ -122,6 +151,7 @@ const open = ref(null) const tags = ref([]) const tagsPending = ref(false) const copiedKey = ref('') +const deleting = ref(null) const { data, @@ -169,17 +199,12 @@ async function refreshAll() { await refresh() } -async function toggle(repo: string) { - if (open.value === repo) { - open.value = null - return - } - open.value = repo +async function loadTags(repo: string) { tagsPending.value = true tags.value = [] try { const res = await $fetch<{ tags: string[] }>('/api/repo-tags', { query: { name: repo } }) - tags.value = res.tags + if (open.value === repo) tags.value = res.tags } catch (e: any) { error.value = e?.data?.statusMessage || '태그를 불러오지 못했습니다' } finally { @@ -187,13 +212,41 @@ async function toggle(repo: string) { } } +async function toggle(repo: string) { + if (open.value === repo) { + open.value = null + return + } + open.value = repo + await loadTags(repo) +} + async function removeTag(name: string, tag: string) { - if (!confirm(`${name}:${tag} 을(를) 삭제할까요?`)) return + if (!confirm(`${name}:${tag} 태그를 삭제할까요?\n같은 이미지를 가리키는 다른 태그는 유지됩니다.`)) return + error.value = '' try { await $fetch('/api/repo-tags', { method: 'DELETE', body: { name, tag } }) - tags.value = tags.value.filter((t) => t !== tag) + await loadTags(name) } catch (e: any) { error.value = e?.data?.statusMessage || '삭제에 실패했습니다' } } + +async function removeRepository(name: string) { + if (!confirm(`저장소 ${name} 을(를) 모든 태그와 함께 삭제할까요?\n되돌릴 수 없습니다.`)) return + error.value = '' + deleting.value = name + try { + await $fetch('/api/repositories', { method: 'DELETE', body: { name } }) + if (open.value === name) { + open.value = null + tags.value = [] + } + await refresh() + } catch (e: any) { + error.value = e?.data?.statusMessage || '저장소 삭제에 실패했습니다' + } finally { + deleting.value = null + } +} diff --git a/app/server/api/repo-tags.delete.ts b/app/server/api/repo-tags.delete.ts index f3d416f..8d0a2a0 100644 --- a/app/server/api/repo-tags.delete.ts +++ b/app/server/api/repo-tags.delete.ts @@ -10,26 +10,8 @@ export default defineEventHandler(async (event) => { await ensureBootstrap() const body = bodySchema.parse(await readBody(event)) + await requireDeletePermission(session, body.name) - if (session.role !== 'ADMIN') { - const user = await prisma.user.findUnique({ - where: { id: session.id }, - include: { acls: true }, - }) - if (!user) { - throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) - } - const allowed = allowedActionsForRepo(user, body.name) - if (!allowed.includes('delete') && !allowed.includes('*')) { - throw createError({ statusCode: 403, statusMessage: 'Delete not allowed' }) - } - } - - const digest = await getManifestDigest(body.name, body.tag) - if (!digest) { - throw createError({ statusCode: 404, statusMessage: 'Tag not found' }) - } - - await deleteManifest(body.name, digest) - return { ok: true, name: body.name, tag: body.tag, digest } + const { digest, manifestDeleted } = await deleteTag(body.name, body.tag) + return { ok: true, name: body.name, tag: body.tag, digest, manifestDeleted } }) diff --git a/app/server/api/repositories/index.delete.ts b/app/server/api/repositories/index.delete.ts new file mode 100644 index 0000000..368fc53 --- /dev/null +++ b/app/server/api/repositories/index.delete.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' + +const bodySchema = z.object({ + name: z.string().min(1), +}) + +export default defineEventHandler(async (event) => { + const session = await requireSession(event) + await ensureBootstrap() + + const { name } = bodySchema.parse(await readBody(event)) + await requireDeletePermission(session, name) + + // Delete manifests through the API first so blobs become unreferenced for GC + const tags = await listTags(name) + const digests = new Set() + for (const tag of tags) { + const digest = await getManifestDigest(name, tag) + if (digest) digests.add(digest) + } + for (const digest of digests) { + await deleteManifest(name, digest) + } + + await removeRepositoryFromStorage(name) + return { ok: true, name, deletedTags: tags.length } +}) diff --git a/app/server/utils/delete-permission.ts b/app/server/utils/delete-permission.ts new file mode 100644 index 0000000..f5cf9b4 --- /dev/null +++ b/app/server/utils/delete-permission.ts @@ -0,0 +1,17 @@ +import type { SessionUser } from './session' + +export async function requireDeletePermission(session: SessionUser, repository: string) { + if (session.role === 'ADMIN') return + + const user = await prisma.user.findUnique({ + where: { id: session.id }, + include: { acls: true }, + }) + if (!user) { + throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) + } + const allowed = allowedActionsForRepo(user, repository) + if (!allowed.includes('delete') && !allowed.includes('*')) { + throw createError({ statusCode: 403, statusMessage: 'Delete not allowed' }) + } +} diff --git a/app/server/utils/registry.ts b/app/server/utils/registry.ts index 00cb7b7..2e66e38 100644 --- a/app/server/utils/registry.ts +++ b/app/server/utils/registry.ts @@ -95,3 +95,39 @@ export async function deleteManifest(name: string, digest: string): Promise { + const digest = await getManifestDigest(name, tag) + if (!digest) { + throw createError({ statusCode: 404, statusMessage: 'Tag not found' }) + } + + const res = await registryFetch( + `/v2/${encodeURI(name)}/manifests/${encodeURIComponent(tag)}`, + { method: 'DELETE' }, + [{ type: 'repository', name, actions: ['delete', 'pull'] }], + ) + if (!res.ok && res.status !== 404) { + throw createError({ + statusCode: 502, + statusMessage: `Registry tag delete failed: ${res.status}`, + }) + } + + const remaining = await listTags(name) + const remainingDigests = await Promise.all(remaining.map((t) => getManifestDigest(name, t))) + if (remainingDigests.includes(digest)) { + return { digest, manifestDeleted: false } + } + + await deleteManifest(name, digest) + return { digest, manifestDeleted: true } +} diff --git a/app/server/utils/repository-storage.ts b/app/server/utils/repository-storage.ts new file mode 100644 index 0000000..5608944 --- /dev/null +++ b/app/server/utils/repository-storage.ts @@ -0,0 +1,61 @@ +import { readdir, rm, rmdir, stat } from 'node:fs/promises' +import { join, resolve, sep } from 'node:path' + +// Distribution repository name grammar (path components separated by '/') +const REPOSITORY_NAME = + /^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$/ + +// Per-repository metadata directories; anything else is a nested repository (e.g. foo/bar under foo) +const REPOSITORY_META_DIRS = ['_manifests', '_layers', '_uploads'] + +function repositoriesRoot(): string { + const storage = process.env.REGISTRY_STORAGE_PATH || '/var/lib/registry' + return resolve(storage, 'docker/registry/v2/repositories') +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory() + } catch { + return false + } +} + +/** + * The registry API cannot remove a repository from the catalog — even with zero tags it + * keeps showing up. Remove its metadata directories directly from the shared storage + * volume. Blobs are left for `registry garbage-collect`. + */ +export async function removeRepositoryFromStorage(name: string): Promise { + if (!REPOSITORY_NAME.test(name)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid repository name' }) + } + + const root = repositoriesRoot() + if (!(await isDirectory(root))) { + throw createError({ + statusCode: 501, + statusMessage: 'Registry storage is not mounted in the app container (REGISTRY_STORAGE_PATH)', + }) + } + + const dir = resolve(root, name) + if (!dir.startsWith(root + sep)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid repository name' }) + } + if (!(await isDirectory(join(dir, '_manifests')))) { + throw createError({ statusCode: 404, statusMessage: 'Repository not found' }) + } + + for (const meta of REPOSITORY_META_DIRS) { + await rm(join(dir, meta), { recursive: true, force: true }) + } + + // Drop now-empty directories up to the repositories root (keeps nested repositories) + let current = dir + while (current !== root) { + if ((await readdir(current)).length) break + await rmdir(current) + current = resolve(current, '..') + } +} diff --git a/docker-compose.yml b/docker-compose.yml index d1375a8..280f6a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,8 @@ services: AUTH_PUBLIC_URL: ${AUTH_PUBLIC_URL:-http://localhost:3080} TOKEN_SERVICE: ${REGISTRY_AUTH_SERVICE:-registry} TOKEN_ISSUER: ${REGISTRY_AUTH_ISSUER:-registry-aio} + # Daily blob garbage collection hour (UTC, 00-23). Empty disables it. + GC_HOUR_UTC: ${GC_HOUR_UTC-20} volumes: - registry-data:/var/lib/registry - ./certs:/certs:ro @@ -51,6 +53,21 @@ services: /config/config.yml.template > /etc/docker/registry/config.yml echo "Rendered registry config:" cat /etc/docker/registry/config.yml + # Deleting tags/manifests only drops references; blobs stay on disk until GC runs. + if [ -n "$${GC_HOUR_UTC:-}" ]; then + ( + last="" + while sleep 600; do + today="$$(date -u +%F)" + if [ "$$(date -u +%H)" = "$${GC_HOUR_UTC}" ] && [ "$$today" != "$$last" ]; then + last="$$today" + echo "garbage-collect: start" + registry garbage-collect /etc/docker/registry/config.yml 2>&1 | tail -n 5 + echo "garbage-collect: done" + fi + done + ) & + fi exec registry serve /etc/docker/registry/config.yml healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5000/v2/ || exit 0"] @@ -63,6 +80,8 @@ services: build: context: ./app dockerfile: Dockerfile + labels: + - com.centurylinklabs.watchtower.enable=false restart: unless-stopped depends_on: certs: @@ -91,9 +110,12 @@ services: SESSION_SECRET: ${SESSION_SECRET:-change-me-to-a-long-random-string} TOKEN_KEY_PATH: /certs/token.key TOKEN_CERT_PATH: /certs/token.crt + # Shared registry storage: lets the UI remove empty repositories from the catalog + REGISTRY_STORAGE_PATH: /var/lib/registry volumes: - app-data:/data - ./certs:/certs:ro + - registry-data:/var/lib/registry healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"] interval: 15s