안녕하세요. 오픈소스컨설팅에서 DevOps 엔지니어로 일하고 있는 정광필입니다. 고객사의 CI/CD 환경 개선과 Kubernetes 기반 애플리케이션 배포 자동화를 주로 담당하고 있습니다.
최근 클라우드 네이티브 아키텍처가 엔터프라이즈 표준으로 자리 잡으면서, CI/CD 파이프라인도 고정된 VM을 벗어나 Kubernetes 클러스터 위에서 동적으로 구동되는 형태로 옮겨가고 있습니다. 특히 Jenkins와 Kubernetes를 연동해 빌드 시점에만 임시(Ephemeral) Agent Pod를 띄우고, 작업이 끝나면 자원을 반납하는 구조는 리소스 효율 측면에서 매력적입니다.
문제는 이런 동적 컨테이너 환경에서 이미지를 빌드하는 일이 생각보다 까다롭다는 점입니다. 이 글에서는 컨테이너 내부에서 이미지를 빌드할 때 자주 쓰이는 세 가지 방식인 DooD(Docker out of Docker), DinD(Docker in Docker), Kaniko의 동작 원리를 살펴보고, 엔터프라이즈 환경에서 Kaniko를 권장하는 이유를 정리해보겠습니다.
전통적인 인프라에서는 고정된 호스트에 Docker 데몬을 상주시키고 docker build를 실행했습니다. 반면 Kubernetes 환경의 Jenkins Agent는 작업을 위해 잠깐 떠 있다가 사라지는 컨테이너(Pod) 한 대일 뿐입니다.
즉, ‘컨테이너 내부에서 또 다른 컨테이너 이미지를 만들고 레지스트리에 푸시해야 하는’ 상황 — 이른바 Containers in Containers 구조가 생깁니다. 현업에서는 보통 세 가지 방식으로 이 문제를 풉니다.
| 방식 | 설명 |
|---|---|
| DooD (Docker out of Docker) | 호스트의 Docker 소켓(/var/run/docker.sock)을 컨테이너에 마운트하여 빌드 |
| DinD (Docker in Docker) | 컨테이너 안에 독립적인 Docker 데몬을 직접 실행 |
| Kaniko | Docker 데몬 없이 유저 스페이스에서 이미지 빌드 |
보안상 호스트 노드의 영향을 최소화해야 하는 환경에서는 보통 DinD와 Kaniko가 최종 후보로 남습니다. DooD가 왜 먼저 제외되는지부터 짚어보겠습니다.
DooD는 호스트의 Docker 소켓 파일(/var/run/docker.sock)을 Jenkins Agent Pod에 볼륨 마운트해서, 컨테이너 안에서 호스트의 Docker 데몬과 직접 통신하는 방식입니다.
# DooD 방식 Pod 예시
volumes:
- name: docker-sock
hostPath:
path: /var/run/docker.sock
containers:
- name: docker-client
image: docker:cli
volumeMounts:
- name: docker-sock
mountPath: /var/run/docker.sock
소켓을 마운트하면 사실상 호스트 Docker 데몬을 그대로 제어할 수 있게 됩니다. /var/run/docker.sock에 접근 가능한 컨테이너는 호스트에서 docker run --privileged 수준의 컨테이너를 마음대로 띄울 수 있고, 다음과 같은 공격도 어렵지 않게 가능합니다.
# 소켓이 마운트된 컨테이너 내부에서 호스트 파일시스템을 통째로 마운트
docker run -v /:/host --rm -it alpine chroot /host
소켓 하나로 클러스터 보안이 무너질 수 있어서, 보안 감사가 이뤄지는 엔터프라이즈 환경에서는 거의 사용하지 않습니다.
Docker in Docker(DinD) 는 임시 Agent Pod 안에 사이드카 패턴 등으로 별도의 Docker 데몬을 실행하는 방식입니다. 파이프라인 스크립트는 이 내부 데몬과 통신해서 이미지를 빌드합니다.
레거시 파이프라인 스크립트를 거의 그대로 재사용할 수 있다는 장점이 있지만, 운영 환경에서 보면 다음과 같은 단점들이 있습니다.
DinD의 내부 Docker 데몬이 커널의 네임스페이스와 cgroups를 다루려면 Pod에 securityContext: privileged: true를 부여해야 합니다. 이 설정은 컨테이너 격리를 사실상 풀어버리고 CAP_SYS_ADMIN 같은 호스트 커널 권한을 컨테이너에 위임합니다. 파이프라인 코드에 악의적인 셸 명령어가 끼어들거나 컨테이너가 탈취되는 경우, 공격자가 호스트 노드를 거쳐 클러스터 전체로 영향을 넓힐 가능성이 생깁니다.
호스트 Docker 데몬은 보통 Overlay2 같은 고성능 스토리지 드라이버를 씁니다. 하지만 DinD에서는 Overlay 위에 다시 Overlay를 쌓는 구조(overlay-on-overlay) 가 호스트 커널/스토리지 구성에 따라 지원되지 않을 수 있고, 이 경우 VFS(Virtual File System) 드라이버로 폴백됩니다.
VFS는 이름 그대로 가상 파일시스템 위에서 동작하는 드라이버인데, Overlay2처럼 변경된 부분만 레이어로 쌓는 Copy-on-Write 방식을 지원하지 않습니다. 폴백이 일어나면 다음과 같은 현상이 발생합니다.
RUN/COPY 같은 명령어가 실행될 때마다, 변경분만 저장하는 게 아니라 이전 레이어 파일 시스템 전체를 새 디렉터리로 통째 복사합니다.--storage-driver=overlay2를 명시하고 커널·스토리지 조건이 맞으면 Overlay2로 동작하지만, 호스트 환경에 따라 안정성 차이가 큽니다.
작업이 끝나면 Jenkins Agent Pod는 Kubernetes Garbage Collection으로 삭제되고, Pod 안에 쌓여 있던 Docker 이미지 레이어 캐시도 함께 사라집니다. 매번 베이스 이미지와 의존성 패키지를 처음부터 다시 받아야 하므로 빌드 시간과 네트워크 사용량이 늘어납니다.
이 부분을 이해하려면 먼저 K8s가 위험한 Pod를 어떻게 걸러내는지 짚어둘 필요가 있습니다. K8s에는 사용자가 kubectl apply로 만든 Pod 스펙이 etcd에 저장되기 직전, ‘이 설정 통과시켜도 되는지’ 검사하는 어드미션 단계가 있습니다. 보안 정책 어드미션은 여기서 privileged: true나 hostPath 마운트처럼 호스트를 위협할 수 있는 설정을 발견하면 Pod 생성을 거부합니다.
Kubernetes 1.25부터 PodSecurityPolicy(PSP)가 빠지고 그 자리를 Pod Security Admission(PSA) 이 대신하게 되었습니다. PSP는 위의 어드미션 단계에서 위험한 Pod를 걸러주던 클러스터 레벨 정책 리소스로, “어떤 사용자가 어떤 위험 설정을 쓸 수 있는지”를 PSP 객체로 정의하고 RBAC으로 사용자에게 연결하는 구조였습니다. 그런데 정책 객체와 RBAC을 따로 만들어 묶어줘야 했고, 어떤 사용자에게 어떤 PSP가 적용되는지 추적이 까다로워서 운영 부담이 컸습니다. 결국 1.21에서 deprecated된 뒤 1.25에서 제거됐습니다.
PSA는 이걸 훨씬 단순하게 바꿔놓은 후속입니다. privileged, baseline, restricted 세 단계의 표준 프로파일이 미리 정의되어 있고, Namespace에 레이블만 하나 붙이면 적용됩니다.
baseline 은 privileged, hostPath, hostNetwork, hostPID 등을 차단합니다.restricted 는 거기에 더해 root 실행, 권한 상승까지 막습니다.문제는 DinD가 privileged: true 없이는 동작하지 않는다는 점입니다. 내부 Docker 데몬이 네임스페이스·cgroups·네트워크 브리지를 다루려면 호스트 커널 권한이 필요하기 때문인데, baseline 이상의 프로파일은 정확히 그 권한을 차단합니다. 그래서 보안 정책이 적용된 네임스페이스에 DinD Pod를 띄우려고 하면 빌드는커녕 Pod 생성 자체가 다음처럼 거부됩니다.
Error from server (Forbidden): pods "jenkins-agent-xxxx" is forbidden:
violates PodSecurity "restricted:latest": privileged
(container "docker" must not set securityContext.privileged=true)
요약하면, K8s 1.25 이후로 PSA를 기본 적용하는 클러스터가 늘면서 DinD 기반 파이프라인은 빌드 시작도 못 하는 경우가 점점 흔해지고 있다는 뜻입니다.
# Namespace에 restricted 정책 적용 시
apiVersion: v1
kind: Namespace
metadata:
name: jenkins
labels:
pod-security.kubernetes.io/enforce: restricted # DinD Pod 생성 불가
pod-security.kubernetes.io/warn: restricted
pipeline {
agent {
kubernetes {
yaml """
apiVersion: v1
kind: Pod
spec:
containers:
- name: docker
image: docker:24.0.5-dind
securityContext:
privileged: true # 보안 위협의 핵심 요소
resources:
requests:
cpu: "1"
memory: "2Gi"
- name: jnlp
image: jenkins/inbound-agent:3148.v532a_22efc531-1
"""
}
}
stages {
stage('Docker Build & Push') {
steps {
container('docker') {
script {
sh 'docker build -t my-registry.com/my-app:${BUILD_NUMBER} .'
sh 'docker push my-registry.com/my-app:${BUILD_NUMBER}'
}
}
}
}
}
}
이런 DinD의 한계를 풀기 위해 Google Container Tools 생태계에서 나온 도구가 Kaniko 입니다. Kaniko는 Docker 데몬에 의존하지 않고, 컨테이너 격리 원칙을 지키면서 이미지를 빌드합니다.
Kaniko는 gcr.io/kaniko-project/executor라는 단일 바이너리 이미지로 동작합니다. 실행되면 대략 다음 순서로 진행됩니다.
FROM 이미지를 레지스트리에서 받아와 풀어놓습니다.RUN, COPY 등을 호스트 커널 권한 없이 컨테이너 내부의 격리된 유저 스페이스에서 순차 실행합니다. (유저 스페이스 = 커널 권한 없이 일반 프로세스 권한으로 동작하는 영역)1) Rootless 동작
Kaniko는 호스트 자원에 접근하기 위한 privileged 권한을 요구하지 않습니다. 일반적인 Pod와 동일하게 제한된 권한 안에서 빌드가 끝납니다. PSA restricted 정책 하에서도 동작하므로, 보안 컴플라이언스가 까다로운 금융권이나 멀티 테넌트 환경에서도 무리 없이 적용할 수 있습니다.
2) 원격 레지스트리 기반 캐싱
앞서 DinD 한계 항목에서 짚었듯, Kubernetes의 Jenkins Agent Pod는 휘발성입니다. 작업이 끝나면 Pod가 사라지고 그 안의 로컬 캐시도 함께 증발하기 때문에, 다음 빌드는 다른 노드의 새로운 Pod에서 시작될 수 있고 캐시 재사용도 어렵습니다. 즉 ‘휘발성 Pod’와 ‘Pod 로컬 디스크에 쌓이는 캐시’는 구조적으로 잘 맞지 않는 조합입니다.
Kaniko의 --cache=true 옵션은 이 미스매치를 캐시 저장소를 Pod 밖, 즉 원격 레지스트리로 옮겨서 해결합니다. 빌드 중 만들어진 중간 레이어를 지정한 원격 레지스트리 경로(Cache Repo)에 별도 이미지로 올려두고, 다음 빌드에서는 어느 노드의 어떤 Pod에서 실행되든 원격 레지스트리에서 레이어 해시를 비교해 변경되지 않은 부분을 그대로 받아 씁니다. 캐시가 Pod의 생명주기와 분리되어 있어, Pod가 휘발성이라는 K8s의 특성을 그대로 둔 채로 캐시 효과를 유지할 수 있는 구조입니다.
pipeline {
agent {
kubernetes {
yaml """
apiVersion: v1
kind: Pod
spec:
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:debug
command: ["sleep"]
args: ["99d"]
volumeMounts:
- name: registry-creds
mountPath: /kaniko/.docker
resources:
requests:
cpu: "1"
memory: "2Gi"
limits:
cpu: "2"
memory: "4Gi" # 스냅샷 시 메모리 스파이크를 고려한 limit
volumes:
- name: registry-creds
secret:
secretName: docker-registry-config # 레지스트리 인증 정보 (K8s Secret)
items:
- key: .dockerconfigjson
path: config.json
"""
}
}
stages {
stage('Kaniko Build & Push') {
steps {
container('kaniko') {
script {
sh """
/kaniko/executor \\
--context ${WORKSPACE} \\
--dockerfile Dockerfile \\
--destination my-registry.com/my-app:${BUILD_NUMBER} \\
--cache=true \\
--cache-repo my-registry.com/kaniko-cache \\
--snapshotMode=redo
"""
}
}
}
}
}
}
| 비교 항목 | DinD | Kaniko |
|---|---|---|
| 보안 | privileged 모드 요구 → 호스트 커널 권한 노출 | 호스트 격리, 유저 스페이스 빌드 |
| PSA 정책 호환 | baseline/restricted에서 동작 불가 | 모든 정책 수준에서 동작 |
| 캐시 | Pod 소멸 시 로컬 캐시 증발 | 원격 레지스트리 기반 캐시 재사용 |
| 스토리지 | 환경에 따라 VFS 폴백 가능성 | 메모리 + 임시 파일 시스템 기반 |
| 런타임 의존성 | 호스트 컨테이너 런타임 종속 가능성 있음 | 런타임에 비종속, 독립 바이너리로 동작 |
| 학습 곡선 | 기존 Docker 스크립트 그대로 재사용 가능 | K8s Secret 연동, 고유 플래그 학습 필요 |
| 리소스 패턴 | 빌드 내내 데몬이 자원 점유 | 스냅샷 시 일시적 메모리 스파이크 |
Kaniko가 모든 시나리오에서 답은 아닙니다. 데몬이 없기 때문에 아래와 같은 기능은 그대로 사용하지 못한다는 단점도 존재합니다.
docker-compose를 활용한 복합 컨테이너 통합 테스트docker buildx로 단일 명령어 기반 멀티 아키텍처(Multi-arch) 동시 빌드멀티 아키텍처 빌드가 필요한 경우, 다음과 같이 단일 아키텍처 빌드를 나눠서 진행한 뒤 docker manifest로 합치는 방식을 씁니다.
# 1. 각 아키텍처별로 Kaniko 빌드 (예: amd64, arm64)
/kaniko/executor --customPlatform=linux/amd64 \
--destination my-registry.com/my-app:${BUILD_NUMBER}-amd64 ...
/kaniko/executor --customPlatform=linux/arm64 \
--destination my-registry.com/my-app:${BUILD_NUMBER}-arm64 ...
# 2. 빌드 결과를 manifest로 묶기
docker manifest create my-registry.com/my-app:${BUILD_NUMBER} \
my-registry.com/my-app:${BUILD_NUMBER}-amd64 \
my-registry.com/my-app:${BUILD_NUMBER}-arm64
docker manifest push my-registry.com/my-app:${BUILD_NUMBER}
현업에서는 자체 Harbor뿐 아니라 AWS ECR, GCP Artifact Registry, Azure ACR 같은 클라우드 관리형 레지스트리도 자주 씁니다. 각 환경별 Kaniko 인증 방법은 다음과 같습니다.
각 환경별 설정에 들어가기 전에, DinD와 Kaniko가 레지스트리에 인증하는 방식이 어떻게 다른지 짧게 짚고 가겠습니다.
DinD는 내부에 Docker 데몬을 그대로 띄우는 구조이기 때문에 인증도 일반적인 Docker CLI와 동일합니다. docker login을 호출하거나 ~/.docker/config.json 파일을 Pod에 주입해두면 데몬이 그 정보를 들고 푸시·풀을 수행합니다. 클라우드 레지스트리의 경우 aws ecr get-login-password | docker login ... 같은 형태로 단기 토큰을 받아 데몬에 등록하는 흐름이 흔합니다. 즉 데몬이 인증 상태를 보관하는 모델입니다.
반면 Kaniko는 데몬이 없습니다. Executor 프로세스 자체가 매 실행마다 레지스트리에 직접 인증해야 하고, 그 인증 정보는 /kaniko/.docker/config.json 경로에서 읽어 들이도록 정해져 있습니다. 따라서 Kaniko에서는 인증 정보를 Pod 스펙 단계에서 주입하는 방식이 표준입니다. 구체적으로는,
dockerconfigjson 타입의 K8s Secret을 마운트하고,워크로드 아이덴티티 방식을 권장하는 이유는, 정적 자격 증명을 Secret으로 저장하지 않아도 되고 토큰 만료·갱신도 클라우드 SDK가 알아서 처리하기 때문입니다.
ECR은 토큰 기반 인증이고 토큰이 12시간마다 갱신됩니다. IRSA(IAM Roles for Service Accounts) 를 쓰면 Secret 없이 Pod의 ServiceAccount에 IAM 권한을 매핑해 인증할 수 있습니다.
# ServiceAccount에 IRSA 어노테이션 부여
apiVersion: v1
kind: ServiceAccount
metadata:
name: jenkins-kaniko-sa
namespace: jenkins
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/KanikoBuildRole
// Kaniko executor 실행
sh """
/kaniko/executor \\
--context ${WORKSPACE} \\
--dockerfile Dockerfile \\
--destination 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/my-app:${BUILD_NUMBER} \\
--cache=true \\
--cache-repo 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/kaniko-cache
"""
IAM Role에는 다음 최소 권한이 필요합니다.
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload"
],
"Resource": "*"
}
GKE의 Workload Identity도 IRSA와 비슷하게 Pod의 ServiceAccount를 GCP IAM과 연결하는 방식입니다.
apiVersion: v1
kind: ServiceAccount
metadata:
name: jenkins-kaniko-sa
namespace: jenkins
annotations:
iam.gke.io/gcp-service-account: kaniko-builder@my-project.iam.gserviceaccount.com
AKS Managed Identity를 사용하면 별도 Secret 없이 인증할 수 있습니다.
containers:
- name: kaniko
image: gcr.io/kaniko-project/executor:debug
env:
- name: AZURE_CLIENT_ID
value: "<managed-identity-client-id>"
# dockerconfigjson Secret 생성
kubectl create secret docker-registry harbor-creds \
--docker-server=harbor.mycompany.com \
--docker-username=robot\$ci-builder \
--docker-password=<robot-token> \
--namespace=jenkins
K8s 환경을 처음 구축할 때는 익숙함 때문에 DinD를 선택하는 경우가 많습니다. 다만 클러스터 규모가 커지고 멀티 테넌트로 전환되거나 보안 감사가 강화되면, privileged 권한에 의존하는 빌드 파이프라인은 점점 부담스러운 부채가 됩니다. 특히 Kubernetes 1.25 이후 PSA가 기본 적용되는 환경에서는 DinD 파이프라인이 아예 동작하지 않을 수 있다는 점도 미리 고려해야 합니다.
물론 Kaniko로 옮긴다고 모든 문제가 해결되지는 않습니다. 인증 체계 변경과 플래그 학습, 멀티 아키텍처 빌드 같은 부분에서 초기 작업이 필요합니다. 그럼에도 클러스터 보안 리스크를 줄이고, 원격 캐싱으로 빌드 환경을 안정화하며, 이후 이미지 서명·취약점 스캐닝 같은 공급망 보안 단계로 자연스럽게 이어진다는 점에서, K8s 기반 CI 파이프라인은 Kaniko 쪽으로 정리하는 편이 장기적으로 유리하다고 개인적으로 판단합니다.
인프라 패러다임이 클라우드 네이티브로 옮겨간 만큼, 그 위의 파이프라인도 같은 방향을 따라가는 것이 자연스럽다고 생각합니다. 비슷한 고민을 하고 계신 인프라 담당자나 DevOps 엔지니어분들께 이 글이 작은 참고가 되었으면 합니다.