Containerd를 이용해 컨테이너 생성하기

아래 코드는 Containerd를 이용하여 컨테이너를 생성하는 예이다. 우선 UNIX 도메인 소켓인 containerd.sock을 이용하여 Containerd에 연결한다. Containerd는 백그라운드(background)에서 항상 동작하는 데몬(daemon) 프로세스이다. 그 후, Client 객체를 통하여 Containerd로 하여금 최신 데비안(Debian) 리눅스 이미지를 받아 데비안 컨테이너를 생성한다생성하도록 한다. 매우 직관적이고 간단하지 않은가? 여기서 알 수 있는 것은 Client 객체를 통하여 해당 프로세스와 통신한다는 점이다. 이 짧은 코드에서는 확인할 수 없지만 Client와 Containerd 간의 통신 과정을 본 포스트에 정리한다.

func main() {
	// create a new client connected to the default socket path for containerd
	client, err := containerd.New("/run/containerd/containerd.sock")
	
	// create a new context with an "builder" namespace
	ctx := namespaces.WithNamespace(context.Background(), "builder")

	// pull the debian image from DockerHub
	image, err := client.Pull(ctx,
							  "docker.io/library/debian:latest",
							  containerd.WithPullUnpack,
							  containerd.WithPullSnapshotter(snapshotter))
	
	// create a debian container
	container, err := client.NewContainer(
		ctx,
		"debian_container",	// container name
		containerd.WithImage(image),
		containerd.WithSnapshotter(snapshotter),
		// snapshot name should unique across snapshotters, namespaces
		containerd.WithNewSnapshot("debian_snapshot", image), // snapshot name 
		containerd.WithNewSpec(oci.WithImageConfig(image)),
	)
}

클라이언트 측의 대리자 Client

새로 생성할 컨테이너에 대한 기초 정보를 containers.Container에 담는다. containers.go에 정의된 이 구조체는 여러 필드들을 포함하지만 컨테이너를 생성하기 전이므로 생성에 필요한 정보, 즉, 새 컨테이너를 어떤 ID로 식별할 지, 어떤 런타임(runtime)으로 생성할 지만 담는다. 여기서 런타임이란 runc나 gVisor 같은 것들을 의미하는데 이에 대한 설명은 전편에서 확인할 수 있다. 여하튼 이 구조체를 ContainerService().Create() 메소드에 넘기면 컨테이너가 생성된다.

NewContainer() 메소드는 client.go 파일에 담겨있다. 해당 메소드의 리시버(receiver)는 Client 객체이다. 즉, Containerd를 임베딩(embedding)하는 입장에서 Client 객체를 생성하여야 컨테이너를 생성할 수 있다. pkg.go.dev/github.com/containerd/containerd에 나열된 API들의 일부가 이 Client 객체의 메소드들인데, GetImage(), LoadContainer()와 같이 직관적인 이름을 가진 메소드들이 포함되어 있다. 필자는 이 API들이 gRPC를 통해 호출해야 하는 Containerd의 것이라고 생각했는데, 사실은 Client의 것이었던 것이다. 쉽게 말하자면 Client는 클라이언트의 대리자로서, 클라이언트를 대신하여 gRPC를 이용해 Containerd와 연결을 맺는다. 따라서 클라이언트를 구현하는 개발자 입장에서는 gRPC 등 복잡한 내용을 신경 쓸 필요가 없다.

// NewContainer will create a new container with the provided id.
// The id must be unique within the namespace.
func (c *Client) NewContainer(ctx context.Context, id string, opts ...NewContainerOpts) (Container, error) {
	container := containers.Container{
		ID: id,
		Runtime: containers.RuntimeInfo{
			Name: c.runtime,
		},
	}
	r, err := c.ContainerService().Create(ctx, container)
	if err != nil {
		return nil, err
	}
	return containerFromRecord(c, r), nil
}

ContainerService()는 ContainerService 객체를 반환할 것 같지만 RemoteContainerStore를 반환한다. Remote? 퍼즐을 맞춰보자. Client는 RemoteContainerStore를 통해 gRPC 너머에 위치한 Containerd의 ContainerStore를 제어하게 된다. 즉, gRPC 연결의 양 끝 단은 RemoteContainerStore와 ContainerStore(실제로는 ContainersService)가 될 것이다. 따라서 지금부터 보게 될 코드들에는 gRPC 및 protobuf와 관련된 내용이 등장하기 시작한다. containersapi는 api/services/containers/v1/containers.proto에 의해 생성되는 containers.pb.go를 의미하며, 이 두 파일은 ContainersService에 한하여 Client와 Containerd 간의 통신 규약을 정의한 것이다. gRPC 및 protobuf에 대한 설명은 생략한다.

import (
	containersapi "github.com/containerd/containerd/api/services/containers/v1"
)

// ContainerService returns the underlying container Store
func (c *Client) ContainerService() containers.Store {
	if c.containerStore != nil {
		return c.containerStore
	}
	c.connMu.Lock()
	defer c.connMu.Unlock()
	return NewRemoteContainerStore(containersapi.NewContainersClient(c.conn))
}

RemoteContainerService는 그저 껍데기?

remoteContainerStore에 관련한 내용은 containerstore.go에 있는데 gRPC 너머에 실제 containerStore가 있음을 주석으로 알 수 있다. 본 파일에는 Get(), List(), Create(), Update(), Delete() 와 같은 메소드들이 있는데 이름에서 유추해 볼 수 있듯이 컨테이너의 생성/삭제, 기 생성된 컨테이너의 정보의 획득/갱신을 수행한다. 다만, 실제 작업은 Containerd의 containerStore가 수행할 뿐. 껍데기일 뿐이다.

// NewRemoteContainerStore returns the container Store connected with the provided client
func NewRemoteContainerStore(client containersapi.ContainersClient) containers.Store {
	return &remoteContainers{
		client: client,
	}
}

Create() 메소드만 보자. 껍데기인 만큼 컨테이너를 생성하는 듯한 내용은 콧빼기도 찾아볼 수 없다. 다만, r.client.Create() 메소드를 한 번 더 호출할 뿐이다. 다른 메소드들 전부가 이런 식이다. r.client는 위에서 본 containers.pb.go의 ContainersClient 객체이고 이 객체의 Create() 메소드는 gRPC 너머 서버 측 객체인 ContainersServer 객체의 Create() 메소드를 호출할 것이다. 이 .pb.go 확장자인 파일들의 내용은 gRPC에 의해 자동으로 생성되는 코드이므로 본 포스트에서는 귀찮아서 설명하지 않는다.

func (r *remoteContainers) Create(ctx context.Context, container containers.Container) (containers.Container, error) {
	created, err := r.client.Create(ctx, &containersapi.CreateContainerRequest{
		Container: containerToProto(&container),
	})
	if err != nil {
		return containers.Container{}, errdefs.FromGRPC(err)
	}

	return containerFromProto(&created.Container), nil

}

서버 측의 대리자 “containers”

아래 코드의 create() 메소드는 gRPC 너머 서버 측 객체인 ContainersServer 객체의 메소드이다. 그렇다면 gRPC는 어떻게 클라이언트 측의 객체인 ContainersClient의 create() 메소드를 여기로 매핑하는 걸까? 그 해답은 RegisterContainersServer() 함수이다. Containerd의 개발자는 gRPC가 정의해주는 ContainersServer 타입의 객체를 구현하여 인자로 넘겨줘야 한다. 결과적으로 이 함수는 개발자가 개발한 객체의 메소드가 호출이 되게끔 매핑한다. 매핑과 관련된 내용은 gRPC가 담당하므로 본 포스트에서는 귀찮아서 설명하지 않는다. 어쨋든 본 코드는 service.go라는 파일에 담겨있고 해당 파일은 “containers"라는 플러그인을 구현한다. 이에 관련된 내용은 다음 단락에서 설명한다.

func init() {
	plugin.Register(&plugin.Registration{
		Type: plugin.GRPCPlugin,
		ID:   "containers",
		Requires: []plugin.Type{
			plugin.ServicePlugin,
		},
		...
	...
}

var _ api.ContainersServer = &service{}

func (s *service) Register(server *grpc.Server) error {
	api.RegisterContainersServer(server, s)
	return nil
}

func (s *service) Create(ctx context.Context, req *api.CreateContainerRequest) (*api.CreateContainerResponse, error) {
	return s.local.Create(ctx, req)
}

드디어 밝혀진 내막 ContainersService 플러그인!!!

앞서 살펴본 ContainersServer 객체의 Create() 메소드가 직접 컨테이너를 생성해도 될 것 같은데 local 객체의 Create() 함수로 일을 떠넘기고 있다. 왜일까? 이제 Containerd 만의 독창적인 부분을 설명하고자 한다. 위의 코드는 service.go라는 파일에, 아래의 코드는 local.go라는 파일에 구현되어 있음을 기억하고 우선 아래의 네 가지 사항을 살펴보자.

  1. 본 포스트의 끝에 첨부한 Containerd의 아키텍처를 보면 여러 서비스(Services)들로 구성되어 있음을 알 수 있다. 이 서비스들은 전부 플러그인(Plugins), 정확히는 빌트인 플러그인(Built-in plugins)이다. 눈에 띄는 것이 하나 있다면 “ContainersService"일 것이다.

  2. 두 파일의 상단부 어디쯤엔가 init() 함수와 함께 plugin.Register() 메소드를 호출하여 플러그인을 초기화하는 코드가 있다.

  3. service.go는 gRPC의 끝 단인 service 객체에 대한 내용이며 “containers"라는 이름의 플러그인을 구현한다.

  4. local.go는 실제 컨테이너를 생성하는 local 객체에 대한 내용이며 services.ContainersService(추적해보면 “containers-service"라는 문자열)라는 플러그인을 구현한다.

요약하면 gRPC를 위한 껍데기인 server 객체는 service.go 파일에 담겨있고 이는 container 플러그인을 구현한다. 반면, local 객체는 local.go에 담겨있고 이는 실제 컨테이너의 생성 등 실제로 일을 하는 본체이며 ContainersService 플러그인을 구현한다. 이 ContainersService 플러그인이 앞서 아키텍처 사진에서 본 그것과 동일한 것이다. container 플러그인은 단순히 ContainersService를 위한 gRPC를 담당하는 Pass-through의 성격이니 그림에는 나오지 않는 것 같다. 나올 필요도 없고.

한 가지 의문점은 local.go의 파일명을 service.go로, service.go는 service-grpc.go 정도로 했으면 어땟을까 하는 점이다. 왜냐면 local.go가 ContainersService 플러그인을 구현하는데 정작 service.go와는 아무 관련이 없기 때문이다.

또 한 가지 더, local 객체가 gRPC가 생성한 ContainersClient 타입을 끌어다 구현되어 있는데 엄밀히 말해서 local 객체는 gRPC의 서버 측 끝 단에 위치한다. 왜 ContainersServer를 끌어다 쓰지 않은건지 논리적으로 이해가 잘 되지 않는다. 아마도 server 객체가 ContainersServer 타입을 이미 끌어다 쓰고 있어서 혼란을 방지하려고 이렇게 구현한 듯 싶은데 필자는 지금 구현이 더 혼란스럽게 느꺼진다. 어쨋든 중요한 건 아닌데 혹시라도 이유를 아시는 나그네는 꼭 지나치지 마시고 댓글 부탁드린다.

func init() {
	plugin.Register(&plugin.Registration{
		Type: plugin.ServicePlugin,
		ID:   services.ContainersService,
		Requires: []plugin.Type{
			plugin.EventPlugin,
			plugin.MetadataPlugin,
		},
		...
	...
}

var _ api.ContainersClient = &local{}

func (l *local) Create(ctx context.Context, req *api.CreateContainerRequest, _ ...grpc.CallOption) (*api.CreateContainerResponse, error) {
	var resp api.CreateContainerResponse

	if err := l.withStoreUpdate(ctx, func(ctx context.Context) error {
		container := containerFromProto(&req.Container)

		created, err := l.Store.Create(ctx, container)
		if err != nil {
			return err
		}

		resp.Container = containerToProto(&created)

		return nil
	}); err != nil {
		return &resp, errdefs.ToGRPC(err)
	}

	return &resp, nil
}

그림 요약 : gRPC를 이용한 Containerd의 통신 과정

지금까지 설명한 클라이언트와 Containerd 간의 통신 과정을 그림으로 나타내면 다음과 같다.

sequenceDiagram participant 클라이언트 participant Client participant RemoteClientStore participant ContainersClient Note right of ContainersServer: : server
containers plugin participant ContainersClient_ Note right of ContainersClient_: : local
ContainersService plugin 클라이언트->>+Client: NewContainer
(id, runtime) Client->>+RemoteClientStore: Create
(Container) RemoteClientStore->>+ContainersClient: Create
(CreateContainerRequest) Note right of ContainersClient: via gRPC connection ContainersClient-->>+ContainersServer: Create
(CreateContainerRequest) ContainersServer->>+ContainersClient_: Create
(CreateContainerRequest) Note right of ContainersClient_: Create a container!!! ContainersClient_->>-ContainersServer: CreateContainerResponse Note left of ContainersServer: via gRPC connection ContainersServer-->>-ContainersClient: CreateContainerResponse ContainersClient->>-RemoteClientStore: CreateConatinerResponse RemoteClientStore->>-Client: Container Client->>-클라이언트: Container

첨부 : Containerd의 아키텍처

Containerd 아키텍처