Kubernetes Distributions — Build Them Yourself
We moved to OpenShift and it died with permission denied
한국어 원문으로 표시합니다.
이 실습은 OpenShift 가 아니라 k3s 에서 흉내 냅니다
OpenShift 는 단일 노드로 설치해도 최소 8 vCPU·16GB 메모리·120GB 저장소를 요구해(OCP 4.21 문서) 이 VM(8GiB)에서 돌릴 수 없습니다. 그래서 VM 의 k3s v1.36.4+k3s1 위에서, OpenShift 의 restricted-v2 SCC 가 파드에 채워 줄 값(범위의 임의 UID, root 그룹, fsGroup)을 직접 넣어 같은 증상을 만듭니다. k3s 는 SCC 도 프로젝트 주석도 모르므로, 주석은 계산의 근거로만 씁니다 — 마지막 단계에서 그 사실도 확인합니다.
목표
루트 소유 디렉터리에 쓰는 이미지가 임의 UID 에서 권한 거부로 죽는 것을 재현하고, 이미지를 root 그룹·g=u 로 고쳐 범위 안의 어떤 UID 로도 뜨게 만들며, fsGroup·root initContainer·읽기 전용 루트가 이 문제에서 각각 무엇을 하고 무엇을 못 하는지 구분합니다.
왜 중요한가
쿠버네티스에서 잘 돌던 이미지가 OpenShift 에서 처음 죽는 이유의 대부분은 사용자입니다. OpenShift 는 이미지의 USER 를 믿지 않고 프로젝트마다 할당한 큰 UID 로 컨테이너를 돌립니다. 컨테이너 탈출 취약점이 있어도 호스트에서 의미 있는 사용자가 되지 못하게 하려는 설계입니다. 그 사용자는 미리 알 수 없으니 이미지 안 파일의 "소유자" 로 맞출 수 없고, 대신 항상 속하는 root 그룹에 권한을 주는 것이 공식 지침입니다. 파드 설정으로 우회하려는 시도(fsGroup, root 로 chown 하는 initContainer)는 대부분 정책에 막히거나 이미지 디렉터리에는 효과가 없습니다.
단계
- 네임스페이스
ocp-sim을 만들고 라벨pod-security.kubernetes.io/enforce=restricted·pod-security.kubernetes.io/warn=restricted, 주석openshift.io/sa.scc.uid-range=1000680000/10000·openshift.io/sa.scc.supplemental-groups=1000680000/10000을 붙이세요. 그다음/root/ocp/project.json에uid_range(주석 값 그대로),default_uid(restricted-v2 의 MustRunAsRange 가 기본으로 고를 UID),max_uid(범위의 마지막 UID)를 적으세요. /root/ocp/legacy.yaml로 파드legacy를 만드세요. 이미지localhost/ocp-app:legacy(VM 에 미리 넣어 둠), 파드 securityContext 는 runAsNonRoot true·runAsUser 는 1단계의 default_uid·runAsGroup 0·fsGroup 1000680000·seccompProfile RuntimeDefault, 컨테이너는 allowPrivilegeEscalation false·capabilities drop ALL·terminationMessagePolicy: FallbackToLogsOnError. 재시작이 한 번 이상 일어나면/root/ocp/crash.json에uid,restart_count,error(종료 메시지 중 Permission denied 가 든 줄 그대로)를 적으세요.- 레거시 이미지로
sleep 86400만 하는 파드probe를 2단계와 같은 securityContext 로/root/ocp/probe.yaml에 만들어 Ready 로 두세요. 그 안에서 신원을 조사해/root/ocp/identity.json에uid,gid,groups(id -G 의 숫자를 정렬한 배열),whoami_ok(whoami 가 성공하는지),home(HOME 환경 변수 값),home_writable(그 HOME 에 쓸 수 있는지)을 적으세요. /root/ocp/app/Containerfile을 작성해 레거시 이미지와 같은 베이스·스크립트로,/app을 root 그룹(GID 0) 소유로 바꾸고 그룹 권한을 소유자 권한과 같게(g=u) 맞춘 뒤 숫자 USER(0 이 아닌 값)를 지정하세요. buildah 로localhost/ocp-app:fixed를 빌드하고 k3s 의 containerd(k8s.io 네임스페이스)에 넣은 다음,/root/ocp/build.json에tool,image,image_id(k3s crictl inspecti의 status.id),user(이미지 설정의 User)를 적으세요.- 고친 이미지로 파드
fixed-a(runAsUser 는 default_uid)와fixed-b(runAsUser 는 max_uid)를 2단계와 같은 securityContext 로/root/ocp/fixed-a.yaml·/root/ocp/fixed-b.yaml에 만들어 둘 다 Ready 로 두세요./root/ocp/arbitrary.json에 파드 이름을 키로uid(컨테이너 안 id -u)와data(/app/data의소유자UID:그룹GID:권한8진수, 예: 0:0:755 형식)를 적으세요. - 레거시 이미지를 고치지 않고 버티는 두 방법을 시험하세요. (1)
/root/ocp/legacy-emptydir.yaml로 파드legacy-ed(2단계와 같되/app/data에 emptyDirdata마운트)를 만들어 Ready 로 둡니다. (2)/root/ocp/legacy-init.yaml로 root(runAsUser 0) initContainerfix-perms가/app/data를 chown 하는 파드legacy-init을 적용해 출력을/root/ocp/init-denied.txt에 저장합니다./root/ocp/alternatives.json에fsgroup_fixed_image_dir(fsGroup 이 있는 legacy 파드가 이미지 디렉터리 쓰기에 성공했는지),emptydir_data(legacy-ed 안/app/data의그룹GID:권한8진수),root_init_admitted(legacy-init 이 받아들여졌는지)를 적으세요. - 고친 이미지에
readOnlyRootFilesystem: true만 더한 파드ro-bare를/root/ocp/ro-bare.yaml로 만들어 실패를 보고, 같은 설정에 emptyDir 을/app/data(이름 data)와/tmp(이름 tmp)에 마운트하고 환경 변수HOME=/tmp를 준 파드fixed-ro를/root/ocp/fixed-ro.yaml로 만들어 Ready 로 두세요./root/ocp/readonly.json에bare_error(ro-bare 종료 메시지 중 Read-only 가 든 줄 그대로),home(fixed-ro 안 HOME),home_writable,etc_writable(fixed-ro 안 /etc 에 쓸 수 있는지)을 적으세요. /root/ocp/report.json에root_cause(2단계 실패의 원인:image-dir-owner·missing-capability·selinux중 하나),whoami_ok_here(이 k3s 에서 임의 UID 로 whoami 가 되는지),openshift_runtime_adds_passwd_entry(OpenShift 문서가 CRI-O 가 임의 UID 를 /etc/passwd 에 넣어 준다고 하는지),fsgroup_fixes_image_dir,k3s_applies_uid_range(이 k3s 가 runAsUser 없는 파드에 주석의 UID 를 채워 넣는지),uid_range_default(주석으로 계산한 기본 UID),running_uids(지금 Ready 인 fixed-a·fixed-b 의 UID 를 정렬한 배열)를 적으세요.
참고
- kubeconfig:
/etc/rancher/k3s/k3s.yaml. 빌드 도구는 buildah 뿐입니다(docker·podman 없음). - 이미지 옮기기:
buildah push <이미지> docker-archive:<파일>:<이름>→k3s ctr -n k8s.io images import <파일>→k3s crictl images - 종료 메시지:
kubectl -n ocp-sim get pod <이름> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.message}' - 흔한 실수: 이미지 안에서
chown 1001처럼 특정 UID 에 맞추는 것. OpenShift 는 그 UID 로 돌리지 않습니다. - 흔한 실수: 같은 태그로 다시 빌드한 뒤 k3s 에 가져오지 않는 것. 파드는 containerd 에 있는 옛 이미지를 씁니다.
- OCP 4.21 이미지 작성 지침 · OCP 4.19 SCC 관리
OpenShift 프로젝트처럼 네임스페이스를 꾸민다
네임스페이스 ocp-sim 을 만들고 라벨 pod-security.kubernetes.io/enforce=restricted·pod-security.kubernetes.io/warn=restricted, 주석 openshift.io/sa.scc.uid-range=1000680000/10000·openshift.io/sa.scc.supplemental-groups=1000680000/10000 을 붙이세요. 그다음 /root/ocp/project.json 에 uid_range(주석 값 그대로), default_uid(restricted-v2 의 MustRunAsRange 가 기본으로 고를 UID), max_uid(범위의 마지막 UID)를 적으세요.
uid-range 주석은 <시작>/<길이> 블록 하나만 받습니다. MustRunAsRange 는 범위의 최솟값을 기본값으로 씁니다. 이 VM 의 k3s 는 이 주석을 읽지 않으므로, 이후 단계에서 그 값을 파드에 직접 넣습니다.
OpenShift 로 옮겼더니 권한 거부로 죽었다
/root/ocp/legacy.yaml 로 파드 legacy 를 만드세요. 이미지 localhost/ocp-app:legacy(VM 에 미리 넣어 둠), 파드 securityContext 는 runAsNonRoot true·runAsUser 는 1단계의 default_uid·runAsGroup 0·fsGroup 1000680000·seccompProfile RuntimeDefault, 컨테이너는 allowPrivilegeEscalation false·capabilities drop ALL·terminationMessagePolicy: FallbackToLogsOnError. 재시작이 한 번 이상 일어나면 /root/ocp/crash.json 에 uid, restart_count, error(종료 메시지 중 Permission denied 가 든 줄 그대로)를 적으세요.
restricted-v2 SCC 가 OpenShift 에서 채워 줄 값을 여기서는 손으로 넣습니다. 레거시 이미지의 Containerfile 은 /root/ocp/app/Containerfile.legacy 에 있습니다. FallbackToLogsOnError 를 쓰면 로그 끝부분이 파드 상태의 종료 메시지로 남아, 컨테이너가 다시 떠도 사라지지 않습니다.
/etc/passwd 에 없는 사용자로 산다는 것
레거시 이미지로 sleep 86400 만 하는 파드 probe 를 2단계와 같은 securityContext 로 /root/ocp/probe.yaml 에 만들어 Ready 로 두세요. 그 안에서 신원을 조사해 /root/ocp/identity.json 에 uid, gid, groups(id -G 의 숫자를 정렬한 배열), whoami_ok(whoami 가 성공하는지), home(HOME 환경 변수 값), home_writable(그 HOME 에 쓸 수 있는지)을 적으세요.
containerd 는 이미지의 /etc/passwd 에 없는 UID 로 컨테이너를 띄워도 막지 않지만 이름과 홈 디렉터리를 알려 줄 수 없습니다. 문서에 따르면 OpenShift 의 CRI-O 는 임의 UID 항목을 /etc/passwd 에 넣어 줍니다 — 여기서 보는 모습이 그 차이입니다. 쓰기 가능 여부는 test -w 로 확인하세요.
이미지 쪽에서 고친다: root 그룹과 g=u
/root/ocp/app/Containerfile 을 작성해 레거시 이미지와 같은 베이스·스크립트로, /app 을 root 그룹(GID 0) 소유로 바꾸고 그룹 권한을 소유자 권한과 같게(g=u) 맞춘 뒤 숫자 USER(0 이 아닌 값)를 지정하세요. buildah 로 localhost/ocp-app:fixed 를 빌드하고 k3s 의 containerd(k8s.io 네임스페이스)에 넣은 다음, /root/ocp/build.json 에 tool, image, image_id(k3s crictl inspecti 의 status.id), user(이미지 설정의 User)를 적으세요.
buildah 가 만든 이미지는 buildah 저장소에만 있습니다. buildah push <이미지> docker-archive:<파일>:<이름> 으로 꺼낸 뒤 k3s ctr -n k8s.io images import <파일> 로 넣습니다. 실행 파일도 그룹 실행 권한이 있어야 임의 UID 가 실행할 수 있습니다.
범위 안의 어떤 UID 로도 뜨는가
고친 이미지로 파드 fixed-a(runAsUser 는 default_uid)와 fixed-b(runAsUser 는 max_uid)를 2단계와 같은 securityContext 로 /root/ocp/fixed-a.yaml·/root/ocp/fixed-b.yaml 에 만들어 둘 다 Ready 로 두세요. /root/ocp/arbitrary.json 에 파드 이름을 키로 uid(컨테이너 안 id -u)와 data(/app/data 의 소유자UID:그룹GID:권한8진수, 예: 0:0:755 형식)를 적으세요.
OpenShift 는 같은 이미지를 프로젝트마다 다른 UID 로 돌립니다. 특정 UID 하나에서만 되는 이미지는 옮기는 순간 다시 깨집니다. stat -c '%u:%g:%a' 로 봅니다.
fsGroup 과 root initContainer 는 왜 답이 아닌가
레거시 이미지를 고치지 않고 버티는 두 방법을 시험하세요. (1) /root/ocp/legacy-emptydir.yaml 로 파드 legacy-ed(2단계와 같되 /app/data 에 emptyDir data 마운트)를 만들어 Ready 로 둡니다. (2) /root/ocp/legacy-init.yaml 로 root(runAsUser 0) initContainer fix-perms 가 /app/data 를 chown 하는 파드 legacy-init 을 적용해 출력을 /root/ocp/init-denied.txt 에 저장합니다. /root/ocp/alternatives.json 에 fsgroup_fixed_image_dir(fsGroup 이 있는 legacy 파드가 이미지 디렉터리 쓰기에 성공했는지), emptydir_data(legacy-ed 안 /app/data 의 그룹GID:권한8진수), root_init_admitted(legacy-init 이 받아들여졌는지)를 적으세요.
fsGroup 은 파드에 붙는 볼륨의 그룹 소유를 바꾸는 장치이지 이미지 파일시스템을 바꾸지 않습니다. emptyDir 는 쓰기는 되지만 파드가 사라지면 함께 사라집니다. restricted 기준과 restricted-v2 SCC 는 둘 다 UID 0 을 허용하지 않습니다.
읽기 전용 루트에서 쓸 곳만 열어 준다
고친 이미지에 readOnlyRootFilesystem: true 만 더한 파드 ro-bare 를 /root/ocp/ro-bare.yaml 로 만들어 실패를 보고, 같은 설정에 emptyDir 을 /app/data(이름 data)와 /tmp(이름 tmp)에 마운트하고 환경 변수 HOME=/tmp 를 준 파드 fixed-ro 를 /root/ocp/fixed-ro.yaml 로 만들어 Ready 로 두세요. /root/ocp/readonly.json 에 bare_error(ro-bare 종료 메시지 중 Read-only 가 든 줄 그대로), home(fixed-ro 안 HOME), home_writable, etc_writable(fixed-ro 안 /etc 에 쓸 수 있는지)을 적으세요.
읽기 전용 루트는 이미지가 어디에 쓰는지 드러내 줍니다. 쓰는 경로마다 볼륨을 주고, HOME 처럼 도구가 기본으로 쓰는 곳도 쓰기 가능한 경로로 돌려 둡니다. ro-bare 는 지우지 말고 증거로 남깁니다.
옮기기 전에 이미지를 점검하는 목록
/root/ocp/report.json 에 root_cause(2단계 실패의 원인: image-dir-owner·missing-capability·selinux 중 하나), whoami_ok_here(이 k3s 에서 임의 UID 로 whoami 가 되는지), openshift_runtime_adds_passwd_entry(OpenShift 문서가 CRI-O 가 임의 UID 를 /etc/passwd 에 넣어 준다고 하는지), fsgroup_fixes_image_dir, k3s_applies_uid_range(이 k3s 가 runAsUser 없는 파드에 주석의 UID 를 채워 넣는지), uid_range_default(주석으로 계산한 기본 UID), running_uids(지금 Ready 인 fixed-a·fixed-b 의 UID 를 정렬한 배열)를 적으세요.
k3s 가 주석을 쓰는지는 runAsUser 를 뺀 파드를 --dry-run=server -o json 으로 보내 돌아온 spec 에 runAsUser 가 채워졌는지로 확인할 수 있습니다. 채점기도 같은 방법과 앞 단계 기록으로 다시 계산합니다.