시작하며
0편에서는 Podman의 매우 간략한 아키텍처를 살펴보았다.
이번 글에서는 가장 기본적인 명령어인 podman run이 어떻게 동작하는지 step-by-step으로 따라가보려고 한다.
예제로 아래의 명령어를 실행했을 때를 가정하여 이 글을 따라가겠다.
podman run alpine echo hello
겉으로 보기에는 단순하다.alpine 이미지를 사용한 컨테이너를 만들고 그 안에서 echo hello를 실행하는 명령이다.
하지만 내부적으로는 아래와 같이 많은 단계가 필요하다.
podman run alpine echo hello
1. Podman CLI 시작과 command 등록
-> podman CLI 실행
-> Go init()을 통한 command 등록
-> Cobra root command 실행
2. config/runtime 초기화와 engine 선택
-> config/runtime 초기화
-> local ABI 또는 remote tunnel 선택
3. run command 실행 준비
-> run command 실행
-> image lookup 또는 pull
-> SpecGenerator 생성
4. ContainerEngine.ContainerRun()
-> ContainerEngine.ContainerRun()
-> libpod container create
-> storage / network / mount 준비
-> OCI config.json 생성
-> conmon + OCI runtime create/start
-> attach / wait
-> 필요 시 cleanup
이번 글에서는 이 흐름을 따라가며 podman run이라는 한 줄의 명령이 Podman 내부에서 어떤 과정을 거쳐 실제 컨테이너 실행으로 이어지는지 살펴보려 한다.
1. Podman CLI 시작과 command 등록
📝 NOTE: podman은 Golang으로 만들어졌다.
Golang은 main() 함수부터 바로 실행되는 것처럼 보이지만, 실제로는 그 전에 런타임 초기화와 패키지 초기화 과정이 먼저 수행된다.
이때 import된 패키지들의 전역 변수 초기화가 먼저 일어나고, 이후 각 패키지의 init() 함수가 실행된다.
예를 들어 cmd/podman/main.go에는 다음과 같이 여러 패키지들이 있다.
// cmd/podman/main.go
import (
_ "go.podman.io/podman/v6/cmd/podman/images"
_ "go.podman.io/podman/v6/cmd/podman/pods"
_ "go.podman.io/podman/v6/cmd/podman/networks"
"go.podman.io/podman/v6/cmd/podman/registry"
(중략)
.
.
)
Podman에서 컨테이너 관련 CLI 명령이 정의되는 패키지는 go.podman.io/podman/v6/cmd/podman/containers이다.
그런데 실제로 cmd/podman/main.go 파일을 보면 이 패키지가 직접 import되어 있지 않다.
그렇다면 containers 패키지는 어디서 로드될까?
확인해보면 cmd/podman/pods/create.go 파일에서 다음과 같이 import되고 있다.
// cmd/podman/pods/create.go
"go.podman.io/podman/v6/cmd/podman/containers"
실제 podman run 작업을 위해 CLI에 등록하는 과정은 cmd/podman/containers/run.go 파일에서 일어난다. 그중 등록과 관련된 주요 코드에 대해서 간략하게 설명 하자면 runCommand는 podman run의 메타데이터와 실행 함수를 담은 Cobra객체이고, init()은 이 객체를 registry.Commands에 넣어 둔다.이후 main의 parseCommands()가 이를 Cobra command tree에 붙일 수 있게 만든다.
📝 NOTE: Cobra 공식 문서링크:https://cobra.dev/docs/
// cmd/podman/containers/run.go
// podman run 명령어 등록 코드
runCommand = &cobra.Command{
Args: cobra.MinimumNArgs(1),
Use: "run [options] IMAGE [COMMAND [ARG...]]",
Short: "Run a command in a new container",
Long: runDescription,
RunE: run,
ValidArgsFunction: common.AutocompleteCreateRun,
Example: `podman run imageID ls -alF /etc
podman run --network=host imageID dnf -y install java
podman run --volume /var/hostdir:/var/ctrdir -i -t fedora /bin/bash`,
}
func init() {
registry.Commands = append(registry.Commands, registry.CliCommand{
Command: runCommand,
})
runFlags(runCommand)
... 중략
}
다만 여기서 registry.Commands에 추가되었다고 해서 곧바로 podman run이 실행 가능한 상태가 되는 것은 아니다.
registry.Commands는 Podman 내부에서 사용하는 command 등록 목록이다.
main()이 실행될 때 cmd/podman/main.go의 parseCommands()가 이 목록을 순회한 뒤 각 command를 Cobra command tree에 연결한다.
// cmd/podman/main.go
func parseCommands() *cobra.Command {
cfg := registry.PodmanConfig()
for _, c := range registry.Commands {
...
addCommand(c)
}
...
return rootCmd
}
📝 NOTE: 실제로 command를 Cobra tree에 붙이는 작업 함수는 addCommand()다.
// cmd/podman/main.go
func addCommand(c registry.CliCommand) {
parent := rootCmd
if c.Parent != nil {
parent = c.Parent
}
parent.AddCommand(c.Command)
...
}
podman run의 runCommand는 Parent가 없는 형태로 registry.Commands에 들어간다.
따라서 addCommand()에서 parent는 rootCmd가 되고, 최종적으로 rootCmd.AddCommand(runCommand)가 호출된다.
그 결과 다음과 같은 트리가 생긴다고 보면 된다.
podman
└── run
이후 rootCmd.ExecuteContext()가 호출되면 Cobra가 실제 사용자가 입력한 os.Args를 파싱한다.
예를 들어 podman run alpine echo hello를 입력했다면 Cobra는 run이라는 subcommand를 찾고, 이를 앞에서 등록한 runCommand와 매칭한다.
// cmd/podman/root.go
func Execute() {
if err := rootCmd.ExecuteContext(registry.Context()); err != nil {
...
}
}
여기서 중요한 점은 rootCmd.ExecuteContext()가 run()을 직접 호출하는 것이 아니라는 점이다.
ExecuteContext()는 Cobra에게 “이제 사용자의 입력을 파싱하고, 알맞은 command를 찾아 실행하라”고 위임하는 단계다.
실제 run() 함수가 호출되기 전에는 Cobra의 실행 hook들이 먼저 실행되는데, 그중 Podman에서 중요한 것이 root command의 PersistentPreRunE다.
이 부분은 다음 섹션에서 이어서 살펴본다.
정리
podman run 등록 및 실행 함수 연결 흐름은 다음과 같다.
cmd/podman/main.go
-> cmd/podman/pods 패키지를 import
-> pods/create.go가 cmd/podman/containers 패키지를 import
-> containers/run.go에서 runCommand 정의
-> init()에서 runCommand를 registry.Commands에 추가
-> main.parseCommands()에서 registry.Commands 순회
-> addCommand()가 runCommand를 Cobra command tree에 연결
-> rootCmd.ExecuteContext()에서 Cobra가 os.Args 파싱
-> Cobra가 podman run을 runCommand에 등록
2. PersistentPreRunE: config/runtime 초기화와 engine 선택
앞에서 podman run 입력이 runCommand에 등록되는 과정까지 살펴보았다.
이제 runCommand의 RunE에 연결된 run() 함수가 바로 실행될 것 같지만, 실제로는 그 전에 root command의 PersistentPreRunE가 먼저 실행된다.
PersistentPreRunE는 Cobra command 실행 전에 공통 초기화/검증을 수행하는 hook이다.
이 hook에서 에러가 발생하면 실제 command의 RunE는 실행되지 않는다.
Podman은 이 단계에서 config를 반영하고, ImageEngine/ContainerEngine을 준비하며, local ABI 또는 remote tunnel 중 어떤 실행 경로를 사용할지 결정한다.
// cmd/podman/root.go
rootCmd = &cobra.Command{
...
PersistentPreRunE: persistentPreRunE,
RunE: validate.SubCommandExists,
...
}
위의 설정 때문에 Cobra는 runCommand.RunE를 호출하기 전에 persistentPreRunE를 먼저 호출한다.
흐름을 다시 한번 파악하면 다음과 같다.
rootCmd.ExecuteContext()
-> Cobra가 os.Args 파싱
-> podman run을 runCommand에 매칭
-> rootCmd.PersistentPreRunE 실행
-> runCommand.RunE에 연결된 run() 실행
persistentPreRunE 내부에는 여러 초기화 로직이 있지만, podman run 흐름에서 특히 중요한 부분은 engine을 준비하는 코드다.
// cmd/podman/root.go
// Prep the engines
if _, err := registry.NewImageEngine(cmd, args); err != nil {
return err
}
if _, err := registry.NewContainerEngine(cmd, args); err != nil {
return err
}
Podman은 image 작업과 container 작업을 분리해서 다룬다.
ImageEngine은 image pull, lookup, inspect 같은 이미지 관련 작업을 담당한다.
ContainerEngine은 container create, run, start, rm 같은 컨테이너 관련 작업을 담당한다.
먼저 registry.NewImageEngine()과 registry.NewContainerEngine()을 보자.
두 함수는 이미 생성된 engine이 있으면 재사용하고, 없으면 infra 패키지를 통해 새 engine을 만든다.
// cmd/podman/registry/registry.go
func NewImageEngine(cmd *cobra.Command, _ []string) (entities.ImageEngine, error) {
if imageEngine == nil {
podmanOptions.FlagSet = cmd.Flags()
engine, err := infra.NewImageEngine(&podmanOptions)
if err != nil {
return nil, err
}
imageEngine = engine
}
return imageEngine, nil
}
func NewContainerEngine(cmd *cobra.Command, _ []string) (entities.ContainerEngine, error) {
if containerEngine == nil {
podmanOptions.FlagSet = cmd.Flags()
engine, err := infra.NewContainerEngine(&podmanOptions)
if err != nil {
return nil, err
}
containerEngine = engine
}
return containerEngine, nil
}
여기서 podmanOptions.FlagSet = cmd.Flags()를 통해 현재 command의 flag 정보가 engine 생성 과정에 전달된다.
이제 infra.NewContainerEngine()을 보면 local ABI와 remote tunnel 로직을 확인할 수 있다.
// pkg/domain/infra/runtime_abi.go
func NewContainerEngine(facts *entities.PodmanConfig) (entities.ContainerEngine, error) {
switch facts.EngineMode {
case entities.ABIMode:
r, err := NewLibpodRuntime(facts.FlagSet, facts)
return r, err
case entities.TunnelMode:
ctx, err := newConnectionWithoutLock(context.Background(), facts)
return &tunnel.ContainerEngine{ClientCtx: ctx}, err
}
return nil, fmt.Errorf("runtime mode '%v' is not supported", facts.EngineMode)
}
EngineMode가 ABIMode라면 NewLibpodRuntime()을 통해 로컬 libpod runtime을 생성한다.
이 경우 이후 컨테이너 생성과 실행은 로컬 libpod를 통해 직접 진행된다.
반대로 TunnelMode라면 Podman은 로컬 libpod를 직접 사용하지 않는다.
newConnectionWithoutLock()으로 Podman API 서버와 통신할 context를 만들고, tunnel.ContainerEngine을 반환한다.
이후 컨테이너 관련 요청은 HTTP API binding을 통해 remote Podman service로 전달된다.
정리
이 단계는 실제 컨테이너를 생성하는 단계가 아닌 run() 함수가 실행되기 전에 Podman이 공통 실행 환경을 준비하는 단계다.
흐름은 다음과 같다.
rootCmd.ExecuteContext()
-> Cobra가 podman run을 runCommand에 매칭
-> persistentPreRunE 실행
-> registry.NewImageEngine()
-> registry.NewContainerEngine()
-> infra.NewContainerEngine()
-> EngineMode에 따라 ABIMode 또는 TunnelMode 선택
-> 준비가 끝나면 runCommand.RunE에 연결된 run() 실행
다음 게시글에서는 실제 cmd/podman/containers/run.go의 run() 함수 안으로 들어가서, CLI 옵션을 검증하고 alpine 이미지를 준비하는 과정을 작성할 예정이다.
'프로그래밍 > Podman' 카테고리의 다른 글
| [Podman] Deep Dive 0편: Podmman 아키텍처 (0) | 2026.05.24 |
|---|