infra
Platform

모듈 맵

[Docker] 사내 프라이빗 레지스트리 구축과 안전한 이미지 관리 방법

0 / 27 완료

펼치기
0 / 27 완료0%

도커 & 컨테이너 · 22 / 27

[Docker] 사내 프라이빗 레지스트리 구축과 안전한 이미지 관리 방법

Docker Registry 2.0과 Harbor로 사내 이미지 저장소를 구축하고 보안 정책을 적용합니다

프라이빗 레지스트리 구축과 이미지 관리

🚨INCIDENT ALERT
HIGH

팀 빌드는 성공했지만 운영 서버는 외부 인터넷이 막혀 있어 Docker Hub에서 이미지를 받을 수 없습니다. 게다가 인증 없는 레지스트리를 임시로 열어두면 내부 이미지가 무단으로 유출될 위험이 생깁니다. 프라이빗 레지스트리는 단순 저장소가 아니라 배포 가용성과 접근 통제를 동시에 보장하는 기반입니다. 이 모듈은 구축부터 인증/TLS, 취약점 점검까지 운영 관점으로 연결합니다.

Docker Hub는 편리하지만 외부 인터넷에 의존한다는 근본적인 한계가 있습니다. 금융, 의료, 국방 등 보안이 중요한 산업에서는 모든 이미지가 사내 네트워크 안에서만 유통되어야 합니다. 또한 CI/CD 파이프라인에서 Docker Hub의 pull rate limit(무료 계정 기준 6시간당 100회)에 걸리면 배포가 멈출 수 있습니다. 프라이빗 레지스트리를 구축하면 이런 제약을 모두 해소하고, 이미지 취약점 스캔과 접근 제어까지 사내 정책으로 적용할 수 있습니다.


이번 챕터에서 배울 것

공식 registry:2 이미지로 즉시 사용 가능한 프라이빗 레지스트리를 구축하고, 인증과 TLS를 적용하여 운영 수준의 이미지 저장소로 발전시키는 전 과정을 실습합니다.

  • 1Docker Registry 2.0 공식 이미지로 로컬 저장소를 5분 만에 구축할 수 있다
  • 2self-signed 인증서를 생성하고 insecure-registry 개발 환경 TLS를 설정할 수 있다
  • 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-lab
openssl 설치 확인 (TLS 인증서 생성에 필요)
openssl version
htpasswd 도구 설치 (Apache 유틸리티)
sudo apt-get install -y apache2-utils || sudo yum install -y httpd-tools
registry:2 이미지 pull
docker pull registry:2
💡개념

Docker Registry 2.0 아키텍처와 이미지 태그 규칙

사내 서버에 빌드한 이미지를 올려두고 다른 서버에서 내려받아야 합니다. Docker Hub는 공개 레지스트리라서 회사 코드가 들어간 이미지를 올릴 수 없고, 인터넷이 차단된 서버 환경에서는 외부 레지스트리 자체를 쓸 수 없습니다. 프라이빗 레지스트리가 필요한 이유입니다. 이 ConceptBlock에서는 Docker 레지스트리가 이미지를 어떻게 저장하고 제공하는지, 이미지 이름 규칙이 무엇인지를 다룹니다.

레지스트리 아키텍처 구조 — 프라이빗 레지스트리는 회사 코드가 든 이미지를 Docker Hub 대신 사내·망분리 환경에 저장. 이미지는 매니페스트(레이어 목록)와 blob(실제 레이어)으로 나뉘어 저장되고, registry-host:port/project/image:tag 형식의 이름 규칙으로 push·pull확대

레지스트리란

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 컨테이너의 v2 HTTP API(매니페스트·레이어 조회/업로드)와 스토리지 드라이버(filesystem·S3·GCS·Azure)가 /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 설정 구조 — 레지스트리는 기본적으로 HTTPS를 요구하므로 TLS 인증서가 없으면 push·pull이 거부됨. 인증서로 레지스트리를 구성하거나, 내부망 테스트에 한해 insecure-registry로 daemon.json에 등록해 HTTP 접근을 허용(운영 비권장)확대

왜 TLS가 필요한가

Docker 클라이언트는 기본적으로 HTTPS(TLS) 연결이 아닌 레지스트리와의 통신을 거부합니다. 비암호화 HTTP로 이미지를 주고받으면 네트워크 상에서 이미지가 탈취되거나 위조될 위험이 크기 때문입니다.

운영 환경에서의 프라이빗 레지스트리 보안 정석

사내 운영 및 엔터프라이즈 환경에서는 보안을 위해 다음과 같은 방법을 적용하여 사설 레지스트리를 암호화하고 관리해야 합니다.

  1. 공인 TLS 인증서 매핑: 도메인이 등록되어 있다면 Let's Encrypt 등의 CA 공인 인증서(domain.crt, domain.key)를 registry:2 컨테이너의 /certs 경로에 바인드 마운트하여 기동시킵니다.
  2. 사내 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"]} 포맷과 대응됩니다.

  1. /etc/docker/daemon.json 생성 또는 수정:
    JSON
    {
      "insecure-registries": [
        "192.168.1.100:5000"
      ]
    }
    
  2. 도커 데몬 재시작:
    로컬 터미널
    $ sudo systemctl restart docker
    
    이 과정을 거치면 TLS 에러 없이 지정된 사내 프라이빗 IP의 5000번 포트로 직접 이미지를 pull/push 할 수 있게 됩니다.

💡개념

docker pull 한 줄로 사설 레지스트리에서 이미지를 받을 때 실제로 무슨 일이 일어나나 — 로그인부터 로컬 저장까지 5단계

docker pull registry.company.com/team/api:1.4.0 한 줄. Enter를 누르면 레이어가 주르륵 내려오고 Status: Downloaded가 뜹니다. 이 짧은 순간에 클라이언트와 레지스트리 사이에서는 주소 파싱 → HTTPS 접속 → 인증(토큰 발급) → manifest 조회 → 레이어 다운로드 → 로컬 저장이 순서대로 일어납니다. 이 흐름을 알면 "왜 401이 뜨지", "왜 404가 뜨지"를 단계로 좁혀 진단할 수 있습니다. 사설 레지스트리에서 이미지를 받는 것은 아래 5단계의 결과입니다.

TEXT
[내 PC]  docker pull registry.company.com/team/api:1.4.0
   │
   ① docker login (사전) → 자격 증명을 ~/.docker/config.json에 base64로 저장
   │
   ② 이미지 이름 파싱 → 레지스트리 주소 결정 → HTTPS로 /v2/ 접속
   │     주소를 생략하면 기본값 docker.io(Docker Hub)로 감
   │
   ③ 레지스트리 인증 → 저장된 자격으로 Bearer 토큰 발급 → 이후 요청에 첨부
   │
   ④ manifest 조회 → 레이어 목록·다이제스트 수신 → 각 레이어(blob)를 다이제스트로 다운로드
   │     → 이미 로컬에 있는 레이어는 건너뜀(Already exists)
   │
   ⑤ 로컬 저장 → 레이어를 조립하고 이름:태그를 로컬 이미지로 등록
   ▼
[로컬]  docker images에 registry.company.com/team/api:1.4.0 등장

각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:

단계하는 일여기서 막히면
① 로그인docker login으로 자격을 ~/.docker/config.json에 저장. 이후 pull이 이 자격을 자동으로 씀로그인 안 한 상태로 pull → no basic auth credentials
② 접속·주소 파싱이미지 이름에서 레지스트리 주소를 떼어내 HTTPS로 /v2/ 접속self-signed 인증서 → x509: certificate signed by unknown authority. certs.d에 CA를 등록하거나 개발용은 insecure-registries. 주소 오타면 Docker Hub로 가 엉뚱한 404
③ 인증(토큰)저장된 자격으로 Bearer 토큰을 발급받아 이후 요청에 첨부자격이 틀림 → 401 Unauthorized. 로그인은 됐지만 그 리포지토리 권한이 없음 → 403 denied
④ manifest·레이어이미지 태그의 manifest에서 레이어 목록을 받고 blob을 다이제스트로 내려받음이미지명·태그 오타 → 404 manifest unknown. 전송 끊김 → 레이어 다운로드 중단·재시도
⑤ 로컬 저장레이어를 로컬 스토리지에 조립하고 이름:태그를 등록디스크 부족 → no space left on device

이미지를 못 받을 때 에러 코드가 곧 단계 지도입니다 — 401은 인증(③), 403은 권한(③), 404는 경로·태그(④), x509는 TLS·주소(②)를 가리킵니다. docker pull 대신 curl -v -u user:pass https://registry.company.com/v2/team/api/manifests/1.4.0으로 같은 요청을 직접 던져 보면, 어느 단계에서 어떤 상태 코드로 끊기는지가 그대로 드러나 진단이 빨라집니다.


기본 실습

1registry:2 컨테이너 실행 (인증 없는 개발용)

가장 간단한 형태의 레지스트리를 실행하고 이미지 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
2htpasswd 기본 인증 적용

인증 없는 레지스트리는 누구나 이미지를 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 방식):

YAML
# ~/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
3TLS + 인증 통합 레지스트리 구성

운영 환경에 가까운 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 추가):

YAML
# ~/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 예상)
# 사전에 발급·검증한 CA 인증서를 사용합니다. -k로 TLS 검증을 끄지 않습니다.
curl --cacert /etc/docker/certs.d/localhost:5002/ca.crt https://localhost:5002/v2/
# {"errors":[{"code":"UNAUTHORIZED","message":"authentication required",...}]}

# 인증 포함 HTTPS 접근
curl --cacert /etc/docker/certs.d/localhost:5002/ca.crt -u '<registry-user>:<password-from-secret-manager>' 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
4Trivy로 이미지 취약점 스캔

레지스트리에 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
# apt-key는 폐기되었으므로 keyrings에 키를 저장하고 지문을 확인합니다.
curl -fsSL https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo gpg --dearmor -o /usr/share/keyrings/trivy.gpg
# 설치 문서의 현재 공개 키 지문과 비교한 뒤에만 저장소를 신뢰합니다.
gpg --show-keys --with-fingerprint /usr/share/keyrings/trivy.gpg
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] 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\", \"수정 없음\")}')
"
공식 패키지 저장소의 서명 키를 keyrings에 등록하고 패키지 관리자로 설치합니다(검증되지 않은 curl | sh는 실행하지 않음).
🔍실행 후 확인할 것
  • 에러 코드 먼저: 레지스트리 접근 오류에서 401 Unauthorized는 인증 실패(로그인 필요), 404 Not Found는 이미지나 태그가 존재하지 않음 — 두 에러를 혼동하면 디버깅 방향이 달라짐
  • insecure-registry 필요 여부 판단: docker push 시 "server gave HTTP response to HTTPS client" 에러가 나면 → HTTP 레지스트리인데 TLS를 시도한 것. daemon.json의 insecure-registries에 추가 후 docker 재시작 필요
  • 저장 용량 확인과 구성 조합 해석: curl http://localhost:5000/v2/_catalog로 리포지토리 수 확인 후 ls ~/registry-lab/data로 디스크 사용량 점검 — 리포지토리는 많은데 blobs/sha256 디렉토리가 크면 garbage-collect 실행 시점

트러블슈팅

증상

로컬 터미널
$ 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 인증서를 신뢰하지 않습니다. 다음 두 가지 상황에서 발생합니다.

  1. Self-signed 인증서 — CA가 공개적으로 신뢰받지 않음
  2. 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 인증서 신뢰 추가 (권장)

SSH 접속 후
# 레지스트리 서버에서 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

# 검증한 CA 인증서와 secret manager에서 주입한 자격증명으로 테스트
curl --cacert /etc/docker/certs.d/192.168.1.100:5000/ca.crt -u '<registry-user>:<password-from-secret-manager>' 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

심화 — 레지스트리가 레이어를 저장하는 방식

💡개념

심화: 콘텐츠 주소 저장(content-addressable storage)과 레이어 공유

레지스트리를 '이미지를 폴더에 파일로 쌓아두는 저장소'로 이해하면, 디스크가 왜 안 줄어드는지·왜 같은 레이어를 두 번 안 올리는지가 설명되지 않습니다. 레지스트리는 파일이 아니라 내용 해시로 주소를 매기는 방식으로 저장합니다.

  • blob은 내용의 digest로 저장됩니다: 각 레이어(blob)는 그 내용의 sha256 다이제스트를 키로 blobs/sha256/ 아래 저장됩니다. 내용이 같으면 다이제스트도 같으므로, 서로 다른 이미지가 동일한 베이스 레이어를 쓰면 한 번만 저장하고 공유합니다(중복 제거). push 때 Layer already exists가 뜨는 이유입니다.
  • 매니페스트가 blob을 참조합니다: 이미지는 '레이어 다이제스트 목록 + 설정'을 담은 매니페스트로 표현되고, 태그는 이 매니페스트를 가리키는 이름표입니다. 태그(:latest)는 언제든 다른 매니페스트로 옮겨 붙일 수 있는 가변(mutable) 식별자지만, 다이제스트(@sha256:...)는 내용이 바뀌면 값도 바뀌는 불변(immutable) 식별자입니다. 재현·공급망 보안이 중요하면 태그가 아니라 다이제스트로 고정합니다.
  • 삭제는 두 단계입니다: 매니페스트 DELETE는 '참조'만 지우는 소프트 삭제이고, 참조가 사라진 blob은 garbage-collect를 돌려야 실제 파일이 삭제됩니다. 그래서 태그를 지워도 디스크가 즉시 줄지 않습니다.

정리하면 레지스트리는 '레이어 = 내용 해시로 식별되는 불변 blob', '이미지 = 그 blob들을 엮은 매니페스트', '태그 = 매니페스트를 가리키는 가변 이름표'라는 3층 구조로 동작합니다. 이 구조를 알면 중복 제거·삭제·용량 회수가 한 번에 이해됩니다.

상황: DELETE API로 오래된 태그와 매니페스트를 정리했는데 du -sh data로 재보면 용량이 줄지 않습니다. 계속 push가 들어와 디스크가 위험 수위에 다가갑니다.

원인: 콘텐츠 주소 저장 구조에서 매니페스트 DELETE는 blob을 지우지 않고 참조만 끊는 소프트 삭제입니다. 참조가 사라진 blob은 남아 있다가 garbage-collect를 실행해야 회수됩니다. 게다가 :latest처럼 가변 태그를 계속 덮어쓰면, 밀려난 이전 매니페스트가 untagged(dangling) 상태로 계속 쌓여 용량을 갉아먹습니다.

진단: 리포지토리 수 대비 blob 디렉토리 크기와, GC dry-run으로 회수 가능한 양을 확인합니다.

로컬 또는 서버
# 리포지토리는 몇 개 안 되는데 blob이 크면 회수 대상 누적 신호
curl -s http://localhost:5000/v2/_catalog
du -sh ~/registry-lab/data/docker/registry/v2/blobs

# 무엇이 지워질지 미리 본다 (실제 삭제 아님)
docker-compose exec registry \
  registry garbage-collect --dry-run /etc/docker/registry/config.yml

해결: 삭제 API를 켠(REGISTRY_STORAGE_DELETE_ENABLED=true) 상태에서, 쓰기를 차단하고 GC를 실행합니다. GC 도중 push가 들어오면 방금 올라온 레이어를 참조 없는 것으로 오인해 지워 이미지가 깨질 수 있으므로, 레지스트리를 읽기 전용이나 중단 상태로 두고 돌립니다.

로컬 터미널
# untagged 매니페스트까지 함께 회수
docker-compose exec registry \
  registry garbage-collect --delete-untagged /etc/docker/registry/config.yml

재발 방지로 태그 보존 정책(이미지별 최근 N개만 유지)과 주기적 GC를 스케줄링하고, 재현이 중요한 배포는 가변 태그 대신 다이제스트로 고정합니다.


실무 맥락

💼
실무 맥락외부 인터넷 차단 환경(폐쇄망)에서 사내 레지스트리 구축
현업 패턴

상황

공공기관 B사의 서비스 서버는 보안 정책상 외부 인터넷과 완전히 차단된 폐쇄망에서 운영됩니다. Docker Hub는 물론 어떤 외부 레지스트리도 접근이 불가능합니다. 새로운 마이크로서비스를 컨테이너로 배포하기 위해 사내 프라이빗 레지스트리가 필요합니다.

폐쇄망 레지스트리 구축 아키텍처

폐쇄망 레지스트리 반입 — 개발자 PC가 이미지를 docker save(tar)→보안 게이트웨이 승인 반입→폐쇄망 레지스트리(192.168.100.10:5000)에 push→운영 서버가 pull확대

폐쇄망 이미지 반입 절차

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/config.json에 Base64로 저장하는데 이는 암호화가 아니라 누구나 디코딩 가능. credential helper(OS 키체인·클라우드 IAM 연동)로 저장하거나, CI에서는 시크릿으로 주입하고 액세스 토큰을 사용확대

docker login 후 자격증명이 저장되는 곳

로컬 터미널
# 레지스트리 로그인
docker login myregistry.example.com

# 자격증명 저장 위치 확인
cat ~/.docker/config.json
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 연동), AWS ECR(AWS 환경·IAM 통합), Harbor(망분리·온프레미스·취약점 스캔) 중 클라우드냐 망분리냐, 어떤 CI/플랫폼을 쓰느냐에 따라 선택. 잘못 고르면 파이프라인 전체를 바꿔야 함확대

주요 레지스트리 비교

레지스트리비용자체 호스팅특징적합한 상황
Docker Hub무료(제한)/유료공개 이미지 최다오픈소스, 소규모
GHCRGitHub 요금제 포함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 연동 필요
Docker
# 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레지스트리 주소 포함 태그 부여
이미지 pushdocker push HOST:PORT/myapp:v1프라이빗 레지스트리에 업로드
이미지 pulldocker pull HOST:PORT/myapp:v1프라이빗 레지스트리에서 다운로드
insecure-registry/etc/docker/daemon.json 에 추가 후 데몬 재시작HTTP 레지스트리 허용 (개발용)
CA 인증서 신뢰/etc/docker/certs.d/HOST:PORT/ca.crtself-signed 인증서 신뢰 등록
htpasswd 파일 생성htpasswd -Bbn user pass > htpasswdbcrypt 해시로 사용자 생성
기본 인증 활성화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:TAGTrivy로 보안 취약점 검사
폐쇄망 이미지 반입docker save | gzip > file.tar.gz + docker load < file.tar.gz오프라인 환경 이미지 이전

다음 모듈에서는 BuildKit 캐시 마운트와 레이어 캐싱 전략으로 대규모 빌드 속도를 획기적으로 단축하는 튜닝 기법을 다룹니다.

클라우드의 관리형 레지스트리(ECR)와 이미지 서명·스캔으로 안전하게 배포하는 흐름은 컨테이너 이미지 보안에서, 그 이미지를 오케스트레이터로 운영하는 법은 매니지드 컨테이너(Cloud Engineering 트랙)에서 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

Docker 클라이언트에서 HTTP(비암호화) 프라이빗 레지스트리를 사용하려면 어떻게 설정해야 하나요?

Q2

사내 프라이빗 레지스트리(192.168.1.100:5000)를 구축했습니다. 로컬에서 빌드한 myapp:v1 이미지를 이 레지스트리에 push하려 합니다. `docker push myapp:v1`을 실행했더니 실패합니다. 원인과 해결 방법은?

Q3

docker-compose로 실행한 Registry 2.0 컨테이너에 htpasswd 인증을 적용할 때 필요한 환경변수는 무엇인가요?

Q4

보안팀이 'push된 이미지에 CVE 취약점이 있어도 배포가 차단되지 않는다'고 지적했습니다. 현재는 Docker Registry 2.0을 사용 중입니다. 이미지 push 시 자동으로 취약점을 스캔하고 심각한 취약점이 있으면 배포를 차단하는 정책을 구현하려면 어떻게 해야 하나요?

Q5

폐쇄망(인터넷 차단) 환경에서 사내 프라이빗 레지스트리를 구축하는 주된 이유는 무엇인가요?

Q6

사내 레지스트리를 고를 때 단순한 Docker Registry 2.0, Harbor, 클라우드 관리형(ECR/GCR/ACR) 중 무엇을 쓸지 가르는 기준은?

Q7

[심화] 프라이빗 레지스트리에 서로 다른 두 이미지를 push했더니 두 번째 push에서 대부분의 레이어가 Layer already exists로 표시됐습니다. 그 이유는?

Q8

[심화] 삭제 API로 오래된 태그를 여러 개 지웠는데 레지스트리 서버의 디스크 사용량이 전혀 줄지 않습니다. 원인과 다음 조치로 옳은 것은?

0 / 8 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

docker-compose.yml로 nginx + 앱 컨테이너를 함께 정의하고, 서비스 간 통신과 볼륨 마운트를 구성한다.

35📋 4단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요