M1. 컨테이너 빌드와 배포 단위
1. 왜 필요한가
섹션 제목: “1. 왜 필요한가”예시 3서비스 시스템의 web-app, api-service, ai-service를 NKS에서 실행하려면 먼저
각 서비스가 재현 가능한 image여야 합니다. 이 모듈에서는 AI가 만든 Dockerfile과 실행
설정을 검토하고, NKS가 pull할 배포 단위가 실제로 같은 image인지 확인하는 손을 만듭니다.
개념 정의의 정본은 Docker Basics입니다. 원본 경로는
content/topics/L5/docker-basics.mdx이며, 이 모듈은 Dockerfile·image·container·registry·
Compose를 다시 정의하지 않고 build, run, push/pull, 실패 복구를 직접 관찰합니다. M0의
프로세스·port·stdout·환경변수 관찰을 선수 실습으로 사용합니다.
2. 핵심 개념
섹션 제목: “2. 핵심 개념”flowchart LR Source["소스 + Dockerfile"] -->|docker build| Image["Image: 불변 배포 단위"] Image -->|tag + push| Registry["Registry: image 저장·전달"] Registry -->|pull| Runtime["Docker / Kubernetes runtime"] Runtime -->|run| Container["Container: 실행 중인 인스턴스"] Compose["Compose: 로컬 runtime 설정"] --> Runtime PodSpec["Pod spec: Kubernetes runtime 설정"] --> Runtime
| 단계 | 입력 | 만들어지는 상태 | 이번 랩의 증거 |
|---|---|---|---|
| build | Dockerfile, build context | content-addressed image와 tag | USER, CMD, WORKDIR, EXPOSE inspect |
| run | image, env, port binding | PID 1과 쓰기 레이어를 가진 container | 실패 exit code, log, 실제 TCP 응답 |
| distribute | registry가 포함된 image reference | 다른 runtime이 pull할 수 있는 image | push 후 로컬 tag 제거, pull ID 비교 |
| compose | image와 여러 runtime option을 적은 YAML | project network와 service container | config, service log, TCP 응답 |
Dockerfile은 build-time 계약이고 Compose와 Pod spec은 runtime 계약입니다. EXPOSE 4567은
image metadata일 뿐 host port를 열지 않습니다. tag는 사람이 바꿀 수 있는 이름이고 image
ID와 registry digest는 content에 연결되므로, “같은 tag인가”와 “같은 bytes인가”를 구분해야
합니다.
시점 의존 설명은 2026-07-15에 다음 공식 1차 자료로 확인했습니다.
- Dockerfile overview: Dockerfile, build context와 image build
- Build, tag and publish: image name 구조와 push
- Port publishing:
EXPOSE와-p, loopback binding 경계 - Docker storage: image layer 위 container writable layer
- Compose build specification: build 설정과 runtime service 구분
- CNCF Distribution local registry:
localhost:5000테스트 registry와 운영 TLS·access control 경계
3. 직관 비유
섹션 제목: “3. 직관 비유”| 개념 | 비유 | 비유의 경계 |
|---|---|---|
| Dockerfile | 재료와 조립 순서를 고정한 제조 지시서 | 같은 지시서라도 base image가 움직이면 결과 bytes가 달라집니다. |
| image | 개봉 전 봉인된 제품 | tag는 포장 라벨이며 동일 tag가 항상 같은 내용이라는 보장은 없습니다. |
| container | 봉인을 열어 실제로 가동한 제품 한 대 | 실행 중 생긴 파일과 프로세스 상태는 image에 역으로 반영되지 않습니다. |
| registry | 주소가 붙은 배포 창고 | 운영 창고에는 인증·TLS·권한·보존 정책이 추가로 필요합니다. |
| Compose | 여러 실행 option을 한 번에 재현하는 로컬 운전표 | Kubernetes가 Compose file을 그대로 실행하는 것은 아닙니다. |
4. 핸즈온 랩
섹션 제목: “4. 핸즈온 랩”검증 환경
섹션 제목: “검증 환경”| 항목 | 검증값 |
|---|---|
| 기준일 | 2026-07-15 |
| host | macOS arm64 |
| Docker client / engine | 29.6.1 / 29.6.1 |
| Docker Compose | 5.2.0 |
| k3d | 5.9.0 — 이 모듈에서는 cluster를 사용하지 않음 |
| kubectl | 1.36.1 — Kubernetes API를 사용하지 않음 |
| Helm | 4.2.3 — 이 모듈에서는 사용하지 않음 |
| app base image | ruby:3.4.7-alpine3.22 + Setup의 고정 digest |
| registry image | registry:2.8.3 + Setup의 고정 multi-platform digest |
| evidence 환경 | local-container; Kubernetes context·namespace는 명시적 N/A |
모든 실행 명령 묶음은 executed-local입니다. 실제 NCP Container Registry 로그인·push,
NKS image pull은 승인된 non-production 환경이 없으므로 실행하지 않습니다. 이 provider 경계는
official-doc-only이며 실제 주소·credential·Secret을 사용하거나 예상 출력을 만들지 않습니다.
사전 조건
섹션 제목: “사전 조건”- 저장소 root에서 실행합니다.
- Docker Desktop daemon이 실행 중이어야 합니다.
- M0를 완료했고 stdout, PID 1, 환경변수와 port를 관찰할 수 있어야 합니다.
localhost:5000은 이 랩의 테스트 registry 전용입니다. macOS의 host listener 목록만으로 Docker Desktop VM의 port 충돌을 단정하지 않습니다. registry container가port is already allocated로 실패하거나listening onlog가 없으면 중단하고, system process를 임의로 종료하지 않습니다.- 이 모듈은 Kubernetes API를 호출하지 않습니다. 현재 kubectl context나 NKS namespace를 읽거나 변경하지 않습니다.
- 예상 소요 시간은 105분입니다.
Setup
섹션 제목: “Setup”정확한 fixture 이름과 /tmp/k8s-wb-m1만 정리한 뒤 app source, Dockerfile과 Compose file을
만듭니다. 긴 명령을 한 덩어리로 추측하지 않도록 생성과 build를 분리합니다.
| 구간 | 성공 checkpoint | 실패하면 먼저 볼 파일 |
|---|---|---|
| app source 생성 | fixture=app.rb | /tmp/k8s-wb-m1/app.rb |
| image recipe 생성 | fixture=Dockerfile | /tmp/k8s-wb-m1/Dockerfile |
| runtime 설정 생성 | fixture=compose.yaml | /tmp/k8s-wb-m1/compose.yaml |
| image build·inspect | user=app ... | Dockerfile과 build output |
if test -f /tmp/k8s-wb-m1/compose.yaml; then docker compose -p k8s-wb-m1 -f /tmp/k8s-wb-m1/compose.yaml \ down --remove-orphans >/dev/null 2>&1 || truefidocker rm -f k8s-wb-m1-app k8s-wb-m1-fail k8s-wb-m1-registry \ >/dev/null 2>&1 || truedocker image rm localhost:5000/web-app:m1 k8s-wb/web-app:m1 \ >/dev/null 2>&1 || truerm -rf /tmp/k8s-wb-m1mkdir -p /tmp/k8s-wb-m1printf '%s\n' \ 'require "socket"' \ '$stdout.sync = true' \ 'env = ENV.fetch("APP_ENV")' \ 'server = TCPServer.new("0.0.0.0", 4567)' \ 'puts "READY env=#{env} pid=#{Process.pid}"' \ 'trap("TERM") { puts "STOP pid=#{Process.pid}"; exit }' \ 'loop do' \ ' client = server.accept' \ ' path = client.gets.to_s.split[1]' \ ' client.gets until $_ == "\r\n"' \ ' body = "service=#{env}\n"' \ ' puts "REQUEST path=#{path}"' \ ' client.write "HTTP/1.1 200 OK\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"' \ ' client.close' \ 'end' > /tmp/k8s-wb-m1/app.rbprintf '%s\n' \ 'FROM ruby:3.4.7-alpine3.22@sha256:2a968472f07d03fd9c9fdfe220881c18fc0b28eb4d514b36b2c2d23810d70b89' \ 'WORKDIR /app' \ 'RUN addgroup -S app && adduser -S -G app app && chown app:app /app' \ 'COPY --chown=app:app app.rb .' \ 'USER app' \ 'EXPOSE 4567' \ 'CMD ["ruby", "app.rb"]' > /tmp/k8s-wb-m1/Dockerfileprintf '%s\n' \ 'services:' \ ' app:' \ ' image: localhost:5000/web-app:m1' \ ' environment:' \ ' APP_ENV: compose' \ ' ports:' \ ' - "127.0.0.1::4567"' > /tmp/k8s-wb-m1/compose.yamlgrep -q 'ENV.fetch' /tmp/k8s-wb-m1/app.rb && echo 'fixture=app.rb'grep -q '^USER app$' /tmp/k8s-wb-m1/Dockerfile && echo 'fixture=Dockerfile'docker compose -f /tmp/k8s-wb-m1/compose.yaml config --quiet \ && echo 'fixture=compose.yaml'예상 출력:
fixture=app.rbfixture=Dockerfilefixture=compose.yamlapp은 APP_ENV가 없으면 즉시 실패하고, 있으면 PID 1에서 TCP 4567을 listen합니다.
Dockerfile은 별도 app user를 만들고 source 소유권과 runtime user를 함께 고정합니다.
node automation/k8s-workbook/preflight.mjsdocker compose versiondocker build --quiet -t k8s-wb/web-app:m1 /tmp/k8s-wb-m1docker image inspect k8s-wb/web-app:m1 \ --format 'user={{.Config.User}} workdir={{.Config.WorkingDir}} cmd={{json .Config.Cmd}} expose={{json .Config.ExposedPorts}}'예상 출력:
PASS platform: darwin/arm64PASS architecture: arm64PASS docker-cli: v29.6.1PASS docker-engine: v29.6.1PASS k3d: v5.9.0PASS kubectl-skew: v1.36.1PASS helm: v4.2.3Docker Compose version v5.2.0sha256:<64자리 image ID>user=app workdir=/app cmd=["ruby","app.rb"] expose={"4567/tcp":{}}image ID와 architecture가 포함된 preflight 값은 host와 build 결과에 따른 변동 필드입니다.
arm64 실측값만 고정하지 않으며, 불변값은 non-root app, /app, exec-form CMD,
4567/tcp metadata입니다. base image를 처음 받는 환경에서는 build 전에 layer download
진행률이 추가될 수 있습니다.
Lab 1 — 필수 환경변수 누락을 실패로 확인하고 복구하기
섹션 제목: “Lab 1 — 필수 환경변수 누락을 실패로 확인하고 복구하기”APP_ENV 없이 container를 실행합니다. ENV.fetch가 실패하면서 PID 1이 exit code 1로
종료되어야 합니다. 다음 명령 묶음 자체의 예상 exit code도 1입니다.
docker run --name k8s-wb-m1-fail k8s-wb/web-app:m1예상 출력과 exit code:
app.rb:3:in 'fetch': key not found: "APP_ENV" (KeyError) from app.rb:3:in '<main>'exit code: 1실패 log를 확인하고 실패 container를 제거한 뒤, 환경변수와 loopback의 임의 host port를
지정해 새 container를 만듭니다. EXPOSE만으로는 host port가 생기지 않고 -p가 실제
binding을 만듭니다.
docker logs k8s-wb-m1-fail 2>&1 | sed -n '1,2p'docker rm k8s-wb-m1-fail >/dev/nulldocker run -d --name k8s-wb-m1-app \ -e APP_ENV=web-app \ -p 127.0.0.1::4567 \ k8s-wb/web-app:m1 >/dev/nullfor attempt in 1 2 3 4 5; do docker logs k8s-wb-m1-app 2>&1 | grep -q '^READY' && break sleep 1donedocker port k8s-wb-m1-app 4567/tcpdocker exec k8s-wb-m1-app ruby -rsocket -e 'socket = TCPSocket.new("127.0.0.1", 4567)socket.write("GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n")puts "response=#{socket.read.lines.last.strip}"socket.close'docker logs k8s-wb-m1-app 2>&1예상 출력:
app.rb:3:in 'fetch': key not found: "APP_ENV" (KeyError) from app.rb:3:in '<main>'127.0.0.1:<임의 host port>response=service=web-appREADY env=web-app pid=1REQUEST path=/healthhost port는 충돌을 피하려고 Docker가 선택하므로 변동 필드입니다. APP_ENV, container port
4567, PID 1과 응답 body가 판단 기준입니다.
Lab 2 — container 쓰기 레이어의 생명주기 확인하기
섹션 제목: “Lab 2 — container 쓰기 레이어의 생명주기 확인하기”실행 중인 container에 파일을 쓴 뒤 container를 제거하고 같은 image로 다시 만듭니다.
docker exec k8s-wb-m1-app sh -c 'echo temporary > /app/runtime-only.txt'docker exec k8s-wb-m1-app test -f /app/runtime-only.txt \ && echo 'before-recreate=present'docker rm -f k8s-wb-m1-app >/dev/nulldocker run -d --name k8s-wb-m1-app \ -e APP_ENV=web-app \ k8s-wb/web-app:m1 >/dev/nulldocker exec k8s-wb-m1-app test ! -e /app/runtime-only.txt \ && echo 'after-recreate=absent'예상 출력:
before-recreate=presentafter-recreate=absent파일이 사라진 것은 image가 바뀌어서가 아니라 이전 container의 writable layer가 제거됐기 때문입니다. 유지해야 하는 데이터는 M10에서 volume 경계를 별도로 다룹니다.
Lab 3 — local registry push/pull 왕복 확인하기
섹션 제목: “Lab 3 — local registry push/pull 왕복 확인하기”공식 Distribution image로 loopback 전용 registry를 열고 app image를 push합니다. local tag를 모두 제거한 뒤 다시 pull하여 content ID가 같은지 비교합니다. 이 registry는 인증과 TLS가 없는 테스트 fixture이며 외부 interface에 공개하지 않습니다.
docker pull \ registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 \ >/dev/nulldocker rm -f k8s-wb-m1-app >/dev/nulldocker run -d --name k8s-wb-m1-registry \ -p 127.0.0.1:5000:5000 \ registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 \ >/dev/nullfor attempt in 1 2 3 4 5; do docker logs k8s-wb-m1-registry 2>&1 | grep -q 'listening on' && break sleep 1donedocker logs k8s-wb-m1-registry 2>&1 | grep -q 'listening on'REGISTRY_IMAGE=localhost:5000/web-app:m1LOCAL_ID="$(docker image inspect k8s-wb/web-app:m1 --format '{{.Id}}')"docker tag k8s-wb/web-app:m1 "$REGISTRY_IMAGE"docker push --quiet "$REGISTRY_IMAGE" >/dev/nulldocker image rm "$REGISTRY_IMAGE" k8s-wb/web-app:m1 >/dev/nulldocker pull --quiet "$REGISTRY_IMAGE" >/dev/nullPULLED_ID="$(docker image inspect "$REGISTRY_IMAGE" --format '{{.Id}}')"test "$LOCAL_ID" = "$PULLED_ID" && echo 'registry-roundtrip=same-image'예상 출력:
registry-roundtrip=same-image처음 실행하면 suppressed progress 외에 Docker의 informational message가 stderr에 추가될 수
있습니다. 판정값은 마지막 줄과 exit code 0입니다. localhost:5000은 fixture 주소이며 실제
NCP registry 주소나 credential을 뜻하지 않습니다.
Lab 4 — 같은 runtime 의도를 Compose로 재현하기
섹션 제목: “Lab 4 — 같은 runtime 의도를 Compose로 재현하기”Compose는 image, env, port binding을 project 단위로 묶습니다. registry에서 pull한 고정
fixture image reference를 사용해 수동 docker run option과 같은 runtime 의도를 재현합니다.
docker compose -p k8s-wb-m1 -f /tmp/k8s-wb-m1/compose.yaml config --servicesdocker compose --progress quiet -p k8s-wb-m1 \ -f /tmp/k8s-wb-m1/compose.yaml up -d >/dev/nullfor attempt in 1 2 3 4 5; do docker compose -p k8s-wb-m1 -f /tmp/k8s-wb-m1/compose.yaml \ logs --no-color app 2>&1 | grep -q 'READY' && break sleep 1donedocker compose -p k8s-wb-m1 -f /tmp/k8s-wb-m1/compose.yaml port app 4567docker compose -p k8s-wb-m1 -f /tmp/k8s-wb-m1/compose.yaml \ exec -T app ruby -rsocket -e 'socket = TCPSocket.new("127.0.0.1", 4567)socket.write("GET /compose HTTP/1.1\r\nHost: app\r\n\r\n")puts "response=#{socket.read.lines.last.strip}"socket.close'docker compose -p k8s-wb-m1 -f /tmp/k8s-wb-m1/compose.yaml \ logs --no-color app 2>&1 | grep -E 'READY|REQUEST'예상 출력:
app127.0.0.1:<임의 host port>response=service=composeapp-1 | READY env=compose pid=1app-1 | REQUEST path=/composehost port는 변동 필드입니다. app-1은 고정 project k8s-wb-m1 안의 Compose service replica
이름이며 container ID나 IP에 의존하지 않습니다.
Cleanup
섹션 제목: “Cleanup”Compose project, registry container, 이 랩의 image tag와 /tmp fixture만 제거합니다. base
image와 공유 build cache에는 손대지 않습니다.
docker compose --progress quiet -p k8s-wb-m1 \ -f /tmp/k8s-wb-m1/compose.yaml down --remove-orphans >/dev/nulldocker rm -f k8s-wb-m1-registry >/dev/nulldocker image rm localhost:5000/web-app:m1 k8s-wb/web-app:m1 \ >/dev/null 2>&1 || truerm -rf /tmp/k8s-wb-m1CONTAINERS="$(docker ps -a --filter name=k8s-wb-m1 \ --format '{{.Names}}' | wc -l | tr -d ' ')"NETWORKS="$(docker network ls --filter name='^k8s-wb-m1_default$' \ --format '{{.Name}}' | wc -l | tr -d ' ')"IMAGES="$(docker image ls \ --filter reference='localhost:5000/web-app:m1' \ --filter reference='k8s-wb/web-app:m1' \ --format '{{.Repository}}:{{.Tag}}' | wc -l | tr -d ' ')"test "$CONTAINERS" = 0test "$NETWORKS" = 0test "$IMAGES" = 0test ! -d /tmp/k8s-wb-m1echo "cleanup=containers:${CONTAINERS},networks:${NETWORKS},images:${IMAGES},fixture:absent"예상 출력:
cleanup=containers:0,networks:0,images:0,fixture:absent5. 관찰 포인트
섹션 제목: “5. 관찰 포인트”| 명령 이후 | 보게 되는 것 | 운영 판단 |
|---|---|---|
docker image inspect | USER, CMD, WORKDIR, EXPOSE | non-root와 실행 metadata가 image에 고정됐는지 봅니다. |
env 없는 docker run | PID 1의 KeyError와 exit code 1 | 재시작보다 먼저 필수 runtime input 누락을 확인합니다. |
docker port | 4567에 대응하는 임의 loopback host port | listen port, metadata와 host publish를 구분합니다. |
| container recreate | runtime file이 사라지고 image 파일은 다시 보임 | writable layer를 영속 저장소처럼 쓰지 않습니다. |
| registry push → tag 제거 → pull | pull한 image ID가 build한 ID와 같음 | 이름뿐 아니라 content identity를 확인합니다. |
docker compose ... config/up | service 설정이 같은 image를 새 container로 실행 | Dockerfile과 runtime 설정의 책임을 분리합니다. |
| Cleanup | container·network·image tag·directory가 모두 0 | 다음 모듈에 숨은 상태를 넘기지 않습니다. |
6. 셀프체크 Q&A
섹션 제목: “6. 셀프체크 Q&A”Q1. Dockerfile에 EXPOSE 4567이 있는데 NKS 이전 점검에서 host 요청이 실패합니다. AI가 EXPOSE를 다시 추가하자고 하면 승인하시겠습니까?
섹션 제목: “Q1. Dockerfile에 EXPOSE 4567이 있는데 NKS 이전 점검에서 host 요청이 실패합니다. AI가 EXPOSE를 다시 추가하자고 하면 승인하시겠습니까?”답: 바로 승인하지 않습니다. EXPOSE는 image metadata이므로 먼저 process가 실제 4567을
listen하는지 확인하고, 로컬 Docker라면 -p, Kubernetes라면 Service의 targetPort와 Pod
port 경로를 확인합니다. 같은 EXPOSE를 다시 적어도 runtime network 경로는 열리지 않습니다.
Q2. CI가 web-app:latest를 push했고 로컬에도 같은 tag가 있습니다. 이것만으로 NKS가 같은 bytes를 실행한다고 판단해도 될까요?
섹션 제목: “Q2. CI가 web-app:latest를 push했고 로컬에도 같은 tag가 있습니다. 이것만으로 NKS가 같은 bytes를 실행한다고 판단해도 될까요?”답: 판단할 수 없습니다. tag는 다른 content를 가리키도록 갱신될 수 있고 node에 cached
image가 있을 수도 있습니다. CI가 기록한 registry digest와 배포 spec이 참조한 digest 또는
고유 불변 tag를 비교하고, 실제 Pod의 image ID까지 확인하는 경로를 선택합니다. 이 랩의
LOCAL_ID = PULLED_ID 비교가 그 판단의 축소판입니다.
Q3. Compose에서 3서비스가 잘 실행되므로 Compose file을 그대로 NKS에 적용하자는 제안을 받았습니다. 무엇을 유지하고 무엇을 다시 설계하시겠습니까?
섹션 제목: “Q3. Compose에서 3서비스가 잘 실행되므로 Compose file을 그대로 NKS에 적용하자는 제안을 받았습니다. 무엇을 유지하고 무엇을 다시 설계하시겠습니까?”답: image reference, env 목록, container port, 시작 명령 같은 runtime 의도는 유지해 검토합니다. Compose project network, host port와 service lifecycle 문법은 Kubernetes의 Deployment, Service, ConfigMap·Secret, probe로 다시 표현해야 합니다. 실제 변환은 M3~M5와 M9에서 관찰 근거를 쌓은 뒤 결정합니다.
7. 흔한 함정
섹션 제목: “7. 흔한 함정”함정 1 — USER를 생략하고 root 실행을 기본값으로 두기
섹션 제목: “함정 1 — USER를 생략하고 root 실행을 기본값으로 두기”base image의 기본 user가 root일 수 있습니다. 이 랩처럼 필요한 디렉터리 소유권을 먼저
정리하고 non-root USER를 image에 고정한 뒤 docker image inspect로 확인합니다. 실제
서비스가 root 권한을 요구한다면 이유와 더 작은 권한 대안을 함께 review해야 합니다.
함정 2 — build argument나 image layer에 Secret 넣기
섹션 제목: “함정 2 — build argument나 image layer에 Secret 넣기”Dockerfile의 ARG, ENV, COPY에 실제 credential을 넣으면 layer와 history에 남을 수
있습니다. 이 워크북은 실제 Secret을 사용하지 않으며 BuildKit secret과 NKS Secret 연동은
심화 경계로만 남깁니다.
함정 3 — localhost:5000 registry를 운영 설계로 복사하기
섹션 제목: “함정 3 — localhost:5000 registry를 운영 설계로 복사하기”이번 registry는 loopback에만 bind한 무인증 HTTP fixture입니다. 외부 접근 registry에는 TLS, authentication, authorization와 보존 정책이 필요하며 실제 NCP registry 구성과 로그인은 승인된 non-production 환경에서만 검증합니다.
8. 다음 모듈 연결 고리
섹션 제목: “8. 다음 모듈 연결 고리”M2에서는 이번에 만든 image·runtime 설정의 구분을 유지한 채 k3d cluster를 만들고,
kubectl로 image를 실행할 Kubernetes object와 실제 상태를 처음 관찰합니다.
심화 또는 제외 범위는 multi-platform buildx, image signing·SBOM·취약점 scanner, production registry TLS·권한 설계, 실제 NCP registry push, Docker storage driver 내부 구현입니다.