프라이빗 레지스트리 구축과 이미지 관리
팀 빌드는 성공했지만 운영 서버는 외부 인터넷이 막혀 있어 Docker Hub에서 이미지를 받을 수 없습니다. 게다가 인증 없는 레지스트리를 임시로 열어두면 내부 이미지가 무단으로 유출될 위험이 생깁니다. 프라이빗 레지스트리는 단순 저장소가 아니라 배포 가용성과 접근 통제를 동시에 보장하는 기반입니다. 이 모듈은 구축부터 인증/TLS, 취약점 점검까지 운영 관점으로 연결합니다.
Docker Hub는 편리하지만 외부 인터넷에 의존한다는 근본적인 한계가 있습니다. 금융, 의료, 국방 등 보안이 중요한 산업에서는 모든 이미지가 사내 네트워크 안에서만 유통되어야 합니다. 또한 CI/CD 파이프라인에서 Docker Hub의 pull rate limit(무료 계정 기준 6시간당 100회)에 걸리면 배포가 멈출 수 있습니다. 프라이빗 레지스트리를 구축하면 이런 제약을 모두 해소하고, 이미지 취약점 스캔과 접근 제어까지 사내 정책으로 적용할 수 있습니다.
공식 registry:2 이미지로 즉시 사용 가능한 프라이빗 레지스트리를 구축하고, 인증과 TLS를 적용하여 운영 수준의 이미지 저장소로 발전시키는 전 과정을 실습합니다.
- 1Docker Registry 2.0 — 공식 레지스트리 이미지로 로컬 저장소 5분 만에 구축
- 2TLS 인증서 설정 — self-signed 인증서 생성과 insecure-registry 개발 환경 설정
- 3htpasswd 기본 인증 — 사용자 이름/비밀번호로 push/pull 접근 제어
- 4이미지 tag / push / pull — 레지스트리 주소 포함 태그 규칙과 실습
- 5Harbor 오픈소스 레지스트리 — 웹 UI, RBAC, Trivy 취약점 스캔 소개
이 모듈은 Linux 환경(Ubuntu/CentOS)을 기준으로 합니다. macOS에서는 brew install apache2-utils로 htpasswd를 설치할 수 있습니다. Windows 환경에서는 WSL2를 사용하세요.
mkdir -p ~/registry-lab/{certs,auth,data} && cd ~/registry-labopenssl versionsudo apt-get install -y apache2-utils || sudo yum install -y httpd-toolsdocker pull registry:2Docker Registry 2.0 아키텍처와 이미지 태그 규칙
사내 서버에 빌드한 이미지를 올려두고 다른 서버에서 내려받아야 합니다. Docker Hub는 공개 레지스트리라서 회사 코드가 들어간 이미지를 올릴 수 없고, 인터넷이 차단된 서버 환경에서는 외부 레지스트리 자체를 쓸 수 없습니다. 프라이빗 레지스트리가 필요한 이유입니다. 이 ConceptBlock에서는 Docker 레지스트리가 이미지를 어떻게 저장하고 제공하는지, 이미지 이름 규칙이 무엇인지를 다룹니다.

레지스트리란
Docker 레지스트리는 이미지 레이어를 저장하고 버전별로 관리하는 서버입니다. docker pull nginx:alpine을 실행하면 Docker 클라이언트가 기본 레지스트리인 registry-1.docker.io(Docker Hub)에서 이미지를 내려받습니다.
이미지 이름 전체 형식:
[레지스트리 호스트[:포트]/][네임스페이스/]리포지토리명[:태그][@다이제스트]
예시:
nginx:alpine
└── registry-1.docker.io/library/nginx:alpine (Docker Hub 공식 이미지)
mycompany.com:5000/backend/api-server:v2.1.0
└── 커스텀 레지스트리 / 네임스페이스 / 리포지토리 / 태그
Docker Registry 2.0의 구조
Docker Registry 2.0 컴포넌트:
┌─────────────────────────────────────────┐
│ registry:2 컨테이너 │
│ │
│ HTTP API (v2) │
│ ├── GET /v2/<name>/manifests/<ref> │ 이미지 매니페스트 조회
│ ├── PUT /v2/<name>/manifests/<ref> │ 매니페스트 업로드
│ ├── GET /v2/<name>/blobs/<digest> │ 레이어 다운로드
│ └── POST /v2/<name>/blobs/uploads/ │ 레이어 업로드
│ │
│ 스토리지 드라이버 │
│ ├── filesystem (기본, 로컬 디렉토리) │
│ ├── S3 (AWS S3) │
│ ├── GCS (Google Cloud Storage) │
│ └── Azure Blob Storage │
└─────────────────────────────────────────┘
│
▼
/var/lib/registry/ (기본 스토리지 경로)
이미지 태그 규칙
사내 레지스트리 배포를 위한 이미지 정석 태그 명명법
사설 이미지 저장소에 커스텀 빌드된 바이너리를 push하기 전, 도커 엔진이 어떤 원격 레지스트리로 배포해야 할지 방향을 지정하는 정석 태그 명명 아키텍처는 아래와 같은 구조를 준수해야 합니다.
- 정형적 태그 명명 구조:
[레지스트리주소[:포트]/][네임스페이스/]이미지명[:태그]레지스트리주소[:포트]: 이미지가 최종 업로드될 원격 서버 주소 (예:192.168.1.100:5000또는myregistry.company.com). 생략할 경우 디폴트로 Docker Hub의 공개 주소로 인식합니다.네임스페이스: 사내 팀 구분명 또는 사용자 프로젝트 그룹명 (예:marketing-team).이미지명[:태그]: 실제 배포할 앱의 고유 명칭과 버전 태그.
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part5/exam_22 && cd /tmp/docker/part5/exam_22
# 1. 빌드된 로컬 이미지를 프라이빗 레지스트리 규격 태그로 복제 복사
$ docker tag my-app:latest 192.168.1.100:5000/marketing-team/my-app:1.0.0
# 2. 지정된 프라이빗 레지스트리 서버로 원격 업로드 수행
$ docker push 192.168.1.100:5000/marketing-team/my-app:1.0.0
프라이빗 레지스트리를 사용할 때 이미지 태그에 레지스트리 주소를 포함해야 합니다.
# 기존 이미지에 프라이빗 레지스트리 태그 추가
docker tag SOURCE_IMAGE[:TAG] REGISTRY_HOST[:PORT]/[NAMESPACE/]REPOSITORY[:TAG]
# 예시
docker tag myapp:v1.0 localhost:5000/myapp:v1.0
docker tag myapp:v1.0 192.168.1.100:5000/team-a/myapp:v1.0
docker tag myapp:v1.0 registry.company.internal/prod/myapp:v1.0
# Push
docker push localhost:5000/myapp:v1.0
# Pull (다른 서버에서)
docker pull 192.168.1.100:5000/team-a/myapp:v1.0
레지스트리 API로 이미지 목록 조회
Registry 2.0은 REST API를 제공하므로 curl로 직접 조회할 수 있습니다.
# 저장된 모든 리포지토리 목록
curl http://localhost:5000/v2/_catalog
# {"repositories":["myapp","nginx","postgres"]}
# 특정 이미지의 태그 목록
curl http://localhost:5000/v2/myapp/tags/list
# {"name":"myapp","tags":["latest","v1.0","v1.1","v2.0"]}
TLS 인증서 설정과 insecure-registry

왜 TLS가 필요한가
Docker 클라이언트는 기본적으로 HTTPS(TLS) 연결이 아닌 레지스트리와의 통신을 거부합니다. 비암호화 HTTP로 이미지를 주고받으면 네트워크 상에서 이미지가 탈취되거나 위조될 위험이 크기 때문입니다.
운영 환경에서의 프라이빗 레지스트리 보안 정석
사내 운영 및 엔터프라이즈 환경에서는 보안을 위해 다음과 같은 방법을 적용하여 사설 레지스트리를 암호화하고 관리해야 합니다.
- 공인 TLS 인증서 매핑: 도메인이 등록되어 있다면 Let's Encrypt 등의 CA 공인 인증서(
domain.crt,domain.key)를registry:2컨테이너의/certs경로에 바인드 마운트하여 기동시킵니다. - 사내 Harbor 레지스트리 도입: 실무에서는 로우레벨
registry:2컨테이너에 복잡한 TLS와 htpasswd를 수동 바인딩하기보다, 엔터프라이즈 통합 솔루션인 Harbor를 올려 웹 UI 및 Role-based Access Control(RBAC), 기본 이미지 스캔(Trivy) 기능을 활용하는 것이 정석적인 아키텍처입니다.
개발 환경: insecure-registry 설정
비암호화(HTTP) 프라이빗 레지스트리 허용: daemon.json 설정
기본적으로 Docker Daemon은 보안 상의 이유로 HTTPS 프로토콜을 사용하지 않는 레지스트리와의 통신을 엄격하게 금지합니다. 그러나 로컬 개발 및 사내 폐쇄망 환경에서는 번거로운 TLS 인증서 발급 대신 HTTP(비암호화) 레지스트리를 임시 사용해야 하는 경우가 많습니다.
이를 도커 엔진에 인지시키기 위해 /etc/docker/daemon.json 파일을 수정하여 비암호화 화이트리스트 목록에 등록해 주어야 합니다. 한 줄로 표기하면 {"insecure-registries": ["192.168.1.100:5000"]} 포맷과 대응됩니다.
/etc/docker/daemon.json생성 또는 수정:JSON{ "insecure-registries": [ "192.168.1.100:5000" ] }- 도커 데몬 재시작:
이 과정을 거치면 TLS 에러 없이 지정된 사내 프라이빗 IP의 5000번 포트로 직접 이미지를 pull/push 할 수 있게 됩니다.로컬 터미널
$ sudo systemctl restart docker
기본 실습
가장 간단한 형태의 레지스트리를 실행하고 이미지 push/pull을 실습합니다.
실습 전 디렉토리와 예제 파일을 먼저 준비합니다.
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part3/exam_5 && cd /tmp/docker/part3/exam_5
# 프라이빗 레지스트리 실습용 디렉토리 구조 생성
mkdir -p data certs auth
# 레지스트리 docker-compose.yml 생성
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
registry:
image: registry:2
ports:
- "5000:5000"
volumes:
- ./data:/var/lib/registry
- ./certs:/certs
- ./auth:/auth
environment:
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
EOF
이제 실습을 진행합니다.
레지스트리 실행 확인:
docker ps
# CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS
# a1b2c3d4e5f6 registry:2 "/entrypoint.sh /etc…" 5 seconds ago Up 4 seconds 0.0.0.0:5000->5000/tcp
# API 응답 확인
curl http://localhost:5000/v2/
# {} ← 빈 JSON 응답이면 정상 동작
공식 이미지를 프라이빗 레지스트리로 복사:
# 1. Docker Hub에서 이미지 pull
docker pull nginx:alpine
# 2. 프라이빗 레지스트리 주소로 태그 추가
docker tag nginx:alpine localhost:5000/nginx:alpine
# 3. 프라이빗 레지스트리에 push
docker push localhost:5000/nginx:alpine
# 예상 출력:
# The push refers to repository [localhost:5000/nginx]
# 4d33db9fdf22: Pushed
# 8e7129b0bc5d: Pushed
# ...
# alpine: digest: sha256:abc123... size: 1234
push된 이미지 확인:
# 저장된 리포지토리 목록
curl http://localhost:5000/v2/_catalog
# {"repositories":["nginx"]}
# 태그 목록 확인
curl http://localhost:5000/v2/nginx/tags/list
# {"name":"nginx","tags":["alpine"]}
# 로컬 이미지 삭제 후 레지스트리에서 pull 테스트
docker rmi localhost:5000/nginx:alpine
docker pull localhost:5000/nginx:alpine
# 예상 출력:
# alpine: Pulling from nginx
# 4abcb2448998: Pull complete
# ...
# Status: Downloaded newer image for localhost:5000/nginx:alpine
커스텀 애플리케이션 이미지 push:
# 간단한 테스트 이미지 빌드
mkdir ~/registry-lab/testapp && cat > ~/registry-lab/testapp/Dockerfile <<'EOF'
FROM alpine:latest
LABEL maintainer="devteam@company.com" version="1.0.0"
RUN echo '{"app":"test","version":"1.0.0"}' > /app-info.json
CMD ["cat", "/app-info.json"]
EOF
docker build -t localhost:5000/testapp:v1.0.0 ~/registry-lab/testapp/
docker push localhost:5000/testapp:v1.0.0
# 전체 이미지 목록 확인
curl http://localhost:5000/v2/_catalog
# {"repositories":["nginx","testapp"]}
레지스트리 데이터 디렉토리 구조 확인:
ls ~/registry-lab/data/docker/registry/v2/repositories/
# nginx testapp
# 레이어 블롭 저장 위치
ls ~/registry-lab/data/docker/registry/v2/blobs/sha256/ | head -5
# 3d 4a 7f a1 bc
cd ~/registry-lab && docker run -d -p 5000:5000 --name local-registry -v $(pwd)/data:/var/lib/registry registry:2인증 없는 레지스트리는 누구나 이미지를 push하고 pull할 수 있어 위험합니다. htpasswd로 사용자 인증을 추가합니다.
htpasswd 파일 생성:
cd ~/registry-lab
# 첫 번째 사용자 생성 (-B: bcrypt 해시 사용, -b: 비밀번호를 인자로 전달)
htpasswd -Bbn admin "P@ssw0rd!Secure" > auth/htpasswd
# 두 번째 사용자 추가 (-B 플래그로 bcrypt, 파일에 추가)
htpasswd -Bb auth/htpasswd developer "Dev!2026Pass"
# 생성된 파일 확인
cat auth/htpasswd
# admin:$2y$05$...해시값...
# developer:$2y$05$...해시값...
인증이 적용된 레지스트리 실행 (docker-compose 방식):
# ~/registry-lab/docker-compose.yml
version: "3.9"
services:
registry:
image: registry:2
container_name: secure-registry
restart: unless-stopped
ports:
- "5001:5000"
environment:
# 기본 인증 설정
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: "MyCompany Docker Registry"
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
# 스토리지 설정
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
# 로그 설정
REGISTRY_LOG_LEVEL: info
REGISTRY_LOG_FORMATTER: json
volumes:
- ./data:/var/lib/registry
- ./auth:/auth:ro # read-only 마운트
인증 없는 기존 레지스트리 강제 제거 전 확인
안전한 실행 조건: local-registry가 실습용 임시 인스턴스이고, 필요한 데이터 백업이 끝난 경우에만 실행합니다.
실행 전 반드시 확인
- 삭제 대상 이름이 local-registry가 맞는가?
- 보존할 이미지/데이터가 없는지 확인했는가?
- 운영 레지스트리가 아닌 실습 환경인가?
docker rm -f local-registry위 항목을 모두 확인한 후 복사할 수 있습니다
# 기존 인증 없는 레지스트리 중지
docker rm -f local-registry
# 인증 레지스트리 시작
docker-compose up -d registry
# 인증 없이 접근 시 거부 확인
curl http://localhost:5001/v2/
# {"errors":[{"code":"UNAUTHORIZED","message":"authentication required",...}]}
# 인증 후 접근 성공 확인
curl -u admin:P@ssw0rd\!Secure http://localhost:5001/v2/
# {}
docker login / push / pull 실습:
# 레지스트리 로그인
docker login localhost:5001
# Username: admin
# Password: (입력)
# WARNING! Your password will be stored unencrypted in /home/user/.docker/config.json
# Login Succeeded
# 이미지 태그 변경 (포트 5001 사용)
docker tag nginx:alpine localhost:5001/nginx:alpine
# push (자동으로 저장된 자격증명 사용)
docker push localhost:5001/nginx:alpine
# 로그아웃 후 인증 없이 pull 시도
docker logout localhost:5001
docker rmi localhost:5001/nginx:alpine
docker pull localhost:5001/nginx:alpine
# Error response from daemon: Head "http://localhost:5001/v2/nginx/manifests/alpine":
# no basic auth credentials
# 재로그인 후 pull
docker login -u developer -p "Dev!2026Pass" localhost:5001
docker pull localhost:5001/nginx:alpine
# Status: Downloaded newer image for localhost:5001/nginx:alpine
자격증명 파일 확인:
cat ~/.docker/config.json
# {
# "auths": {
# "localhost:5001": {
# "auth": "ZGV2ZWxvcGVyOkRldiEyMDI2UGFzcw==" <- base64 인코딩
# }
# }
# }
# 디코딩하면 "developer:Dev!2026Pass"
echo "ZGV2ZWxvcGVyOkRldiEyMDI2UGFzcw==" | base64 -d
# developer:Dev!2026Pass
cd ~/registry-lab운영 환경에 가까운 TLS + htpasswd 조합의 완성된 레지스트리를 구성합니다.
self-signed 인증서 빠르게 생성:
cd ~/registry-lab/certs
# 단일 명령으로 self-signed 인증서 생성 (개발/테스트용)
openssl req -newkey rsa:4096 -nodes -sha256 \
-keyout registry.key \
-x509 -days 365 \
-out registry.crt \
-subj "/CN=localhost" \
-addext "subjectAltName=IP:127.0.0.1,DNS:localhost"
# 생성된 파일 확인
ls -la certs/
# registry.crt registry.key
Docker가 self-signed 인증서를 신뢰하도록 설정:
# Docker 레지스트리별 인증서 디렉토리
sudo mkdir -p /etc/docker/certs.d/localhost:5002
sudo cp certs/registry.crt /etc/docker/certs.d/localhost:5002/ca.crt
# docker restart 불필요 — 연결 시마다 certs.d를 읽음
docker-compose.yml 업데이트 (TLS 추가):
# ~/registry-lab/docker-compose.yml (TLS 포함 전체 구성)
version: "3.9"
services:
registry:
image: registry:2
container_name: tls-registry
restart: unless-stopped
ports:
- "5002:5000"
environment:
# TLS 설정
REGISTRY_HTTP_TLS_CERTIFICATE: /certs/registry.crt
REGISTRY_HTTP_TLS_KEY: /certs/registry.key
# 인증 설정
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: "MyCompany Secure Registry"
REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd
# 스토리지
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
# 삭제 API 활성화
REGISTRY_STORAGE_DELETE_ENABLED: "true"
volumes:
- ./data:/var/lib/registry
- ./auth:/auth:ro
- ./certs:/certs:ro
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider --no-check-certificate https://localhost:5000/v2/ 2>&1 | grep -E '(401|200)' || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
# 기존 레지스트리 중지 후 TLS 레지스트리 시작
docker-compose down
docker-compose up -d
# HTTPS로 접근 (인증 없이 → 401 예상)
curl -k https://localhost:5002/v2/
# {"errors":[{"code":"UNAUTHORIZED","message":"authentication required",...}]}
# 인증 포함 HTTPS 접근
curl -k -u admin:P@ssw0rd\!Secure https://localhost:5002/v2/
# {}
# docker login (HTTPS)
docker login localhost:5002
# Username: admin
# Password:
# Login Succeeded
# 이미지 push
docker tag nginx:alpine localhost:5002/nginx:alpine
docker push localhost:5002/nginx:alpine
# The push refers to repository [localhost:5002/nginx]
# ...
# alpine: digest: sha256:... size: 1234
cd ~/registry-lab레지스트리에 push하기 전에 이미지의 보안 취약점을 검사하는 방법을 실습합니다.
Trivy 설치 (대안적 방법 — Docker 사용):
# Docker로 Trivy 실행 (설치 없이 사용 가능)
alias trivy='docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.cache/trivy:/root/.cache/trivy \
aquasec/trivy:latest'
# 또는 직접 설치 (Ubuntu)
sudo apt-get install -y wget apt-transport-https gnupg lsb-release
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" \
| sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update && sudo apt-get install -y trivy
로컬 이미지 스캔:
# nginx:alpine 이미지 취약점 스캔
trivy image nginx:alpine
# 예상 출력:
# 2026-03-28T10:00:00Z INFO Vulnerability scanning is enabled
# 2026-03-28T10:00:00Z INFO Secret scanning is enabled
#
# nginx:alpine (alpine 3.19.0)
# ===========================
# Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
취약점이 있는 오래된 이미지 스캔:
# 의도적으로 오래된 이미지 스캔
docker pull python:3.6-slim
trivy image python:3.6-slim
# 예상 출력 (심각한 취약점 다수):
# python:3.6-slim (debian 9.13)
# ==============================
# Total: 287 (UNKNOWN: 5, LOW: 75, MEDIUM: 117, HIGH: 75, CRITICAL: 15)
#
# ┌───────────────┬────────────────┬──────────┬────────────┬───────────────────────┐
# │ Library │ Vulnerability │ Severity │ Installed │ Fixed Version │
# ├───────────────┼────────────────┼──────────┼────────────┼───────────────────────┤
# │ openssl │ CVE-2023-0464 │ HIGH │ 1.1.0l-1 │ 1.1.1n+really1.1.0l-1+│
# │ libc6 │ CVE-2019-1010024│ LOW │ 2.24-11 │ │
# └───────────────┴────────────────┴──────────┴────────────┴───────────────────────┘
CI/CD 파이프라인 통합 — 심각 취약점 발견 시 push 차단:
#!/bin/bash
# registry-push.sh — 취약점 검사 후 push
IMAGE=$1
REGISTRY="localhost:5002"
TAGGED="${REGISTRY}/${IMAGE}"
echo "=== 이미지 빌드 ==="
docker build -t "$TAGGED" .
echo "=== 취약점 스캔 중 ==="
# --exit-code 1: CRITICAL 취약점 발견 시 exit code 1 반환
# --severity: 검사할 심각도 수준
trivy image --exit-code 1 --severity CRITICAL "$TAGGED"
if [ $? -ne 0 ]; then
echo "❌ CRITICAL 취약점 발견 — push 중단"
exit 1
fi
echo "=== 레지스트리에 push ==="
docker push "$TAGGED"
echo "✅ $TAGGED push 완료"
JSON 형식으로 스캔 결과 저장:
trivy image --format json --output scan-result.json nginx:alpine
cat scan-result.json | python3 -c "
import sys, json
data = json.load(sys.stdin)
results = data.get('Results', [])
for r in results:
vulns = r.get('Vulnerabilities') or []
critical = [v for v in vulns if v['Severity'] == 'CRITICAL']
if critical:
print(f'CRITICAL 취약점 {len(critical)}개 발견:')
for v in critical[:3]:
print(f' - {v[\"VulnerabilityID\"]}: {v[\"PkgName\"]} {v.get(\"FixedVersion\", \"수정 없음\")}')
"
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.49.0- 레지스트리 주소를 포함한 태그 규칙으로 push/pull이 일관되게 동작하는가?
- 인증 미적용 요청은 거부되고, 로그인 후 접근만 허용되는가?
- TLS/자격증명 설정이 의도한 범위(개발/운영)에 맞게 분리됐는가?
트러블슈팅
증상
$ docker push 192.168.1.100:5000/myapp:v1
Error response from daemon: Get "https://192.168.1.100:5000/v2/":
x509: certificate signed by unknown authority
원인 분석
Docker 클라이언트가 레지스트리의 TLS 인증서를 신뢰하지 않습니다. 다음 두 가지 상황에서 발생합니다.
- Self-signed 인증서 — CA가 공개적으로 신뢰받지 않음
- insecure-registry 미설정 — HTTP 레지스트리인데 Docker가 HTTPS를 시도
해결 방법 1: insecure-registry 설정 (개발 환경)
# /etc/docker/daemon.json 편집
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"insecure-registries": ["192.168.1.100:5000"]
}
EOF
sudo systemctl restart docker
# 적용 확인
docker info 2>/dev/null | grep -A 3 "Insecure Registries"
# Insecure Registries:
# 192.168.1.100:5000
해결 방법 2: 레지스트리 CA 인증서 신뢰 추가 (권장)
# 레지스트리 서버에서 CA 인증서 복사
scp admin@192.168.1.100:~/registry-lab/certs/registry.crt /tmp/registry.crt
# 방법 A: Docker 레지스트리별 인증서 등록 (재시작 불필요)
sudo mkdir -p /etc/docker/certs.d/192.168.1.100:5000
sudo cp /tmp/registry.crt /etc/docker/certs.d/192.168.1.100:5000/ca.crt
# 방법 B: 시스템 전체 CA 신뢰 추가 (Ubuntu)
sudo cp /tmp/registry.crt /usr/local/share/ca-certificates/company-registry.crt
sudo update-ca-certificates
sudo systemctl restart docker
# 적용 확인
docker pull 192.168.1.100:5000/nginx:alpine
# Status: Downloaded newer image
디버깅 명령어
# 인증서 정보 직접 확인
openssl s_client -connect 192.168.1.100:5000 </dev/null 2>/dev/null | \
openssl x509 -noout -subject -issuer -dates
# subject=CN=192.168.1.100
# issuer=CN=MyCompany-CA
# notBefore=Mar 28 00:00:00 2026 GMT
# notAfter=Mar 28 00:00:00 2027 GMT
# curl로 인증서 검증 없이 테스트
curl -k -u admin:password https://192.168.1.100:5000/v2/
증상
$ docker push localhost:5001/myapp:v1
Error response from daemon: unexpected status code 401 Unauthorized
# 또는
denied: requested access to the resource is denied
# 또는
no basic auth credentials
원인별 진단
원인 1: docker login을 하지 않은 경우
# ~/.docker/config.json에 해당 레지스트리 항목 확인
cat ~/.docker/config.json | python3 -m json.tool
# {
# "auths": {} ← localhost:5001 항목 없음
# }
# 해결: 로그인
docker login localhost:5001
# Username: admin
# Password: (입력)
# Login Succeeded
원인 2: 잘못된 자격증명 (htpasswd 파일 내용과 불일치)
# 현재 htpasswd 파일 내용 확인
cat ~/registry-lab/auth/htpasswd
# htpasswd로 비밀번호 직접 검증
htpasswd -v ~/registry-lab/auth/htpasswd admin
# Password: (입력)
# Password for user admin correct. # 맞음
# Password for user admin incorrect. # 틀림 → 파일 재생성 필요
# htpasswd 파일 재생성
htpasswd -Bbn admin "NewPassword123!" > ~/registry-lab/auth/htpasswd
docker-compose restart registry
원인 3: htpasswd 파일이 bcrypt 해시가 아닌 경우
cat ~/registry-lab/auth/htpasswd
# admin:{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g= ← SHA1, Registry 2.0은 bcrypt 필요!
# 해결: -B 플래그로 bcrypt 사용하여 재생성
htpasswd -Bbn admin "Password123!" > ~/registry-lab/auth/htpasswd
# admin:$2y$05$... ← $2y$ 접두사가 bcrypt 해시임을 나타냄
원인 4: 레지스트리 컨테이너가 htpasswd 파일을 읽지 못하는 경우
# 컨테이너 로그에서 인증 관련 오류 확인
docker logs tls-registry 2>&1 | grep -i "auth\|error\|htpasswd"
# 볼륨 마운트 경로 확인
docker inspect tls-registry --format '{{range .Mounts}}{{.Source}} → {{.Destination}}{{println}}{{end}}'
# /home/user/registry-lab/auth → /auth
# 컨테이너 내부에서 파일 존재 여부 직접 확인
docker exec tls-registry cat /auth/htpasswd
실무 맥락
상황
공공기관 B사의 서비스 서버는 보안 정책상 외부 인터넷과 완전히 차단된 폐쇄망에서 운영됩니다. Docker Hub는 물론 어떤 외부 레지스트리도 접근이 불가능합니다. 새로운 마이크로서비스를 컨테이너로 배포하기 위해 사내 프라이빗 레지스트리가 필요합니다.
폐쇄망 레지스트리 구축 아키텍처
[인터넷 연결 허용 구역] [폐쇄망 내부]
│
개발자 PC ┌──────▼──────────────────┐
│ │ 레지스트리 서버 │
│ (이미지 build) │ 192.168.100.10:5000 │
│ │ (Harbor 또는 registry:2) │
│ ① 이미지를 tar 파일로 └──────┬──────────────────┘
│ 저장 (docker save) │
│ │ pull
▼ ▼
[보안 게이트웨이] [운영 서버들]
② 승인 후 반입 192.168.100.21 ~ .50
③ 레지스트리에 push
폐쇄망 이미지 반입 절차
Step 1: 인터넷 연결 환경에서 이미지 저장
# 필요한 모든 이미지를 tar 파일로 저장
docker pull nginx:1.25-alpine
docker pull postgres:16-alpine
docker pull redis:7-alpine
docker save \
nginx:1.25-alpine \
postgres:16-alpine \
redis:7-alpine \
| gzip > /tmp/base-images-2026-03-28.tar.gz
# 파일 크기 및 체크섬 기록 (무결성 검증용)
sha256sum /tmp/base-images-2026-03-28.tar.gz > /tmp/base-images-2026-03-28.sha256
Step 2: 보안 검사 및 반입
# 반입 전 취약점 스캔 (인터넷 연결 환경에서)
trivy image --exit-code 1 --severity HIGH,CRITICAL nginx:1.25-alpine
trivy image --exit-code 1 --severity HIGH,CRITICAL postgres:16-alpine
# 스캔 결과를 PDF/HTML로 저장하여 보안팀 승인 요청
trivy image --format template \
--template "@contrib/html.tpl" \
--output nginx-scan-report.html \
nginx:1.25-alpine
Step 3: 폐쇄망 레지스트리에 push
# 폐쇄망 레지스트리 서버에서 실행
# tar 파일에서 이미지 로드
docker load < /media/approved/base-images-2026-03-28.tar.gz
# 로드된 이미지 확인
docker images | grep -E "nginx|postgres|redis"
# 사내 레지스트리에 push
docker tag nginx:1.25-alpine registry.internal:5000/base/nginx:1.25-alpine
docker tag postgres:16-alpine registry.internal:5000/base/postgres:16-alpine
docker tag redis:7-alpine registry.internal:5000/base/redis:7-alpine
docker push registry.internal:5000/base/nginx:1.25-alpine
docker push registry.internal:5000/base/postgres:16-alpine
docker push registry.internal:5000/base/redis:7-alpine
Harbor 도입 시 주요 이점
Docker Registry 2.0은 API만 제공하지만, 엔터프라이즈 환경에서는 Harbor 오픈소스 레지스트리를 사용하는 경우가 많습니다.
Harbor 주요 기능:
┌─────────────────────────────────────────────────┐
│ 웹 UI 대시보드 │
│ ├── 이미지 탐색, 태그 관리, 삭제 │
│ └── 리포지토리별 통계 및 접근 로그 │
│ │
│ RBAC (역할 기반 접근 제어) │
│ ├── 프로젝트별 사용자/그룹 권한 설정 │
│ └── Guest / Developer / Maintainer / Admin 역할 │
│ │
│ 취약점 스캔 │
│ ├── Trivy 또는 Clair 내장 연동 │
│ └── push 시 자동 스캔 + 취약점 이미지 차단 │
│ │
│ 이미지 복제 (Replication) │
│ ├── 다른 레지스트리와 자동 동기화 │
│ └── DR(재해 복구) 레지스트리 구성 │
│ │
│ 저장소 할당량 관리 │
│ └── 프로젝트별 최대 저장 용량 설정 │
└─────────────────────────────────────────────────┘
Harbor 빠른 시작 (docker-compose):
# Harbor 설치 스크립트 다운로드 (폐쇄망에서는 인터넷 연결 환경에서 미리 준비)
wget https://github.com/goharbor/harbor/releases/download/v2.10.0/harbor-online-installer-v2.10.0.tgz
tar xvf harbor-online-installer-v2.10.0.tgz
cd harbor
# 설정 파일 수정
cp harbor.yml.tmpl harbor.yml
# harbor.yml에서 hostname, https 인증서 경로, admin 초기 비밀번호 설정
# 설치 (Trivy 취약점 스캔 포함)
./install.sh --with-trivy
# 접속: http://hostname (기본 계정: admin / Harbor12345)
운영 팁: 이미지 보존 정책
레지스트리는 이미지가 쌓일수록 디스크를 소비합니다. 정기적인 정리가 필요합니다.
# Registry 2.0 — 가비지 컬렉션
# 1. 레지스트리를 읽기 전용 모드로 전환
docker-compose exec registry registry garbage-collect --dry-run /etc/docker/registry/config.yml
# → 삭제될 레이어 목록 미리 확인
# 2. 실제 가비지 컬렉션 실행
docker-compose exec registry registry garbage-collect /etc/docker/registry/config.yml
# 특정 태그 삭제 (삭제 API가 활성화된 경우)
# 1. 매니페스트 다이제스트 조회
DIGEST=$(curl -s -u admin:password \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
-I https://localhost:5002/v2/myapp/manifests/v1.0.0 \
| grep Docker-Content-Digest | awk '{print $2}' | tr -d '\r')
# 2. 삭제 요청
curl -s -u admin:password -X DELETE \
https://localhost:5002/v2/myapp/manifests/${DIGEST}
레지스트리 인증과 자격증명 안전하게 관리하기
CI 서버에서 레지스트리에 push하도록 설정했습니다. 편의상 docker login을 실행하고 비밀번호를 입력했습니다. 몇 주 뒤 보안 팀이 연락합니다. ~/.docker/config.json에 비밀번호가 Base64로 저장되어 있고, CI 서버에 접근할 수 있는 사람이라면 누구나 읽을 수 있다는 내용입니다. Base64는 암호화가 아닙니다. 자격증명을 안전하게 저장하고 CI 환경에서 주입하는 방법을 알아야 합니다.

docker login 후 자격증명이 저장되는 곳
# 레지스트리 로그인
docker login myregistry.example.com
# 자격증명 저장 위치 확인
cat ~/.docker/config.json
{
"auths": {
"myregistry.example.com": {
"auth": "dXNlcjpwYXNzd29yZA=="
}
}
}
auth 값은 username:password를 Base64 인코딩한 것입니다. 암호화가 아니라 단순 인코딩이므로 이 파일이 유출되면 자격증명이 노출됩니다.
# 저장된 auth 값 디코딩
echo "dXNlcjpwYXNzd29yZA==" | base64 -d
# user:password ← 평문으로 보임!
CI/CD 환경에서 안전한 로그인
# ❌ 잘못된 방법: 패스워드를 명령줄에 직접 입력 (shell history에 기록됨)
docker login -u myuser -p mypassword myregistry.example.com
# ✅ 올바른 방법: 환경변수 + --password-stdin
echo "$REGISTRY_PASSWORD" | docker login \
-u "$REGISTRY_USER" \
--password-stdin \
myregistry.example.com
레지스트리별 로그인 방법
# GitHub Container Registry (GHCR)
# GitHub Personal Access Token (PAT) 필요 — 권한: read:packages, write:packages
echo "$GITHUB_TOKEN" | docker login ghcr.io \
-u "$GITHUB_USERNAME" \
--password-stdin
# AWS ECR (IAM 인증 — 12시간마다 토큰 갱신 필요)
aws ecr get-login-password --region ap-northeast-2 | \
docker login \
--username AWS \
--password-stdin \
123456.dkr.ecr.ap-northeast-2.amazonaws.com
# GCP Artifact Registry
gcloud auth configure-docker asia-northeast1-docker.pkg.dev
로그아웃 및 자격증명 제거
# 특정 레지스트리 로그아웃
docker logout myregistry.example.com
# 모든 레지스트리 자격증명 제거
rm ~/.docker/config.json
# 또는
cat > ~/.docker/config.json << 'EOF'
{}
EOF
레지스트리 선택 기준 — 상황별 최적 옵션
새 프로젝트를 시작합니다. 이미지를 어디에 올릴지 결정해야 합니다. Docker Hub, GHCR, AWS ECR, Harbor — 이름은 많은데 어떤 기준으로 골라야 할지 막막합니다. 클라우드 환경이냐, 망분리 환경이냐, GitHub를 쓰느냐에 따라 답이 달라집니다. 잘못 고르면 나중에 전체 파이프라인을 바꿔야 하는 상황이 생깁니다.

주요 레지스트리 비교
| 레지스트리 | 비용 | 자체 호스팅 | 특징 | 적합한 상황 |
|---|---|---|---|---|
| Docker Hub | 무료(제한)/유료 | ✗ | 공개 이미지 최다 | 오픈소스, 소규모 |
| GHCR | GitHub 요금제 포함 | ✗ | GitHub Actions 통합 | GitHub 기반 프로젝트 |
| AWS ECR | 스토리지/전송량 과금 | ✗ | IAM 인증, ECS/EKS 통합 | AWS 환경 |
| GCP Artifact Registry | 스토리지/전송량 과금 | ✗ | GKE 통합 | GCP 환경 |
| Azure ACR | 플랜별 정액 | ✗ | AKS 통합 | Azure 환경 |
| Harbor | 무료 (운영 비용) | ✓ | 취약점 스캔, RBAC, 복제 | 기업 내부, 망분리 |
| Docker Registry 2.0 | 무료 (운영 비용) | ✓ | 경량, 기본 기능 | 간단한 내부 배포 |
자체 호스팅이 필요한 경우
# 이럴 때 자체 레지스트리 (Harbor 또는 Registry 2.0) 선택:
# ✓ 망분리(에어갭) 환경 — 외부 레지스트리 접근 불가
# ✓ 데이터 주권 요구사항 — 이미지를 외부 클라우드에 저장 불가
# ✓ 대용량 이미지 빈번한 pull — 내부 bandwidth 절약
# ✓ 취약점 스캔 + 정책 적용 필요
# ✓ 레지스트리 간 복제 (DR 구성)
Harbor vs Docker Registry 2.0 선택
Docker Registry 2.0 선택:
✓ 단순히 이미지 저장/전달만 필요
✓ 팀 규모가 작아 권한 관리가 단순
✓ 빠른 구성 필요
Harbor 선택:
✓ 이미지 취약점 스캔 (Trivy/Clair 통합)
✓ 프로젝트/팀별 접근 권한 관리 (RBAC)
✓ 레지스트리 간 복제 (다중 사이트)
✓ 이미지 서명 (Notary)
✓ 웹 UI가 필요한 경우
✓ 기업 SSO/LDAP 연동 필요
# Harbor 설치 (Docker Compose 기반, 최소 4CPU/8GB 권장)
# https://github.com/goharbor/harbor/releases 에서 offline installer 다운로드
# Docker Registry 2.0 간단 설치
docker run -d \
--name registry \
--restart unless-stopped \
-p 5000:5000 \
-v registry-data:/var/lib/registry \
registry:2
핵심 요약
| 개념 | 명령/설정 | 설명 |
|---|---|---|
| 레지스트리 실행 | docker run -d -p 5000:5000 registry:2 | 기본 레지스트리 컨테이너 시작 |
| 이미지 태그 규칙 | docker tag myapp:v1 HOST:PORT/myapp:v1 | 레지스트리 주소 포함 태그 부여 |
| 이미지 push | docker push HOST:PORT/myapp:v1 | 프라이빗 레지스트리에 업로드 |
| 이미지 pull | docker pull HOST:PORT/myapp:v1 | 프라이빗 레지스트리에서 다운로드 |
| insecure-registry | /etc/docker/daemon.json 에 추가 후 데몬 재시작 | HTTP 레지스트리 허용 (개발용) |
| CA 인증서 신뢰 | /etc/docker/certs.d/HOST:PORT/ca.crt | self-signed 인증서 신뢰 등록 |
| htpasswd 파일 생성 | htpasswd -Bbn user pass > htpasswd | bcrypt 해시로 사용자 생성 |
| 기본 인증 활성화 | REGISTRY_AUTH=htpasswd 환경변수 | 레지스트리 컨테이너 인증 설정 |
| 로그인 | docker login HOST:PORT | 레지스트리에 자격증명 저장 |
| 로그아웃 | docker logout HOST:PORT | 저장된 자격증명 제거 |
| 이미지 목록 조회 | curl http://HOST:PORT/v2/_catalog | 저장된 리포지토리 목록 API |
| 태그 목록 조회 | curl http://HOST:PORT/v2/IMAGE/tags/list | 이미지별 태그 목록 API |
| 취약점 스캔 | trivy image IMAGE:TAG | Trivy로 보안 취약점 검사 |
| 폐쇄망 이미지 반입 | docker save | gzip > file.tar.gz + docker load < file.tar.gz | 오프라인 환경 이미지 이전 |