개인 docker hub 만들기
댓글 0
포스트 내용에 대한 의견이나 질문을 남길 수 있습니다. 답글과 좋아요는 로그인한 사용자 기준으로 기록됩니다.
이전 글과 다음 글
현재 글을 기준으로 앞뒤 글을 바로 이동해서 볼 수 있습니다.
Loading Services...
포스트 내용에 대한 의견이나 질문을 남길 수 있습니다. 답글과 좋아요는 로그인한 사용자 기준으로 기록됩니다.
현재 글을 기준으로 앞뒤 글을 바로 이동해서 볼 수 있습니다.

Docker Hub에 올린 개인 이미지를 계속 쓰는 게 찝찝했다.
공개 저장소로 둔 것도 있고, 나중에 실수로 민감한 이미지가 올라갈 수도 있고. 그래서 그냥 내부망에 Docker Registry 하나 두고, 필요할 때만 외부 도메인으로 push/pull 가능하게 만들기로 했다.
처음 생각은 되게 단순했다. Registry 컨테이너 하나 띄우면 끝 아닌가. 근데 하다 보니 expose랑 ports부터 헷갈렸고, HTTPS를 안 쓰면 각 Docker 호스트마다 설정을 건드려야 한다는 것도 뒤늦게 알았다. 결국 문서 안 읽고 감으로 시작한 삽질이었다.
처음엔 registry 설정 파일, 인증 파일, nginx 예제, 스크립트까지 한꺼번에 만들었다. 운영 환경까지 생각하면 나쁜 방향은 아닌데, 당장 필요한 건 내부망에서 pull/push 되는지 확인하는 거였다.
그때 compose는 이 정도면 충분했다.
services:
registry:
image: registry:3
container_name: docker-registry
restart: unless-stopped
ports:
- "5000:5000"
environment:
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
REGISTRY_STORAGE_DELETE_ENABLED: "true"
volumes:
- ./data:/var/lib/registry여기서 ./data:/var/lib/registry가 바인드 마운트다. Registry에 push한 이미지 레이어는 호스트의 data 디렉터리에 쌓인다. 처음엔 이걸 보고 Docker volume을 안 쓰냐고 생각했는데, 오히려 registry는 백업이나 용량 확인 때문에 명시적인 bind mount가 더 편했다.운영으로 오래 쓸 거면 repo 밑 ./data보다 [내부경로] 같은 경로로 빼는 게 더 깔끔하긴 하다. 나는 일단 테스트와 초기 이전이 목적이라 로컬 디렉터리로 뒀다.
어떤 예제에서는 이렇게 되어 있었다.
expose:
- "5000"처음엔 이게 더 좋아 보였다. 뭔가 덜 열리는 느낌이니까.
근데 내 구조에서는 nginx 게이트웨이가 다른 호스트에 있었다.
구조가 이랬다.
내부 Docker 호스트들
-> registry 호스트:5000
외부 사용자
-> nginx 게이트웨이
-> registry 호스트:5000expose는 같은 Docker 네트워크 안의 컨테이너끼리 통신할 때 의미가 크다. 다른 물리 호스트의 nginx가 붙어야 하면 호스트 포트를 열어야 한다. 그래서 ports가 맞았다.처음엔 내부 IP에만 바인딩할까도 생각했다.
ports:
- "[내부경로]:5000:5000"근데 결국 단순하게 갔다.ports:
- "5000:5000"대신 UFW로 내부망만 허용했다.sudo ufw allow from [내부경로] to any port 5000 proto tcp
sudo ufw deny 5000/tcp다만 Docker가 iptables를 직접 만지는 경우가 있어서 UFW만 믿으면 살짝 찝찝하다. 이건 Docker + UFW 조합에서 은근 자주 나오는 얘기다. 그래도 내 환경에서는 내부망 위주였고, 나중에 외부는 nginx만 통과시키는 구조라 일단 이렇게 갔다.확인은 단순했다.
curl -I http://localhost:5000/v2/
curl -I http://[내부경로]:5000/v2/응답이 이렇게 오면 registry API는 살아있는 거다.HTTP/1.1 200 OK
Docker-Distribution-Api-Version: registry/2.0여기서 200을 봤을 때는 좀 허무했다. 그렇게 머리 싸맬 필요 없이 그냥 5000 열면 되는 거였다.내부망에서 http://[내부경로]:5000으로 쓰면 Docker는 기본적으로 싫어한다. Docker는 registry가 HTTPS라고 가정한다. HTTP registry를 쓰려면 각 호스트마다 daemon 설정에 넣어줘야 한다.
{
"insecure-registries": ["[내부경로]:5000"]
}그리고 Docker 재시작.sudo systemctl restart docker이게 생각보다 귀찮았다. 내부망 호스트가 한두 대면 상관없는데, 앞으로 늘어날 수도 있고 매번 서버마다 daemon 건드리는 게 별로였다.그래서 결국 도메인 + HTTPS 방향으로 넘어갔다.
귀찮음이 사라지는 건 아니고 종류가 바뀐다. insecure-registries 대신 각 호스트에서 docker login을 한 번 해야 한다.
docker login [도메인]이렇게 로그인하면 이후에는 HTTPS registry로 평범하게 push/pull 할 수 있다.docker pull [도메인]/myapp:latest
docker push [도메인]/myapp:latestDocker가 로그인 정보를 사용자 홈의 Docker 설정 파일에 저장한다는 경고를 띄우긴 한다. 개인 서버에서는 일단 감수했다. 찝찝하면 credential helper를 붙이면 되는데, 그건 이 작업의 본질은 아니었다.Docker Hub에 있던 이미지는 한 번 pull 해서 내부 registry로 다시 push했다.
docker pull [개인정보]/first-license:latest
docker tag [개인정보]/first-license:latest localhost:5000/first-license:latest
docker push localhost:5000/first-license:latest처음 성공했을 때는 이런 줄이 나왔다.latest: digest: sha256:[API_KEY] size: 4286이 줄이 나오면 push 완료다.태그가 latest가 없는 이미지도 있었다. 그때는 Docker Hub 태그 API로 확인했다.
curl -s https://[도메인]/v2/repositories/[개인정보]/hi-crawl/tags | jq -r '.results[].name'실제로는 3.3, 3.2, 3.1 같은 태그만 있고 latest가 없었다. 그래서 원하는 버전을 pull/push한 다음 내부 registry에서 latest 태그를 따로 붙였다.docker pull localhost:5000/hi-crawl:3.3
docker tag localhost:5000/hi-crawl:3.3 localhost:5000/hi-crawl:latest
docker push localhost:5000/hi-crawl:latest이미 올라간 레이어라서 이때는 대부분 Layer already exists로 끝났다. 처음부터 latest를 당연히 있을 거라고 생각한 게 두 번째 실수였다. 태그는 믿으면 안 된다. 봐야 한다.현재 registry에 뭐가 올라왔는지는 API로 확인했다.
curl -s http://localhost:5000/v2/_catalog
curl -s http://localhost:5000/v2/_catalog \
| jq -r '.repositories[]?' \
| while read -r repo; do
printf '%s: ' "$repo"
curl -s "http://localhost:5000/v2/$repo/tags/list" \
| jq -r '.tags // [] | join(", ")'
done이런 식으로 repository와 tag가 보이면 된다.first-license: latest
goals-node: latest
hi-crawl: 3.3, latest
html2img: latest
multistage: latest
next-test: latest
node-exam1: latest기본 Docker Registry에는 관리자 페이지가 없다. 그냥 API 서버다.
처음엔 웹에서 목록 정도는 보여줄 줄 알았는데 안 보여준다. 그래서 별도 UI 컨테이너를 붙였다.
registry-ui:
image: joxit/docker-registry-ui:latest
container_name: docker-registry-ui
restart: unless-stopped
ports:
- "5001:80"
environment:
SINGLE_REGISTRY: "true"
REGISTRY_TITLE: Private Registry
DELETE_IMAGES: "true"
SHOW_CONTENT_DIGEST: "true"
NGINX_PROXY_PASS_URL: http://registry:5000
depends_on:
- registry여기서 중요한 건 NGINX_PROXY_PASS_URL이다.NGINX_PROXY_PASS_URL: http://registry:5000registry는 compose 서비스 이름이다. 같은 compose 네트워크 안에서는 이 이름으로 접근된다. 중간에 NGINX_RESOLVER: 127.0.0.11도 넣을까 했는데, 지금 구조에서는 굳이 필요 없다고 보고 뺐다. 단순한 게 낫다.UI는 내부망에서는 편한데 외부에 그대로 열기는 싫었다. 삭제 기능도 켜져 있었고, registry API보다 더 위험하게 느껴졌다. 그래서 외부 nginx에는 UI를 안 열기로 했다.
같은 호스트에서 localhost:5000으로 push하면 당연히 빠를 줄 알았다.
근데 큰 이미지는 오래 걸렸다.
특히 HTML을 이미지로 만드는 용도의 이미지가 있었는데, 레이어 하나가 2GB가 넘었다.
Pushing [=======> ] 311.4MB/2.059GB처음엔 왜 localhost인데 이렇게 느리지 싶었다. 근데 Docker push는 파일 복사가 아니었다.Docker 이미지 레이어 읽기
-> localhost registry로 HTTP 업로드
-> registry가 digest 검증
-> registry data 디렉터리에 blob 저장
-> 마지막에 manifest 저장같은 디스크에서 읽고, 같은 디스크에 다시 쓰고, 체크섬 계산까지 한다. CPU도 먹고 디스크도 쓴다. 실제로 dockerd가 순간적으로 CPU를 꽤 잡아먹었다.디스크 상태도 봤다.
docker system df
df -hT
lsblk
iostat -xz 1
docker stats docker-registry대충 이런 상태였다.CPU: 오래된 4코어 8스레드 노트북 CPU
RAM: 16GB
Disk: SATA SSD
Network: 1Gbps
Registry data: 아직 작음
Build cache: 생각보다 큼디스크가 완전히 꽉 막힌 건 아니었다. 큰 레이어를 처음 registry에 적재하는 시간이 오래 걸렸던 거다. 한 번 올라간 레이어는 다음 push부터 Layer already exists가 떠서 훨씬 빨랐다.외부 도메인은 nginx 게이트웨이에서 처리했다. Registry 호스트는 계속 HTTP로 두고, nginx에서 HTTPS와 Basic Auth를 맡겼다.
대략 이런 구조다.
https://[도메인]
-> nginx gateway
-> http://[내부경로]:5000nginx 설정은 /v2/만 열고 나머지는 닫는 방식으로 갔다. Docker Registry API는 /v2/를 쓰니까 그 외 경로는 굳이 보여줄 이유가 없었다.server {
listen 80;
listen [::]:80;
server_name [도메인];
location = /v2 {
return 301 https://$server_name/v2/;
}
location /v2/ {
return 301 https://$server_name$request_uri;
}
location / {
return 444;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name [도메인];
ssl_certificate [내부경로]/fullchain.pem;
ssl_certificate_key [내부경로]/privkey.pem;
client_max_body_size 0;
proxy_connect_timeout 60s;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_request_buffering off;
proxy_buffering off;
location = /v2 {
return 301 /v2/;
}
location /v2/ {
auth_basic "Private Docker Registry";
auth_basic_user_file [내부경로];
proxy_pass http://[내부경로]:5000;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
proxy_set_header Authorization $http_authorization;
proxy_pass_header Authorization;
proxy_hide_header Docker-Distribution-API-Version;
add_header Docker-Distribution-API-Version registry/2.0 always;
}
location / {
return 444;
}
}여기서 client_max_body_size 0이 중요하다. 이미지 레이어가 크면 nginx body size 제한에 걸릴 수 있다. proxy_request_buffering off도 push할 때 nginx가 업로드를 디스크에 다 받아놓고 upstream으로 넘기는 일을 줄이려고 넣었다.Basic Auth 파일은 htpasswd 해시로 만들었다. 비밀번호 평문은 넣지 않는다.
printf 'username:%s\n' '[API_KEY]' | sudo tee [내부경로] >/dev/null
sudo chmod 640 [내부경로]
sudo nginx -t
sudo systemctl reload nginx여기서도 조심할 게 있었다. nginx worker 유저가 nginx인지 www-data인지에 따라 htpasswd 파일 group 권한이 달라질 수 있다. 이건 서버마다 다르다. 모르면 확인해야 한다.grep '^user' [내부경로]이런 사소한 권한 문제 때문에 nginx -t는 통과하는데 실제 요청은 500이 나는 경우도 있다. 안 겪고 싶다. 근데 언젠가 겪게 된다.인증을 걸었으니 브라우저나 curl로 그냥 때리면 200이 나오면 안 된다.
curl -I https://[도메인]/v2/기대값은 401이다.HTTP/2 401
WWW-Authenticate: Basic ...
Docker-Distribution-API-Version: registry/2.0처음 보면 401이라 실패처럼 보이는데, 이 경우엔 성공이다. 인증 없이 접근을 막고 있다는 뜻이다.Docker CLI에서는 로그인한다.
docker login [도메인]그리고 push/pull.docker tag myapp:latest [도메인]/myapp:latest
docker push [도메인]/myapp:latest
docker pull [도메인]/myapp:latest각 Docker 호스트마다 한 번씩 login해야 한다.
이게 좀 귀찮긴 한데, HTTP registry 때문에 daemon 설정 바꾸고 Docker 재시작하는 것보다는 낫다.
처음에는 registry 호스트에서 localhost:5000/...로 태그를 붙였다. 동작은 잘 된다.
근데 Docker 이미지 목록에 계속 localhost:5000/...가 보이니까 괜히 킹받았다.
이럴 때는 내부망 주소나 도메인으로 태그를 다시 붙이고 localhost 태그를 지우면 된다.
docker tag localhost:5000/myapp:latest [도메인]/myapp:latest
docker rmi localhost:5000/myapp:latest이건 레이어 삭제가 아니라 태그 정리에 가깝다. 같은 image ID에 이름만 여러 개 붙은 상태면 하나 지워도 실제 데이터는 남아 있다.Docker Hub에서 가져온 원래 태그도 내부 registry로 옮겼으면 지워도 된다.
docker rmi [개인정보]/myapp:latest다만 태그만 지운다고 항상 공간이 확 줄지는 않는다. 같은 레이어를 다른 태그가 참조하고 있으면 그대로 남는다. 실제로 공간을 보려면 이쪽이 더 정확하다.docker system df내 경우에는 이미지보다 build cache가 더 컸다.Images: 약 몇 GB
Build Cache: 생각보다 큼
Registry data: 아직 작음공간이 부족하면 먼저 이 정도만 고려하면 된다.docker builder prunedocker builder prune -a는 더 많이 지우지만 다음 빌드가 많이 느려질 수 있다. 지금 당장 디스크가 넉넉하면 굳이 세게 밀 필요는 없었다.내부망에서는 여전히 registry 호스트의 5000번을 쓸 수 있다.
외부나 다른 호스트에서는 HTTPS 도메인으로 접근한다.
내부망 직접 접근
http://[내부경로]:5000
외부/공용 접근
https://[도메인]외부는 Basic Auth가 걸려 있고, Docker 호스트마다 docker login을 한 번 해야 한다. UI는 외부에 안 열었다. 열 수는 있는데, 삭제 기능까지 있는 화면을 굳이 인터넷에 올리고 싶지는 않았다.처음엔 Docker Registry 하나 띄우는 게 뭐 어렵겠냐 싶었다.
실제로 registry 자체는 어렵지 않았다. 헷갈린 건 그 주변이었다. expose와 ports, HTTP registry의 daemon 설정, nginx body 제한, Basic Auth, 그리고 push가 느린 이유 같은 것들.
뭐랄까, registry는 그냥 창고인데 창고 문을 어디에 내고, 자물쇠를 어디에 걸고, 누가 어떤 길로 들어오게 할지가 일이었다.
내부 registry가 살아난 다음에는 외부에서 쓸 도메인을 붙였다. 처음엔 예제 설정을 거의 그대로 가져오면 될 줄 알았다. 그런데 개인 registry는 생각보다 조심할 게 많았다.
가장 중요한 건 /v2/만 열어야 한다는 거였다. Docker Registry API는 /v2/ 아래를 쓴다. UI는 따로 열지 않았고, 루트나 이상한 경로는 전부 닫았다.
location = /v2 {
return 301 /v2/;
}
location /v2/ {
auth_basic "Private Docker Registry";
auth_basic_user_file [내부경로]/.htpasswd-registry;
proxy_pass http://[내부경로]:5000;
}
location / {
return 444;
}처음엔 /를 404로 둘까 했다. 근데 Cloudflare 프록시를 꺼둔 상태라 nginx가 직접 스캔을 맞는다. 굳이 친절하게 응답해줄 이유가 없었다. 그래서 /v2/ 빼고는 444로 끊었다.대용량 push 때문에 이 설정도 꼭 넣었다.
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off;
proxy_connect_timeout 60s;
proxy_read_timeout 900s;
proxy_send_timeout 900s;client_max_body_size 0은 body 제한을 없애는 거고, buffering off는 nginx가 업로드를 중간에 붙잡고 있지 않게 하는 설정이다. Docker image layer가 커지면 여기서 괜히 막히는 경우가 있다.개인 registry라 인증 없이 열면 안 됐다. 모르는 사람이 push할 수 있으면 그건 그냥 사고다.
인증 파일은 htpasswd 형식으로 만들었다.
printf '[개인정보]:%s\n' '[API_KEY]' | sudo tee [내부경로]/.htpasswd-registry >/dev/null
sudo chown root:nginx [내부경로]/.htpasswd-registry
sudo chmod 640 [내부경로]/.htpasswd-registry여기서 한 번 삽질했다. 경로를 줄바꿈으로 잘못 끊어서 tee가 디렉터리에 쓰려고 했다.tee: [내부경로]: 디렉터리입니다진짜 별 거 아닌데 이런 데서 시간이 새더라. 결국 파일 경로를 한 줄로 제대로 주니까 끝났다.인증이 제대로 걸렸는지는 이걸로 봤다.
curl -I https://[도메인]/v2/정상이라면 200이 아니라 401이 나와야 한다.HTTP/2 401
www-authenticate: Basic realm="Private Docker Registry"
docker-distribution-api-version: registry/2.0이게 처음엔 좀 헷갈렸다. 401이면 실패 아닌가 싶었는데, 무인증 요청이 막힌 거라 이게 정상이다. 인증을 붙이면 200이 나온다.curl -I -u '[개인정보]:[API_KEY]' https://[도메인]/v2/HTTP/2 200
content-type: application/json
docker-distribution-api-version: registry/2.0외부 PC에서는 도메인으로 잘 됐다. 그런데 registry가 있는 서버 자기 자신에서 도메인으로 curl을 치니까 timeout이 났다.
curl -I https://[도메인]/v2/처음엔 nginx 설정이 잘못된 줄 알았다. 근데 같은 서버에서 공인 IP로 나갔다가 다시 집 안으로 들어오는 경로가 막힌 거였다. 이른바 hairpin NAT 문제였다.서버 자기 자신에서는 이렇게 테스트했다.
curl -k -I --resolve [도메인]:443:127.0.0.1 https://[도메인]/v2/Docker도 같은 호스트에서 쓸 거라 /etc/hosts에 로컬로 잡았다.127.0.0.1 [도메인]그러면 흐름이 이렇게 된다.Docker daemon
-> 127.0.0.1:443
-> nginx
-> 내부 registry:5000컨테이너 안의 127.0.0.1이랑 헷갈리면 안 된다. Docker image pull/push는 컨테이너가 하는 게 아니라 Docker daemon이 한다. 이걸 잠깐 헷갈렸다. 컨테이너 안에서 직접 registry API를 때리는 경우랑, 호스트가 이미지를 pull하는 경우는 다르다.이제 기존 Docker Hub namespace를 쓰던 프로젝트도 내부 registry로 바꿨다. 핵심은 이미지 이름이었다.
기존:
[개인정보]/next-test:latest
변경:
[도메인]/next-test:latest스크립트 안에서는 기본 repo 값을 바꿨다.DEFAULT_IMAGE_REPO="[도메인]/next-test"compose도 같이 맞췄다.services:
next-app:
image: ${DEPLOY_IMAGE_REF:-[도메인]/next-test:latest}여기서 또 한 번 착각했다. 빌드 로그에 이런 게 떠서 아직 Docker Hub를 쓰는 줄 알았다.load metadata for docker.io/library/node:20-alpine근데 이건 최종 이미지를 Docker Hub에 올린다는 뜻이 아니었다. Dockerfile의 base image가 node:20-alpine이라 Docker Hub에서 받는 것뿐이었다. push 대상은 따로 봐야 한다.push 단계에서 이쪽으로 가면 맞다.
[도메인]/next-test:latest기존 배포 스크립트는 빌드할 때 고유 태그를 만들고, 동시에 latest도 붙였다. 여기까지는 괜찮다.
문제는 wrapper가 배포할 때 다시 latest를 썼다는 거다.
./scripts/build-push-image.sh
./scripts/deploy-image.sh latest혼자 쓰면 거의 문제 없다. 근데 빌드가 겹치면 방금 내가 만든 이미지가 아니라, 나중에 누군가 덮은 latest가 배포될 수 있다. 확률은 낮아도 찝찝했다.그래서 빌드 스크립트가 실제 이미지 ref를 파일로 남기고, wrapper가 그걸 읽어서 배포하게 바꿨다.
IMAGE_REF_FILE="$(mktemp)"
DEPLOY_IMAGE_REF_OUTPUT_FILE="$IMAGE_REF_FILE" ./scripts/build-push-image.sh
IMAGE_REF="$(sed -n '1p' "$IMAGE_REF_FILE")"
./scripts/deploy-image.sh "$IMAGE_REF" "$STACK_NAME"이제 배포 대상은 latest가 아니라 방금 빌드한 정확한 태그다.[도메인]/next-test:20260604-xxxx-gitsha이런 사소한 차이가 운영에서는 꽤 중요하다. 예전엔 latest면 편하지 않나 싶었는데, 지금 생각하면 편한 대신 추적성이 떨어진다.빌드가 느린 부분은 push보다 Next build 쪽이었다. npm ci는 Docker layer cache가 잘 타고 있었다. 진짜 오래 걸리는 건 npm run build였다.
Dockerfile은 대충 이런 구조였다.
COPY . .
RUN npm run build소스가 조금만 바뀌어도 COPY . . 이후 레이어가 바뀌고, build는 다시 돈다. 이건 어쩔 수 없는 부분이 있다. 그래도 Next cache를 살리면 다음 빌드부터 조금 나아질 수 있다.BuildKit cache mount를 넣었다.
# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/app/.next/cache npm run build
그리고 빌드 스크립트에서 BuildKit을 기본으로 켰다.export DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}"첫 빌드는 별 차이 없을 수 있다. 캐시를 새로 쌓아야 하니까. 두 번째부터 효과가 나오는 쪽이다. 엄청난 마법은 아니고, 반복 배포할 때 조금 덜 답답해지는 정도로 보는 게 맞다.무인증 요청은 401이 나와야 한다.
curl -I https://[도메인]/v2/인증 요청은 200이 나와야 한다.curl -I -u '[개인정보]:[API_KEY]' https://[도메인]/v2/로그인도 한 번 해둔다.docker login [도메인]그리고 실제 프로젝트는 그냥 기존 배포 스크립트를 돌리면 된다../build-push-and-deploy-image.sh
처음엔 registry 하나 붙이는 일이 이렇게 길어질 줄 몰랐다. 내부망이라 쉬울 줄 알았는데, 오히려 내부망이라 더 헷갈리는 지점이 있었다. expose와 ports, HTTP registry, Basic Auth, hairpin NAT, latest 태그까지 하나씩 밟고 나서야 구조가 좀 보였다.