Pull to refresh

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

Reading time11 min
Views6K

Вступление

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
}

На этом, думаю, можно завершить общую частью и перейти непосредственно к разбору кода команд.

Заключение

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

Tags:
Hubs:
Total votes 9: ↑7 and ↓2+6
Comments0

Articles