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라는 파일에 구현되어 있음을 기억하고 우선 아래의 네 가지 사항을 살펴보자.
본 포스트의 끝에 첨부한 Containerd의 아키텍처를 보면 여러 서비스(Services)들로 구성되어 있음을 알 수 있다. 이 서비스들은 전부 플러그인(Plugins), 정확히는 빌트인 플러그인(Built-in plugins)이다. 눈에 띄는 것이 하나 있다면 “ContainersService"일 것이다.
두 파일의 상단부 어디쯤엔가 init() 함수와 함께 plugin.Register() 메소드를 호출하여 플러그인을 초기화하는 코드가 있다.
service.go는 gRPC의 끝 단인 service 객체에 대한 내용이며 “containers"라는 이름의 플러그인을 구현한다.
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 간의 통신 과정을 그림으로 나타내면 다음과 같다.
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