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