이전 글 : https://decompression.tistory.com/19

 

컨테이너 런타임 만들기 : Phase 3

이전 글 : https://decompression.tistory.com/14 컨테이너 런타임 만들기 : Phase 2이전 포스트https://decompression.tistory.com/12 컨테이너 런타임 만들기 : Phase 1컨테이너의 동작을 이해하기 위해 컨테이너 런타임

decompression.tistory.com

 

평범한 컨테이너의 사용사례는 아래와 같다.

docker pull image
docker run image

 

이렇듯 컨테이너 프로세스 생성은 image를 기반으로 동작을 처리하게 되는데.
이 이미지 내부에는 저수준 컨테이너 런타임이 사용할 bundle directory가 포함된다.

bundle directory는 아래와 같은 구조를 지닌다.

bundle directory
ㄴ config.json (spec)
ㄴ root directory

 

이전에 만든 ubuntufs를 번들 디렉터리로 만들어보자.

Makefile#L9-65

setup-ubuntu:
    docker create --name temp-ubuntu ubuntu:22.04
    mkdir -p /root/testbundle/ubuntufs
    docker export temp-ubuntu -o /tmp/ubuntu.tar
    tar -xf /tmp/ubuntu.tar -C /root/testbundle/ubuntufs
    rm /tmp/ubuntu.tar
    docker rm temp-ubuntu
    printf '%s\n' '{' \
    '"ociVersion": "1.0.2",' \
    '"process": {' \
        '"terminal": true,' \
        '"user": { "uid": 0, "gid": 0 },' \
        '"args": ["/bin/bash"],' \
        '"env": [' \
            '"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",' \
            '"TERM=xterm"' \
        '],' \
        '"cwd": "/",' \
        '"capabilities": {' \
            '"bounding": ["CAP_AUDIT_WRITE","CAP_KILL","CAP_NET_BIND_SERVICE"],' \
            '"effective": ["CAP_AUDIT_WRITE","CAP_KILL","CAP_NET_BIND_SERVICE"],' \
            '"inheritable": ["CAP_AUDIT_WRITE","CAP_KILL","CAP_NET_BIND_SERVICE"],' \
            '"permitted": ["CAP_AUDIT_WRITE","CAP_KILL","CAP_NET_BIND_SERVICE"],' \
            '"ambient": ["CAP_AUDIT_WRITE","CAP_KILL","CAP_NET_BIND_SERVICE"]' \
        '},' \
        '"rlimits": [{ "type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024 }],' \
        '"noNewPrivileges": true' \
    '},' \
    '"root": { "path": "/root/testbundle/ubuntufs", "readonly": false },' \
    '"hostname": "oci-ubuntu",' \
    '"mounts": [' \
        '{ "destination": "/proc", "type": "proc", "source": "proc" },' \
        '{ "destination": "/dev", "type": "tmpfs", "source": "tmpfs", "options": ["nosuid","strictatime","mode=755","size=65536k"] },' \
        '{ "destination": "/dev/pts", "type": "devpts", "source": "devpts", "options": ["nosuid","noexec","newinstance","ptmxmode=0666","mode=0620","gid=5"] },' \
        '{ "destination": "/dev/shm", "type": "tmpfs", "source": "shm", "options": ["nosuid","noexec","nodev","mode=1777","size=65536k"] },' \
        '{ "destination": "/dev/mqueue", "type": "mqueue", "source": "mqueue", "options": ["nosuid","noexec","nodev"] },' \
        '{ "destination": "/sys", "type": "sysfs", "source": "sysfs", "options": ["nosuid","noexec","nodev","ro"] }' \
    '],' \
    '"linux": {' \
        '"resources": { "devices": [{ "allow": false, "access": "rwm" }] },' \
        '"namespaces": [' \
            '{ "type": "pid" },' \
            '{ "type": "network" },' \
            '{ "type": "ipc" },' \
            '{ "type": "uts" },' \
            '{ "type": "mount" }' \
        '],' \
        '"maskedPaths": [' \
            '"/proc/kcore", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats",' \
            '"/proc/sched_debug", "/proc/scsi", "/sys/firmware"' \
        '],' \
        '"readonlyPaths": [' \
            '"/proc/asound", "/proc/bus", "/proc/fs", "/proc/irq",' \
            '"/proc/sys", "/proc/sysrq-trigger"' \
        ']' \
    '}' \
    '}' > /root/testbundle/config.json

 

Makefile에 setup-ubuntu로 만들었다.
각 필드에 대해 자세한 사항은 runtime-spec을 참고하자 먼저 스펙을 다룰 유틸리티 파일을 만들었다.

 

internal/container/spec.go

package container

import (
"encoding/json"
"fmt"
"os"

"github.com/opencontainers/runtime-spec/specs-go"
)

func loadSpec(specPath string) (*specs.Spec, error) {
var spec specs.Spec
f, err := os.Open(specPath)
if err != nil {
return nil, fmt.Errorf("container: failed to open spec file at %s: %w", specPath, err)
}
defer f.Close()

if err := json.NewDecoder(f).Decode(&spec); err != nil {
return nil, fmt.Errorf("container: failed to decode spec JSON from %s: %w", specPath, err)
}

return &spec, nil

}

 

그리고 create등을 spec기반으로 처리할 수 있도록 변경한다.

// Create initializes a new container with the given ID and root filesystem path.
func Create(containerID, bundlePath string) error {

	bundlePath, err := filepath.Abs(bundlePath)
	if err != nil {
		return fmt.Errorf("container: failed to get absolute path for bundle: %w", err)
	}

	configPath := filepath.Join(bundlePath, "config.json")

	state := newContainerState(containerID, bundlePath)

	spec, err := loadSpec(configPath)
	if err != nil {
		return err
	}

	if err := saveState(state); err != nil {
		return fmt.Errorf("container: failed to save initial state: %w", err)
	}

	cgroup.SetupCgroups()

	selfExe, err := os.Executable()
	if err != nil {
		return fmt.Errorf("container: failed to get executable path: %w", err)
	}

	cmd := exec.Command(selfExe, append([]string{"init"}, spec.Process.Args...)...)
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	var cloneFlags uintptr
	for _, ns := range spec.Linux.Namespaces {
		switch ns.Type {
		case specs.PIDNamespace:
			cloneFlags |= syscall.CLONE_NEWPID
		case specs.UTSNamespace:
			cloneFlags |= syscall.CLONE_NEWUTS
		case specs.MountNamespace:
			cloneFlags |= syscall.CLONE_NEWNS
		case specs.IPCNamespace:
			cloneFlags |= syscall.CLONE_NEWIPC
		case specs.NetworkNamespace:
			cloneFlags |= syscall.CLONE_NEWNET
		case specs.UserNamespace:
			cloneFlags |= syscall.CLONE_NEWUSER
		case specs.CgroupNamespace:
			cloneFlags |= syscall.CLONE_NEWCGROUP
		case specs.TimeNamespace:
			cloneFlags |= syscall.CLONE_NEWTIME
		}
	}

	cmd.SysProcAttr = &syscall.SysProcAttr{
		Cloneflags: cloneFlags,
	}

	r, w, err := os.Pipe()
	if err != nil {
		return fmt.Errorf("container: failed to create pipe: %w", err)
	}
	defer w.Close()

	cmd.ExtraFiles = []*os.File{r}

	if err := cmd.Start(); err != nil {
		return fmt.Errorf("container: failed to start command: %w", err)
	}

	if err := json.NewEncoder(w).Encode(&spec); err != nil {
		return fmt.Errorf("container: failed to encode spec: %w", err)
	}

	state.Pid = cmd.Process.Pid
	state.Status = specs.StateCreated

	if err := saveState(state); err != nil {
		return fmt.Errorf("container: failed to update state with PID: %w", err)
	}

	return nil
}

 

이제 create에서 분리되는 init process은 이전의 고정적인 실행구조에서
탈피하여 spec을 전해받을 매개가 필요하다.

 

create함수에서는 os pipe를 생성하여 init process에 ExtraFiles로 넘겨준다.

	r, w, err := os.Pipe()
	if err != nil {
		return fmt.Errorf("container: failed to create pipe: %w", err)
	}
	defer w.Close()

	cmd.ExtraFiles = []*os.File{r}

 

넘김받은 파이프는 첫 번째 FD(File Descriptor)로 들어가게 되고.
이는 표준 입/출력/에러를 제외하고 fd3에 열리게 된다.

 

그럼 init에서는 전달받은 파이프를 리슨하여 spec을 받는다.
이후 spec에 정의된 작업을 수행한다.

func Init() {
	pipe := os.NewFile(3, "pipe")
	if pipe == nil {
		log.Fatalf("container: failed to create pipe")
	}
	defer pipe.Close()

	var spec specs.Spec
	if err := json.NewDecoder(pipe).Decode(&spec); err != nil {
		log.Fatalf("container: failed to decode spec: %v", err)
	}

	ch := make(chan os.Signal, 1)

	signal.Notify(ch, syscall.SIGCONT)
	<-ch

	if spec.Hostname != "" {
		if err := syscall.Sethostname([]byte(spec.Hostname)); err != nil {
			log.Fatalf("container: failed to set hostname: %v", err)
		}
	}

	rootfs := spec.Root.Path

	if err := syscall.Mount(rootfs, rootfs, "", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
		log.Fatalf("container: failed to bind mount rootfs: %v", err)
	}

	pivotDir := filepath.Join(rootfs, ".old_root")

	if err := os.MkdirAll(pivotDir, 0755); err != nil {
		log.Fatalf("container: failed to create pivot directory %s: %v", pivotDir, err)
	}

	if err := syscall.PivotRoot(rootfs, pivotDir); err != nil {
		log.Fatalf("container: failed to pivot root to %s: %v", rootfs, err)
	}

	if err := os.Chdir("/"); err != nil {
		log.Fatalf("container: failed to change directory to /: %v", err)
	}

	for _, m := range spec.Mounts {
		if err := os.MkdirAll(m.Destination, 0755); err != nil {
			log.Fatalf("container: failed to create mount destination %s: %v", m.Destination, err)
		}

		if err := syscall.Mount(m.Source, m.Destination, m.Type, 0, ""); err != nil {
			log.Fatalf("container: failed to mount %s: %v", m.Destination, err)
		}
		defer func(dest string) {
			if err := syscall.Unmount(dest, 0); err != nil {
				log.Printf("container: failed to unmount %s: %v", dest, err)
			}
		}(m.Destination)
	}

	if err := syscall.Exec(spec.Process.Args[0], spec.Process.Args, os.Environ()); err != nil {
		log.Fatalf("container: failed to exec command %s: %v", spec.Process.Args[0], err)
	}
}

 

추가로 cmd 디렉터리에 main.go 하나로 통일했다.
굳이 나눌 필요가 없었다.

 

실제 실행시 잘 작동한다.

[yhw@hyunwooyoon containeruntime]$ sudo ./containeruntime create ubuntu /root/testbundle
ubuntu
[yhw@hyunwooyoon containeruntime]$ sudo ./containeruntime state ubuntu
{
  "ociVersion": "1.2.1",
  "id": "ubuntu",
  "status": "created",
  "pid": 5158,
  "bundle": "/root/testbundle"
}[yhw@hyunwooyoon containeruntime]$ sudo ./containeruntime start ubuntu
[yhw@hyunwooyoon containeruntime]$ root@oci-ubuntu:/#

 

 

이외에도 Cgroup관련 설계를 진행했다.

디렉터리 구조

 

cgroup SubSystem에 맞는 구조체를 짜넣고 Cgroup Manager에 넣어두면

CgroupManager.Setup()을 통해 Cgroup을 spec에 맞게 설정할거다.

package cgroup

import (
	"fmt"
	"os"
	"path/filepath"
)

// CgroupManager manages the cgroups for a container.
type CgroupManager struct {
	root          string
	containerName string
	subsystems    []SubSystem
}

// SubSystem represents a cgroup v2 controller.
type SubSystem interface {
	Name() string
	Setup(path string) error
	Clean(path string) error
}

// NewCgroupManager creates a new CgroupManager for a given container name.
func NewCgroupManager(containerName string, subsystems []SubSystem) *CgroupManager {
	return &CgroupManager{
		root:          "/sys/fs/cgroup",
		containerName: containerName,
		subsystems:    subsystems,
	}
}

// Setup creates the cgroup hierarchy and configures all subsystems.
func (m *CgroupManager) Setup() error {
	containerCgroup := filepath.Join(m.root, m.containerName)
	if err := os.Mkdir(containerCgroup, 0755); err != nil && !os.IsExist(err) {
		return fmt.Errorf("cgroup: failed to create container cgroup: %w", err)
	}

	var controllers []string
	for _, s := range m.subsystems {
		controllers = append(controllers, "+"+s.Name())
	}
	if len(controllers) > 0 {
		ctrl := []byte(fmt.Sprintf("%s", controllers))
		if err := os.WriteFile(filepath.Join(containerCgroup, "cgroup.subtree_control"), ctrl, 0700); err != nil {
			return fmt.Errorf("cgroup: failed to set controllers: %w", err)
		}
	}

	for _, s := range m.subsystems {
		if err := s.Setup(containerCgroup); err != nil {
			return fmt.Errorf("cgroup: subsystem %s setup failed: %w", s.Name(), err)
		}
	}
	return nil
}

// Clean removes the cgroup hierarchy and cleans up all subsystems.
func (m *CgroupManager) Clean() error {
	containerCgroup := filepath.Join(m.root, m.containerName)
	for _, s := range m.subsystems {
		if err := s.Clean(containerCgroup); err != nil {
			return fmt.Errorf("cgroup: subsystem %s clean failed: %w", s.Name(), err)
		}
	}
	if err := os.Remove(containerCgroup); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("cgroup: failed to remove container cgroup: %w", err)
	}
	return nil
}

 

서브시스템들을 구현하면서는 Cgroup의 동작원리등의 대한 설명을
더 자세히 하려고 하기에 다음 글에서 이어서 진행하려고 한다.

 

이제 어느 정도 골자가 설계되었고 상세한 부분을 구현하며
실제 동작원리나 이론적인 부분들도 다루어보겠다.

 

설명되지 않은 변경사항은 다음 PR을 참고하자

https://github.com/yoonhyunwoo/containeruntime/pull/4

 

Config based processing by yoonhyunwoo · Pull Request #4 · yoonhyunwoo/containeruntime

need squash merge

github.com

 

 

참고자료

https://github.com/opencontainers/runtime-spec

+ Recent posts