Вступление

Данная статья является второй, в цикле по истории развития и изучению исходного кода 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 }
На этом, думаю, можно завершить общую частью и перейти непосредственно к разбору кода команд.
Заключение
В следующей части мы подробно рассмотрим код отвечающий за работу с контейнерами, образами, репозиторием, сетевым стеком, а также создадим образ вручную и запустим контейнер на его основе.
