infra
Platform

모듈 맵

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

0 / 27 완료

펼치기
0 / 27 완료0%

Docker · 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분 만에 구축
  • 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-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 레지스트리는 이미지 레이어를 저장하고 버전별로 관리하는 서버입니다. 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 설정 구조

왜 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 할 수 있게 됩니다.

기본 실습

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 예상)
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
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
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 인증서를 신뢰하지 않습니다. 다음 두 가지 상황에서 발생합니다.

  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

# 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
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무료(제한)/유료공개 이미지 최다오픈소스, 소규모
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오프라인 환경 이미지 이전

지식 확인

퀴즈 — 5문제

Q1

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

Q2

Docker Registry 2.0에서 이미지를 push하기 전에 반드시 수행해야 하는 작업은 무엇인가요?

Q3

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

Q4

Harbor가 Docker Registry 2.0보다 제공하는 추가 기능이 아닌 것은 무엇인가요?

Q5

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

0 / 5 답변

🧪 실습으로 확인하기

Docker Compose 멀티 서비스 구성

초급

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

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

이것도 배워보세요

docker고급 · 65
[Docker] 도커 스웜의 한계와 쿠버네티스(K8s) 전환 로드맵
Docker 트랙 계속
networking입문 · 45
[Network] OSI 7계층과 TCP/IP 4계층 모델 실무적 관점 분석
Networking 트랙 시작점