콘텐츠로 이동

M1. 컨테이너 빌드와 배포 단위

예시 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·환경변수 관찰을 선수 실습으로 사용합니다.

소스가 NKS의 실행 프로세스가 되기까지
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
단계입력만들어지는 상태이번 랩의 증거
buildDockerfile, build contextcontent-addressed image와 tagUSER, CMD, WORKDIR, EXPOSE inspect
runimage, env, port bindingPID 1과 쓰기 레이어를 가진 container실패 exit code, log, 실제 TCP 응답
distributeregistry가 포함된 image reference다른 runtime이 pull할 수 있는 imagepush 후 로컬 tag 제거, pull ID 비교
composeimage와 여러 runtime option을 적은 YAMLproject network와 service containerconfig, 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재료와 조립 순서를 고정한 제조 지시서같은 지시서라도 base image가 움직이면 결과 bytes가 달라집니다.
image개봉 전 봉인된 제품tag는 포장 라벨이며 동일 tag가 항상 같은 내용이라는 보장은 없습니다.
container봉인을 열어 실제로 가동한 제품 한 대실행 중 생긴 파일과 프로세스 상태는 image에 역으로 반영되지 않습니다.
registry주소가 붙은 배포 창고운영 창고에는 인증·TLS·권한·보존 정책이 추가로 필요합니다.
Compose여러 실행 option을 한 번에 재현하는 로컬 운전표Kubernetes가 Compose file을 그대로 실행하는 것은 아닙니다.
항목검증값
기준일2026-07-15
hostmacOS arm64
Docker client / engine29.6.1 / 29.6.1
Docker Compose5.2.0
k3d5.9.0 — 이 모듈에서는 cluster를 사용하지 않음
kubectl1.36.1 — Kubernetes API를 사용하지 않음
Helm4.2.3 — 이 모듈에서는 사용하지 않음
app base imageruby:3.4.7-alpine3.22 + Setup의 고정 digest
registry imageregistry: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 on log가 없으면 중단하고, system process를 임의로 종료하지 않습니다.
  • 이 모듈은 Kubernetes API를 호출하지 않습니다. 현재 kubectl context나 NKS namespace를 읽거나 변경하지 않습니다.
  • 예상 소요 시간은 105분입니다.

정확한 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·inspectuser=app ...Dockerfile과 build output
Terminal window
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 || true
fi
docker rm -f k8s-wb-m1-app k8s-wb-m1-fail k8s-wb-m1-registry \
>/dev/null 2>&1 || true
docker image rm localhost:5000/web-app:m1 k8s-wb/web-app:m1 \
>/dev/null 2>&1 || true
rm -rf /tmp/k8s-wb-m1
mkdir -p /tmp/k8s-wb-m1
printf '%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.rb
printf '%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/Dockerfile
printf '%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.yaml
grep -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.rb
fixture=Dockerfile
fixture=compose.yaml

app은 APP_ENV가 없으면 즉시 실패하고, 있으면 PID 1에서 TCP 4567을 listen합니다. Dockerfile은 별도 app user를 만들고 source 소유권과 runtime user를 함께 고정합니다.

Terminal window
node automation/k8s-workbook/preflight.mjs
docker compose version
docker build --quiet -t k8s-wb/web-app:m1 /tmp/k8s-wb-m1
docker 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/arm64
PASS architecture: arm64
PASS docker-cli: v29.6.1
PASS docker-engine: v29.6.1
PASS k3d: v5.9.0
PASS kubectl-skew: v1.36.1
PASS helm: v4.2.3
Docker Compose version v5.2.0
sha256:<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입니다.

Terminal window
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을 만듭니다.

Terminal window
docker logs k8s-wb-m1-fail 2>&1 | sed -n '1,2p'
docker rm k8s-wb-m1-fail >/dev/null
docker run -d --name k8s-wb-m1-app \
-e APP_ENV=web-app \
-p 127.0.0.1::4567 \
k8s-wb/web-app:m1 >/dev/null
for attempt in 1 2 3 4 5; do
docker logs k8s-wb-m1-app 2>&1 | grep -q '^READY' && break
sleep 1
done
docker port k8s-wb-m1-app 4567/tcp
docker 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-app
READY env=web-app pid=1
REQUEST path=/health

host port는 충돌을 피하려고 Docker가 선택하므로 변동 필드입니다. APP_ENV, container port 4567, PID 1과 응답 body가 판단 기준입니다.

Lab 2 — container 쓰기 레이어의 생명주기 확인하기

섹션 제목: “Lab 2 — container 쓰기 레이어의 생명주기 확인하기”

실행 중인 container에 파일을 쓴 뒤 container를 제거하고 같은 image로 다시 만듭니다.

Terminal window
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/null
docker run -d --name k8s-wb-m1-app \
-e APP_ENV=web-app \
k8s-wb/web-app:m1 >/dev/null
docker exec k8s-wb-m1-app test ! -e /app/runtime-only.txt \
&& echo 'after-recreate=absent'

예상 출력:

before-recreate=present
after-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에 공개하지 않습니다.

Terminal window
docker pull \
registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 \
>/dev/null
docker rm -f k8s-wb-m1-app >/dev/null
docker run -d --name k8s-wb-m1-registry \
-p 127.0.0.1:5000:5000 \
registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 \
>/dev/null
for attempt in 1 2 3 4 5; do
docker logs k8s-wb-m1-registry 2>&1 | grep -q 'listening on' && break
sleep 1
done
docker logs k8s-wb-m1-registry 2>&1 | grep -q 'listening on'
REGISTRY_IMAGE=localhost:5000/web-app:m1
LOCAL_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/null
docker image rm "$REGISTRY_IMAGE" k8s-wb/web-app:m1 >/dev/null
docker pull --quiet "$REGISTRY_IMAGE" >/dev/null
PULLED_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 의도를 재현합니다.

Terminal window
docker compose -p k8s-wb-m1 -f /tmp/k8s-wb-m1/compose.yaml config --services
docker compose --progress quiet -p k8s-wb-m1 \
-f /tmp/k8s-wb-m1/compose.yaml up -d >/dev/null
for 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 1
done
docker compose -p k8s-wb-m1 -f /tmp/k8s-wb-m1/compose.yaml port app 4567
docker 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'

예상 출력:

app
127.0.0.1:<임의 host port>
response=service=compose
app-1 | READY env=compose pid=1
app-1 | REQUEST path=/compose

host port는 변동 필드입니다. app-1은 고정 project k8s-wb-m1 안의 Compose service replica 이름이며 container ID나 IP에 의존하지 않습니다.

Compose project, registry container, 이 랩의 image tag와 /tmp fixture만 제거합니다. base image와 공유 build cache에는 손대지 않습니다.

Terminal window
docker compose --progress quiet -p k8s-wb-m1 \
-f /tmp/k8s-wb-m1/compose.yaml down --remove-orphans >/dev/null
docker rm -f k8s-wb-m1-registry >/dev/null
docker image rm localhost:5000/web-app:m1 k8s-wb/web-app:m1 \
>/dev/null 2>&1 || true
rm -rf /tmp/k8s-wb-m1
CONTAINERS="$(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" = 0
test "$NETWORKS" = 0
test "$IMAGES" = 0
test ! -d /tmp/k8s-wb-m1
echo "cleanup=containers:${CONTAINERS},networks:${NETWORKS},images:${IMAGES},fixture:absent"

예상 출력:

cleanup=containers:0,networks:0,images:0,fixture:absent
명령 이후보게 되는 것운영 판단
docker image inspectUSER, CMD, WORKDIR, EXPOSEnon-root와 실행 metadata가 image에 고정됐는지 봅니다.
env 없는 docker runPID 1의 KeyError와 exit code 1재시작보다 먼저 필수 runtime input 누락을 확인합니다.
docker port4567에 대응하는 임의 loopback host portlisten port, metadata와 host publish를 구분합니다.
container recreateruntime file이 사라지고 image 파일은 다시 보임writable layer를 영속 저장소처럼 쓰지 않습니다.
registry push → tag 제거 → pullpull한 image ID가 build한 ID와 같음이름뿐 아니라 content identity를 확인합니다.
docker compose ... config/upservice 설정이 같은 image를 새 container로 실행Dockerfile과 runtime 설정의 책임을 분리합니다.
Cleanupcontainer·network·image tag·directory가 모두 0다음 모듈에 숨은 상태를 넘기지 않습니다.

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에서 관찰 근거를 쌓은 뒤 결정합니다.

함정 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 환경에서만 검증합니다.

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 내부 구현입니다.