Эволюция Docker. Часть 2.1

Kate

Administrator
Команда форума

Вступление​

v0.1.0
v0.1.0
Данная статья является второй, в цикле по истории развития и изучению исходного кода Docker. В ней мы разберем, что представлял собой первый публичный релиз от 23 марта 2013 года.

Изначально я планировал уложить весь материал, посвященный этой версии, в одной статье, но в процессе стало ясно, что она получается слишком большой, поэтому я решил разделить ее на две. В текущей части (2.1) будет рассмотрена лишь общая структура и начальный код, а последующая часть (2.2) будет посвящена принципу работы и коду конкретных команд.

Некоторые части кода уже были разобраны в первой статье, так что для полноты восприятия, рекомендую начать с нее, а также пятиминутной презентации The Future of Linux Containers, на которой и была представлена первая версия Docker.

Docker v0.1.0​

Все действия будут выполняться, как и ранее, на Windows 10 и Vagrant с Ubuntu 20.04. Начнем с установки требуемых пакетов:

sudo apt-get update && sudo apt-get -y install lxc libarchive-tools curl golang git debootstrap tree
Клонируем репозиторий и перейдем на версию v0.1.0:

cd /home/vagrant && git clone https://github.com/docker/engine.git && cd ./engine
git checkout -f v0.1.0 && git log | head -n 5 && tree
HEAD is now at 57e2126a02 Bumped version to 0.1.0
commit 57e2126a02f8b96b0542df7f6a573233d8419bb1
Author: Solomon Hykes <solomon@dotcloud.com>
Date: Sat Mar 23 17:48:18 2013 -0700

Bumped version to 0.1.0
.
├── AUTHORS
├── LICENSE
├── NOTICE
├── README.md
├── Vagrantfile
├── archive.go
├── archive_test.go
├── auth
│ ├── auth.go
│ └── auth_test.go
├── changes.go
├── commands.go
├── container.go
├── container_test.go
├── contrib
│ ├── README
│ ├── install.sh
│ └── mkimage-busybox.sh
├── deb
│ ├── Makefile -> ../Makefile
│ ├── Makefile.deb
│ ├── README.md -> ../README.md
│ ├── debian
│ │ ├── changelog
│ │ ├── compat
│ │ ├── control
│ │ ├── copyright
│ │ ├── docs
│ │ ├── rules
│ │ └── source
│ │ └── format
│ └── etc
│ ├── docker-dev.upstart
│ └── docker.upstart
├── docker
│ └── docker.go
├── docs
│ ├── README.md
│ └── images-repositories-push-pull.md
├── graph.go
├── graph_test.go
├── image.go
├── lxc_template.go
├── mount.go
├── mount_darwin.go
├── mount_linux.go
├── network.go
├── network_test.go
├── puppet
│ ├── manifests
│ │ └── quantal64.pp
│ └── modules
│ └── docker
│ ├── manifests
│ │ └── init.pp
│ └── templates
│ ├── dockerd.conf
│ └── profile
├── rcli
│ ├── http.go
│ ├── tcp.go
│ └── types.go
├── registry.go
├── runtime.go
├── runtime_test.go
├── state.go
├── sysinit.go
├── tags.go
├── term
│ ├── term.go
│ ├── termios_darwin.go
│ └── termios_linux.go
├── utils.go
└── utils_test.go

16 directories, 58 files
Как можно заметить, со времени первого коммита файлов заметно прибавилось. Директории deb и puppet мы опустим, так как они нас мало интересуют.

Для начала попробуем скомпилировать и запустить данную версию программы:

go mod init github.com/dotcloud/docker && cd docker && go build docker.go
go: creating new go.mod: module github.com/dotcloud/docker
go: finding github.com/kr/pty v1.1.8
go: downloading github.com/kr/pty v1.1.8
go: extracting github.com/kr/pty v1.1.8
go: downloading github.com/creack/pty v1.1.7
go: extracting github.com/creack/pty v1.1.7
go: finding github.com/creack/pty v1.1.7
vagrant@ubuntu-focal:/vagrant/engine/docker$ sudo ./docker version
docker version
Version:0.1.0

vagrant@ubuntu-focal:~/engine/docker$ sudo ./docker help
Usage: docker COMMAND [arg...]

A self-sufficient runtime for linux containers.

Commands:
run Run a command in a container
ps Display a list of containers
import Create a new filesystem image from the contents of a tarball
attach Attach to a running container
commit Create a new image from a container's changes
history Show the history of an image
diff Inspect changes on a container's filesystem
images List images
info Display system-wide information
inspect Return low-level information on a container
kill Kill a running container
login Register or Login to the docker registry server
logs Fetch the logs of a container
port Lookup the public-facing port which is NAT-ed to PRIVATE_PORT
ps List containers
pull Pull an image or a repository to the docker registry server
push Push an image or a repository to the docker registry server
restart Restart a running container
rm Remove a container
rmi Remove an image
run Run a command in a new container
start Start a stopped container
stop Stop a running container
export Stream the contents of a container as a tar archive
version Show the docker version information
wait Block until a container stops, then print its exit code
Уже в первой версии имеется знакомый нам функционал для работы с контейнерами, образами, историей, репозиторием, сетевыми портами и тп.

К сожалению, воспользоваться репозиторием для скачивания образа не получится, так как с тех пор формат и сам репозиторий поменялись, а для запуска контейнера нужно будет применить патч для lxc шаблона. Но во второй части, мы вручную создадим образ при помощи утилиты debootstrap, применим патч, после чего импортируем и запустим контейнер. А пока приступим к изучению кода.

Entry point​

Главной точкой входа, является функция main в файле docker/docker.go:

func main() {
if docker.SelfPath() == "/sbin/init" {
// Running in init mode
docker.SysInit()
return
}
// FIXME: Switch d and D ? (to be more sshd like)
fl_daemon := flag.Bool("d", false, "Daemon mode")
fl_debug := flag.Bool("D", false, "Debug mode")
flag.Parse()
rcli.DEBUG_FLAG = *fl_debug
if *fl_daemon {
if flag.NArg() != 0 {
flag.Usage()
return
}
if err := daemon(); err != nil {
log.Fatal(err)
}
} else {
if err := runCommand(flag.Args()); err != nil {
log.Fatal(err)
}
}
}
В самом начале функции находится, как может показаться, довольно странная проверка. Запускаемый файл проверяется на соответствие с /sbin/init. В следующей части, когда мы перейдем к функции запуска контейнера, будет видно, что исполняемый файл docker, монтируется в точку /sbin/init, запускаемого lxc контейнера, а функция docker.SysInit() из файла sysinit.go производит настройку окружения и последующий запуск требуемого процесса в контейнере. На данном этапе мы пропустим эту часть.

// Sys Init code
// This code is run INSIDE the container and is responsible for setting
// up the environment before running the actual process
func SysInit() {
if len(os.Args) <= 1 {
fmt.Println("You should not invoke docker-init manually")
os.Exit(1)
}
var u = flag.String("u", "", "username or uid")
var gw = flag.String("g", "", "gateway address")

flag.Parse()

setupNetworking(*gw)
changeUser(*u)
executeProgram(flag.Arg(0), flag.Args())
}
Далее в зависимости от флага -d, докер может запускаться в режиме демона работая по сетевому сокету или выполнять команды локально.

func daemon() error {
service, err := docker.NewServer()
if err != nil {
return err
}
return rcli.ListenAndServe("tcp", "127.0.0.1:4242", service)
}
Функция daemon стартует простой tcp сервер на порту 4242, принимает соединения, читает и выполняет команды переданные в json формате. Его код можно найти в файле rcli/tcp.go:

// Listen on `addr`, using protocol `proto`, for incoming rcli calls,
// and pass them to `service`.
func ListenAndServe(proto, addr string, service Service) error {
listener, err := net.Listen(proto, addr)
if err != nil {
return err
}
log.Printf("Listening for RCLI/%s on %s\n", proto, addr)
defer listener.Close()
for {
if conn, err := listener.Accept(); err != nil {
return err
} else {
go func() {
if DEBUG_FLAG {
CLIENT_SOCKET = conn
}
if err := Serve(conn, service); err != nil {
log.Printf("Error: " + err.Error() + "\n")
fmt.Fprintf(conn, "Error: "+err.Error()+"\n")
}
conn.Close()
}()
}
}
return nil
}

// Parse an rcli call on a new connection, and pass it to `service` if it
// is valid.
func Serve(conn io.ReadWriter, service Service) error {
r := bufio.NewReader(conn)
var args []string
if line, err := r.ReadString('\n'); err != nil {
return err
} else if err := json.Unmarshal([]byte(line), &args); err != nil {
return err
} else {
return call(service, ioutil.NopCloser(r), conn, args...)
}
return nil
}

// FIXME: For reverse compatibility
func call(service Service, stdin io.ReadCloser, stdout io.Writer, args ...string) error {
return LocalCall(service, stdin, stdout, args...)
}
В случае же запуска докера без флага -d выполнение переходит к функции runCommand:

func runCommand(args []string) error {
var oldState *term.State
var err error
if term.IsTerminal(0) && os.Getenv("NORAW") == "" {
oldState, err = term.MakeRaw(0)
if err != nil {
return err
}
defer term.Restore(0, oldState)
}
// FIXME: we want to use unix sockets here, but net.UnixConn doesn't expose
// CloseWrite(), which we need to cleanly signal that stdin is closed without
// closing the connection.
// See http://code.google.com/p/go/issues/detail?id=3345
if conn, err := rcli.Call("tcp", "127.0.0.1:4242", args...); err == nil {
receive_stdout := docker.Go(func() error {
_, err := io.Copy(os.Stdout, conn)
return err
})
send_stdin := docker.Go(func() error {
_, err := io.Copy(conn, os.Stdin)
if err := conn.CloseWrite(); err != nil {
log.Printf("Couldn't send EOF: " + err.Error())
}
return err
})
if err := <-receive_stdout; err != nil {
return err
}
if !term.IsTerminal(0) {
if err := <-send_stdin; err != nil {
return err
}
}
} else {
service, err := docker.NewServer()
if err != nil {
return err
}
if err := rcli.LocalCall(service, os.Stdin, os.Stdout, args...); err != nil {
return err
}
}
if oldState != nil {
term.Restore(0, oldState)
}
return nil
}
В самом начале происходит перевод терминала в raw режим. Весь функционал, отвечающий за работу с терминалом, находится в папке term.

Далее идет попытка подключения и отправка команды демону функцией rcli.Call из файла rcli/tcp.go (демон должен быть запущен заранее с флагом -d):

// Connect to a remote endpoint using protocol `proto` and address `addr`,
// issue a single call, and return the result.
// `proto` may be "tcp", "unix", etc. See the `net` package for available protocols.
func Call(proto, addr string, args ...string) (*net.TCPConn, error) {
cmd, err := json.Marshal(args)
if err != nil {
return nil, err
}
conn, err := net.Dial(proto, addr)
if err != nil {
return nil, err
}
if _, err := fmt.Fprintln(conn, string(cmd)); err != nil {
return nil, err
}
return conn.(*net.TCPConn), nil
}
В случае, если попытка не удалась, команда выполняется локально при помощи вызова LocalCall и структуры возвращаемой функцией docker.NewServer, которая находится в файле commands.go:

func NewServer() (*Server, error) {
rand.Seed(time.Now().UTC().UnixNano())
if runtime.GOARCH != "amd64" {
log.Fatalf("The docker runtime currently only supports amd64 (not %s). This will change in the future. Aborting.", runtime.GOARCH)
}
runtime, err := NewRuntime()
if err != nil {
return nil, err
}
srv := &Server{
runtime: runtime,
}
return srv, nil
}

type Server struct {
runtime *Runtime
}
Мы вернемся к структуре Server и Runtime немного позже, а сперва посмотрим на функцию LocalCall из файла rcli/types.go:

func LocalCall(service Service, stdin io.ReadCloser, stdout io.Writer, args ...string) error {
if len(args) == 0 {
args = []string{"help"}
}
flags := flag.NewFlagSet("main", flag.ContinueOnError)
flags.SetOutput(stdout)
flags.Usage = func() { stdout.Write([]byte(service.Help())) }
if err := flags.Parse(args); err != nil {
return err
}
cmd := flags.Arg(0)
log.Printf("%s\n", strings.Join(append(append([]string{service.Name()}, cmd), flags.Args()[1:]...), " "))
if cmd == "" {
cmd = "help"
}
method := getMethod(service, cmd)
if method != nil {
return method(stdin, stdout, flags.Args()[1:]...)
}
return errors.New("No such command: " + cmd)
}
В ней происходит разбор аргументов и выполнение команды, полученной из getMethod, который использует рефлексию структуры Server для вычисления метода соответствующего переданной команде по шаблону "Cmd" + MethodName:

func getMethod(service Service, name string) Cmd {
if name == "help" {
return func(stdin io.ReadCloser, stdout io.Writer, args ...string) error {
if len(args) == 0 {
stdout.Write([]byte(service.Help()))
} else {
if method := getMethod(service, args[0]); method == nil {
return errors.New("No such command: " + args[0])
} else {
method(stdin, stdout, "--help")
}
}
return nil
}
}
methodName := "Cmd" + strings.ToUpper(name[:1]) + strings.ToLower(name[1:])
method, exists := reflect.TypeOf(service).MethodByName(methodName)
if !exists {
return nil
}
return func(stdin io.ReadCloser, stdout io.Writer, args ...string) error {
ret := method.Func.CallSlice([]reflect.Value{
reflect.ValueOf(service),
reflect.ValueOf(stdin),
reflect.ValueOf(stdout),
reflect.ValueOf(args),
})[0].Interface()
if ret == nil {
return nil
}
return ret.(error)
}
}
Все доступные методы лежат в файле commands.go, их разбором мы займемся во второй части, а пока вернемся к функции NewRuntime вызываемой в функции docker.NewServer. Она содержится в файле runtime.go и которая возвращает структуру Runtime сохраняемую в структуре Server:

func NewRuntime() (*Runtime, error) {
return NewRuntimeFromDirectory("/var/lib/docker")
}

func NewRuntimeFromDirectory(root string) (*Runtime, error) {
runtime_repo := path.Join(root, "containers")

if err := os.MkdirAll(runtime_repo, 0700); err != nil && !os.IsExist(err) {
return nil, err
}

g, err := NewGraph(path.Join(root, "graph"))
if err != nil {
return nil, err
}
repositories, err := NewTagStore(path.Join(root, "repositories"), g)
if err != nil {
return nil, fmt.Errorf("Couldn't create Tag store: %s", err)
}
netManager, err := newNetworkManager(networkBridgeIface)
if err != nil {
return nil, err
}
authConfig, err := auth.LoadConfig(root)
if err != nil && authConfig == nil {
// If the auth file does not exist, keep going
return nil, err
}

runtime := &Runtime{
root: root,
repository: runtime_repo,
containers: list.New(),
networkManager: netManager,
graph: g,
repositories: repositories,
authConfig: authConfig,
}

if err := runtime.restore(); err != nil {
return nil, err
}
return runtime, nil
}
Как можно заметить, часть этого функционала раньше находилась в файле docker.go, который мы разобрали в первой статье. Теперь же сюда добавлены и новые структуры:

  • graph (NewGraph) - отвечает за работу со слоями и зависимостями образа. Файл graph.go
  • networkManager (newNetworkManager) - отвечает за весь сетевой стек. Файл network.go
  • repositories (NewTagStore) - отвечает за локальный репозиторий и работу с метками (tags) образов. Файл tags.go
  • authConfig (auth.LoadConfig) - отвечает за хранение данных для авторизации на удаленном репозитории образов. Файл auth/auth.go
Работу метода restore мы также разбирали в первой части. Принцип остался прежним, изменения коснулись лишь процедуры инициализации существующих контейнеров:

func (runtime *Runtime) restore() error {
dir, err := ioutil.ReadDir(runtime.repository)
if err != nil {
return err
}
for _, v := range dir {
id := v.Name()
container, err := runtime.Load(id)
if err != nil {
Debugf("Failed to load container %v: %v", id, err)
continue
}
Debugf("Loaded container %v", container.Id)
}
return nil
}

func (runtime *Runtime) Load(id string) (*Container, error) {
container := &Container{root: runtime.containerRoot(id)}
if err := container.FromDisk(); err != nil {
return nil, err
}
if container.Id != id {
return container, fmt.Errorf("Container %s is stored at %s", container.Id, id)
}
if err := runtime.Register(container); err != nil {
return nil, err
}
return container, nil
}

func (container *Container) FromDisk() error {
data, err := ioutil.ReadFile(container.jsonPath())
if err != nil {
return err
}
// Load container settings
if err := json.Unmarshal(data, container); err != nil {
return err
}
return nil
}
В первой части мне пришлось делать небольшой патч, теперь же метод Register осуществляет полную инициализацию контейнера:

// Register makes a container object usable by the runtime as <container.Id>
func (runtime *Runtime) Register(container *Container) error {
if container.runtime != nil || runtime.Exists(container.Id) {
return fmt.Errorf("Container is already loaded")
}
if err := validateId(container.Id); err != nil {
return err
}
container.runtime = runtime
// Setup state lock (formerly in newState()
lock := new(sync.Mutex)
container.State.stateChangeLock = lock
container.State.stateChangeCond = sync.NewCond(lock)
// Attach to stdout and stderr
container.stderr = newWriteBroadcaster()
container.stdout = newWriteBroadcaster()
// Attach to stdin
if container.Config.OpenStdin {
container.stdin, container.stdinPipe = io.Pipe()
} else {
container.stdinPipe = NopWriteCloser(ioutil.Discard) // Silently drop stdin
}
// Setup logging of stdout and stderr to disk
if err := runtime.LogToDisk(container.stdout, container.logPath("stdout")); err != nil {
return err
}
if err := runtime.LogToDisk(container.stderr, container.logPath("stderr")); err != nil {
return err
}
// done
runtime.containers.PushBack(container)
return nil
}
Функционал writeBroadcaster, State знакомый нам из первой части, остался без изменения, добавилась лишь заглушка для stdin:

type nopWriteCloser struct {
io.Writer
}

func (w *nopWriteCloser) Close() error { return nil }

func NopWriteCloser(w io.Writer) io.WriteCloser {
return &nopWriteCloser{w}
}
и логирование потоков stdout stderr в файл на диск:

func (container *Container) logPath(name string) string {
return path.Join(container.root, fmt.Sprintf("%s-%s.log", container.Id, name))
}

func (runtime *Runtime) LogToDisk(src *writeBroadcaster, dst string) error {
log, err := os.OpenFile(dst, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0600)
if err != nil {
return err
}
src.AddWriter(NopWriteCloser(log))
return nil
}
На этом, думаю, можно завершить общую частью и перейти непосредственно к разбору кода команд.

Заключение​

В следующей части мы подробно рассмотрим код отвечающий за работу с контейнерами, образами, репозиторием, сетевым стеком, а также создадим образ в ручную и запустим контейнер на его основе.

 
Сверху