seccomp으로 컨테이너의 시스템 호출 제한하기

seccomp으로 컨테이너의 시스템 호출 제한하기 (Restrict a Container's Syscalls with seccomp)

seccomp는 secure computing mode를 뜻하며 Linux 커널 2.6.12부터 기능이었어요. 프로세스의 권한을 샌드박스 처리해, 프로세스가 사용자 공간에서 커널로 할 수 있는 호출을 제한하는 데 사용될 수 있어요. 쿠버네티스는 노드에 로드된 seccomp 프로파일을 파드와 컨테이너에 자동으로 적용하게 해줘요.

워크로드에 필요한 권한을 식별하는 것은 어려울 수 있어요. 이 튜토리얼에서는 seccomp 프로파일을 로컬 쿠버네티스 클러스터에 로드하는 방법, 그것들을 파드에 적용하는 방법, 그리고 컨테이너 프로세스에 꼭 필요한 권한만 주는 프로파일을 만들기 시작하는 방법을 살펴볼 거예요.

출처: 문서

본문

목표 (Objectives)

  • 노드에 seccomp 프로파일을 로드하는 방법 배우기
  • 컨테이너에 seccomp 프로파일을 적용하는 방법 배우기
  • 컨테이너 프로세스가 만든 시스템 호출의 감사(auditing) 관찰하기
  • 누락된 프로파일이 지정되었을 때의 동작 관찰하기
  • seccomp 프로파일 위반 관찰하기
  • 세밀한(fine-grained) seccomp 프로파일을 만드는 방법 배우기
  • 컨테이너 런타임 기본 seccomp 프로파일을 적용하는 방법 배우기

시작하기 전에

이 튜토리얼의 모든 단계를 완료하려면 kind와 kubectl을 설치해야 해요.

튜토리얼에 사용된 명령은 Docker를 컨테이너 런타임으로 사용한다고 가정해요(kind가 만든 클러스터는 내부적으로 다른 컨테이너 런타임을 사용할 수 있어요). Podman을 사용할 수도 있지만, 그 경우 작업을 성공적으로 완료하려면 특정 지침을 따라야 해요.

이 튜토리얼은 (v1.25부터) 여전히 베타인 일부 예시와, 일반 사용 가능한 seccomp 기능만 사용하는 다른 예시를 보여 줘요. 사용 중인 버전에 맞게 클러스터가 올바르게 구성되어 있는지 확인해야 해요.

튜토리얼은 또한 예시를 컴퓨터에 다운로드하기 위해 curl 도구를 사용해요. 원한다면 단계를 다른 도구를 사용하도록 적응시킬 수 있어요.

참고

예시 seccomp 프로파일 다운로드하기

이 프로파일들의 내용은 나중에 살펴볼 거지만, 지금은 profiles/라는 디렉터리에 다운로드해 클러스터로 로드할 수 있게 해 보죠.

{
    "defaultAction": "SCMP_ACT_LOG"
}
{
    "defaultAction": "SCMP_ACT_ERRNO"
}
{
    "defaultAction": "SCMP_ACT_ERRNO",
    "architectures": [
        "SCMP_ARCH_X86_64",
        "SCMP_ARCH_X86",
        "SCMP_ARCH_X32"
    ],
    "syscalls": [
        {
            "names": [
                "accept4", "epoll_wait", "pselect6", "futex", "madvise", "epoll_ctl",
                "getsockname", "setsockopt", "vfork", "mmap", "read", "write", "close",
                "arch_prctl", "sched_getaffinity", "munmap", "brk", "rt_sigaction",
                "rt_sigprocmask", "sigaltstack", "gettid", "clone", "bind", "socket",
                "openat", "readlinkat", "exit_group", "epoll_create1", "listen",
                "rt_sigreturn", "sched_yield", "clock_gettime", "connect", "dup2",
                "epoll_pwait", "execve", "exit", "fcntl", "getpid", "getuid", "ioctl",
                "mprotect", "nanosleep", "open", "poll", "recvfrom", "sendto",
                "set_tid_address", "setitimer", "writev", "fstatfs", "getdents64",
                "pipe2", "getrlimit"
            ],
            "action": "SCMP_ACT_ALLOW"
        }
    ]
}

다음 명령을 실행하세요.

mkdir ./profiles
curl -L -o profiles/audit.json https://k8s.io/examples/pods/security/seccomp/profiles/audit.json
curl -L -o profiles/violation.json https://k8s.io/examples/pods/security/seccomp/profiles/violation.json
curl -L -o profiles/fine-grained.json https://k8s.io/examples/pods/security/seccomp/profiles/fine-grained.json
ls profiles

마지막 단계 끝에 세 개의 프로파일이 나열된 것을 볼 수 있어야 해요.

audit.json  fine-grained.json  violation.json

kind로 로컬 쿠버네티스 클러스터 만들기

간단함을 위해 kind를 사용해 seccomp 프로파일이 로드된 단일 노드 클러스터를 만들 수 있어요. kind는 Kubernetes를 Docker에서 실행하므로, 클러스터의 각 노드는 컨테이너예요. 이는 각 컨테이너의 파일시스템에 파일을 마운트해 노드에 파일을 로드하는 것과 유사하게 할 수 있게 해줘요.

apiVersion: kind.x-k8s.io/v1alpha4
kind: Cluster
nodes:
- role: control-plane
  extraMounts:
  - hostPath: "./profiles"
    containerPath: "/var/lib/kubelet/seccomp/profiles"

그 예시 kind 구성에 대한 링크를 다운로드해 kind.yaml이라는 파일로 저장하세요.

curl -L -O https://k8s.io/examples/pods/security/seccomp/kind.yaml

노드의 컨테이너 이미지를 설정해 특정 쿠버네티스 버전을 설정할 수 있어요. 이에 대한 더 자세한 내용은 kind 문서의 Nodes를 참조하세요. 이 튜토리얼은 쿠버네티스 v1.37을 사용한다고 가정해요.

베타 기능으로, 쿠버네티스는 Unconfined로 폴백하는 대신 컨테이너 런타임이 기본으로 선호하는 프로파일을 사용하도록 구성할 수 있어요. 그렇게 해 보려면, 계속하기 전에 '모든 워크로드에 대해 RuntimeDefault를 기본 seccomp 프로파일로 사용 활성화'를 참고하세요.

kind 구성이 준비되면 그 구성으로 kind 클러스터를 만들어요.

kind create cluster --config=kind.yaml

새 쿠버네티스 클러스터가 준비된 후, 단일 노드 클러스터로 실행되는 Docker 컨테이너를 확인해요.

docker ps

kind-control-plane이라는 이름의 컨테이너가 실행 중임을 나타내는 출력이 보여야 해요. 출력은 다음과 비슷해요.

CONTAINER ID        IMAGE                  COMMAND                  CREATED             STATUS              PORTS                       NAMES
6a96207fed4b        kindest/node:v1.18.2   "/usr/local/bin/entr…"   27 seconds ago      Up 24 seconds       127.0.0.1:42223->6443/tcp   kind-control-plane

그 컨테이너의 파일시스템을 관찰하면 profiles/ 디렉터리가 kubelet의 기본 seccomp 경로에 성공적으로 로드되어 있다는 것을 볼 수 있어야 해요. docker exec로 파드에서 명령을 실행해요.

docker exec -it kind-control-plane ls /var/lib/kubelet/seccomp/profiles
audit.json  fine-grained.json  violation.json

이 seccomp 프로파일들이 kind 안에서 실행되는 kubelet에 사용 가능하다는 것을 확인했어요.

컨테이너 런타임 기본 seccomp 프로파일을 사용하는 파드 만들기

대부분의 컨테이너 런타임은 허용되거나 허용되지 않는 합리적인 기본 시스템 호출 집합을 제공해요. 파드나 컨테이너의 보안 컨텍스트에서 seccomp 유형을 RuntimeDefault로 설정해 이러한 기본값을 워크로드에 채택할 수 있어요.

참고

모든 컨테이너에 대해 RuntimeDefault seccomp 프로파일을 요청하는 파드의 매니페스트는 다음과 같아요.

apiVersion: v1
kind: Pod
metadata:
  name: default-pod
  labels:
    app: default-pod
spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: test-container
    image: hashicorp/http-echo:1.0
    args:
    - "-text=just made some syscalls!"
    securityContext:
      allowPrivilegeEscalation: false

그 파드를 만들어요.

kubectl apply -f https://k8s.io/examples/pods/security/seccomp/ga/default-pod.yaml
kubectl get pod default-pod

파드가 성공적으로 시작된 것으로 표시되어야 해요.

NAME        READY   STATUS    RESTARTS   AGE
default-pod 1/1     Running   0          20s

다음 섹션으로 이동하기 전에 파드를 삭제해요.

kubectl delete pod default-pod --wait --now

시스템 호출 감사를 위한 seccomp 프로파일이 있는 파드 만들기

시작하면서, 프로세스의 모든 시스템 호출을 기록할 audit.json 프로파일을 새 파드에 적용해요.

그 파드의 매니페스트는 다음과 같아요.

apiVersion: v1
kind: Pod
metadata:
  name: audit-pod
  labels:
    app: audit-pod
spec:
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/audit.json
  containers:
  - name: test-container
    image: hashicorp/http-echo:1.0
    args:
    - "-text=just made some syscalls!"
    securityContext:
      allowPrivilegeEscalation: false

참고

클러스터에서 파드를 만들어요.

kubectl apply -f https://k8s.io/examples/pods/security/seccomp/ga/audit-pod.yaml

이 프로파일은 어떤 시스템 호출도 제한하지 않으므로 파드는 성공적으로 시작되어야 해요.

kubectl get pod audit-pod
NAME        READY   STATUS    RESTARTS   AGE
audit-pod   1/1     Running   0          30s

이 컨테이너가 노출하는 엔드포인트와 상호작용할 수 있게, kind 제어 플레인 컨테이너 안에서 엔드포인트에 접근할 수 있게 하는 NodePort Service를 만들어요.

kubectl expose pod audit-pod --type NodePort --port 5678

Service가 노드에 할당한 포트를 확인해요.

kubectl get service audit-pod

출력은 다음과 비슷해요.

NAME        TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
audit-pod   NodePort   10.111.36.142   <none>        5678:32373/TCP   72s

이제 curl을 사용해 kind 제어 플레인 컨테이너 안에서, 이 Service가 노출한 포트로 그 엔드포인트에 접근할 수 있어요. docker exec로 제어 플레인 컨테이너에 속한 컨테이너 안에서 curl 명령을 실행해요.

# 32373 을 "kubectl get service audit-pod"에서 본 포트 번호로 변경하세요
docker exec -it kind-control-plane curl localhost:32373
just made some syscalls!

프로세스가 실행 중인 것을 볼 수 있지만, 실제로 어떤 시스템 호출을 했을까요? 이 파드가 로컬 클러스터에서 실행 중이므로, 로컬 시스템의 /var/log/syslog에서 그들을 볼 수 있어야 해요. 새 터미널 창을 열고 http-echo의 호출에 대한 출력을 tail해요.

# 컴퓨터의 로그 경로는 "/var/log/syslog"와 다를 수 있습니다
tail -f /var/log/syslog | grep 'http-echo'

http-echo가 만든 시스템 호출의 로그 일부가 이미 있어야 하고, 제어 플레인 컨테이너 안에서 curl을 다시 실행하면 로그에 더 많은 출력이 쓰이는 것을 볼 수 있어요.

예를 들어:

Jul  6 15:37:40 my-machine kernel: [369128.669452] audit: type=1326 audit(1594067860.484:14536): auid=4294967295 uid=0 gid=0 ses=4294967295 pid=29064 comm="http-echo" exe="/http-echo" sig=0 arch=c000003e syscall=51 compat=0 ip=0x46fe1f code=0x7ffc0000
Jul  6 15:37:40 my-machine kernel: [369128.669453] audit: type=1326 audit(1594067860.484:14537): auid=4294967295 uid=0 gid=0 ses=4294967295 pid=29064 comm="http-echo" exe="/http-echo" sig=0 arch=c000003e syscall=54 compat=0 ip=0x46fdba code=0x7ffc0000
Jul  6 15:37:40 my-machine kernel: [369128.669455] audit: type=1326 audit(1594067860.484:14538): auid=4294967295 uid=0 gid=0 ses=4294967295 pid=29064 comm="http-echo" exe="/http-echo" sig=0 arch=c000003e syscall=202 compat=0 ip=0x455e53 code=0x7ffc0000
Jul  6 15:37:40 my-machine kernel: [369128.669456] audit: type=1326 audit(1594067860.484:14539): auid=4294967295 uid=0 gid=0 ses=4294967295 pid=29064 comm="http-echo" exe="/http-echo" sig=0 arch=c000003e syscall=288 compat=0 ip=0x46fdba code=0x7ffc0000
Jul  6 15:37:40 my-machine kernel: [369128.669517] audit: type=1326 audit(1594067860.484:14540): auid=4294967295 uid=0 gid=0 ses=4294967295 pid=29064 comm="http-echo" exe="/http-echo" sig=0 arch=c000003e syscall=0 compat=0 ip=0x46fd44 code=0x7ffc0000
Jul  6 15:37:40 my-machine kernel: [369128.669519] audit: type=1326 audit(1594067860.484:14541): auid=4294967295 uid=0 gid=0 ses=4294967295 pid=29064 comm="http-echo" exe="/http-echo" sig=0 arch=c000003e syscall=270 compat=0 ip=0x4559b1 code=0x7ffc0000
Jul  6 15:38:40 my-machine kernel: [369188.671648] audit: type=1326 audit(1594067920.488:14559): auid=4294967295 uid=0 gid=0 ses=4294967295 pid=29064 comm="http-echo" exe="/http-echo" sig=0 arch=c000003e syscall=270 compat=0 ip=0x4559b1 code=0x7ffc0000
Jul  6 15:38:40 my-machine kernel: [369188.671726] audit: type=1326 audit(1594067920.488:14560): auid=4294967295 uid=0 gid=0 ses=4294967295 pid=29064 comm="http-echo" exe="/http-echo" sig=0 arch=c000003e syscall=202 compat=0 ip=0x455e53 code=0x7ffc0000

각 줄의 syscall= 항목을 보면 http-echo 프로세스가 필요로 하는 시스템 호출을 이해하기 시작할 수 있어요. 이것들이 사용하는 모든 시스템 호출을 포괄하지는 않지만, 이 컨테이너의 seccomp 프로파일의 기초로 사용될 수 있어요.

다음 섹션으로 이동하기 전에 Service와 파드를 삭제해요.

kubectl delete service audit-pod --wait
kubectl delete pod audit-pod --wait --now

위반을 일으키는 seccomp 프로파일이 있는 파드 만들기

시연을 위해, 어떤 시스템 호출도 허용하지 않는 프로파일을 파드에 적용해요.

이 시연의 매니페스트는 다음과 같아요.

apiVersion: v1
kind: Pod
metadata:
  name: violation-pod
  labels:
    app: violation-pod
spec:
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/violation.json
  containers:
  - name: test-container
    image: hashicorp/http-echo:1.0
    args:
    - "-text=just made some syscalls!"
    securityContext:
      allowPrivilegeEscalation: false

클러스터에서 파드를 만들어 봐요.

kubectl apply -f https://k8s.io/examples/pods/security/seccomp/ga/violation-pod.yaml

파드는 만들어지지만, 문제가 있어요. 파드의 상태를 확인하면 시작에 실패한 것을 볼 수 있어야 해요.

kubectl get pod violation-pod
NAME            READY   STATUS             RESTARTS   AGE
violation-pod   0/1     CrashLoopBackOff   1          6s

이전 예시에서 본 것처럼 http-echo 프로세스는 꽤 많은 시스템 호출을 필요로 해요. 여기서 seccomp는 "defaultAction": "SCMP_ACT_ERRNO"를 설정해 어떤 시스템 호출이든 오류를 내도록 지시받았어요. 이것은 매우 안전하지만, 의미 있는 어떤 일을 할 능력을 제거해요. 정말 원하는 것은 워크로드에 필요한 권한만 주는 것이에요.

다음 섹션으로 이동하기 전에 파드를 삭제해요.

kubectl delete pod violation-pod --wait --now

필요한 시스템 호출만 허용하는 seccomp 프로파일이 있는 파드 만들기

fine-grained.json 프로파일을 살펴보면, 첫 번째 예시의 syslog에서 본 시스템 호출 중 일부가 프로파일이 "defaultAction": "SCMP_ACT_LOG"로 설정된 것을 볼 수 있어요. 이제 프로파일은 "defaultAction": "SCMP_ACT_ERRNO"를 설정하지만, "action": "SCMP_ACT_ALLOW" 블록에서 시스템 호출 집합을 명시적으로 허용해요. 이상적으로는 컨테이너가 성공적으로 실행되고 syslog에 보내지는 메시지가 없게 될 거예요.

이 예시의 매니페스트는 다음과 같아요.

apiVersion: v1
kind: Pod
metadata:
  name: fine-pod
  labels:
    app: fine-pod
spec:
  securityContext:
    seccompProfile:
      type: Localhost
      localhostProfile: profiles/fine-grained.json
  containers:
  - name: test-container
    image: hashicorp/http-echo:1.0
    args:
    - "-text=just made some syscalls!"
    securityContext:
      allowPrivilegeEscalation: false

클러스터에서 파드를 만들어요.

kubectl apply -f https://k8s.io/examples/pods/security/seccomp/ga/fine-pod.yaml
kubectl get pod fine-pod

파드가 성공적으로 시작된 것으로 표시되어야 해요.

NAME        READY   STATUS    RESTARTS   AGE
fine-pod   1/1     Running   0          30s

새 터미널 창을 열고 tail로 http-echo의 호출을 언급하는 로그 항목을 모니터링해요.

# 컴퓨터의 로그 경로는 "/var/log/syslog"와 다를 수 있습니다
tail -f /var/log/syslog | grep 'http-echo'

다음으로 NodePort Service로 파드를 노출해요.

kubectl expose pod fine-pod --type NodePort --port 5678

Service가 노드에 할당한 포트를 확인해요.

kubectl get service fine-pod

출력은 다음과 비슷해요.

NAME        TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
fine-pod    NodePort   10.111.36.142   <none>        5678:32373/TCP   72s

curl을 사용해 kind 제어 플레인 컨테이너 안에서 그 엔드포인트에 접근해요.

# 32373 을 "kubectl get service fine-pod"에서 본 포트 번호로 변경하세요
docker exec -it kind-control-plane curl localhost:32373
just made some syscalls!

syslog에는 출력이 없어야 해요. 이는 프로파일이 필요한 모든 시스템 호출을 허용하고 목록 밖의 것이 호출되면 오류가 발생하도록 지정했기 때문이에요. 이것은 보안 관점에서 이상적인 상황이지만, 프로그램 분석에 약간의 노력이 필요했어요. 그렇게 많은 노력 없이 이 보안에 더 가까이 갈 수 있는 간단한 방법이 있었다면 좋겠네요.

다음 섹션으로 이동하기 전에 Service와 파드를 삭제해요.

kubectl delete service fine-pod --wait
kubectl delete pod fine-pod --wait --now

모든 워크로드에 대해 RuntimeDefault를 기본 seccomp 프로파일로 사용 활성화하기

seccomp 프로파일 기본값 설정을 사용하려면, 사용하려는 각 노드에서 --seccomp-default 명령줄 플래그를 활성화한 채로 kubelet을 실행해야 해요.

활성화되면 kubelet은 Unconfined(seccomp 비활성화) 모드를 사용하는 대신 컨테이너 런타임이 정의한 RuntimeDefault seccomp 프로파일을 기본으로 사용해요. 기본 프로파일은 워크로드의 기능을 보존하면서 강력한 보안 기본값 집합을 제공하는 것을 목표로 해요. 기본 프로파일은 CRI-O와 containerd의 것을 비교할 때처럼, 컨테이너 런타임과 그 릴리스 버전 간에 다를 수 있어요.

참고

일부 워크로드는 다른 것보다 적은 시스템 호출 제한을 필요로 할 수 있어요. 이는 RuntimeDefault 프로파일로도 런타임 중에 실패할 수 있다는 뜻이에요. 그러한 실패를 완화하려면 다음을 할 수 있어요.

  • 워크로드를 명시적으로 Unconfined로 실행하기.
  • 노드에 대한 SeccompDefault 기능을 비활성화하기. 그리고 워크로드가 기능이 비활성화된 노드에서 스케줄링되도록 보장하기.
  • 워크로드에 대한 사용자 지정 seccomp 프로파일 만들기.

이 기능을 프로덕션 같은 클러스터에 도입한다면, 쿠버네티스 프로젝트는 노드 부분 집합에서 이 기능 게이트를 활성화한 다음, 클러스터 전체로 변경을 롤아웃하기 전에 워크로드 실행을 테스트할 것을 권장해요.

업그레이드 및 다운그레이드 전략에 대한 더 자세한 정보는 관련 쿠버네티스 개선 제안(KEP) 'seccomp를 기본으로 활성화'에서 찾을 수 있어요.

쿠버네티스 1.37은 파드의 spec이 특정 seccomp 프로파일을 정의하지 않을 때 적용되는 seccomp 프로파일을 구성할 수 있게 해줘요. 하지만 사용하려는 각 노드에 대해 여전히 이 기본값 설정을 활성화해야 해요.

쿠버네티스 1.37 클러스터를 실행 중이고 기능을 활성화하려면, --seccomp-default 명령줄 플래그로 kubelet을 실행하거나 kubelet 구성 파일을 통해 활성화해요. kind에서 기능 게이트를 활성화하려면, kind가 최소 요구 쿠버네티스 버전을 제공하고 kind 구성에서 SeccompDefault 기능을 활성화하도록 보장해요.

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    image: kindest/node:v1.28.0@sha256:9f3ff58f19dcf1a0611d11e8ac989fdb30a28f40f236f59f0bea31fb956ccf5c
    kubeadmConfigPatches:
      - |
        kind: JoinConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            seccomp-default: "true"
  - role: worker
    image: kindest/node:v1.28.0@sha256:9f3ff58f19dcf1a0611d11e8ac989fdb30a28f40f236f59f0bea31fb956ccf5c
    kubeadmConfigPatches:
      - |
        kind: JoinConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            seccomp-default: "true"

클러스터가 준비되면, 파드를 실행해요.

kubectl run --rm -it --restart=Never --image=alpine alpine -- sh

이제 기본 seccomp 프로파일이 부착되어야 해요. 이는 kind worker에서 docker exec로 컨테이너에 대해 crictl inspect를 실행해 확인할 수 있어요.

docker exec -it kind-worker bash -c \
    'crictl inspect $(crictl ps --name=alpine -q) | jq .info.runtimeSpec.linux.seccomp'
{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": [
"SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"],
  "syscalls": [
    {
      "names": [
"..."
]
    }
  ]
}

더 알아보기 (Learn more)

Linux seccomp에 대해 더 배울 수 있어요.