gce-containers-startup/runtime/runtime.go (309 lines of code) (raw):

// Copyright 2017 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package runtime import ( "bytes" "encoding/base64" "encoding/json" "fmt" "hash/fnv" "io/ioutil" "log" "math/rand" "os" "strings" "unicode" "golang.org/x/net/context" dockerapi "github.com/docker/engine-api/client" dockertypes "github.com/docker/engine-api/types" dockercontainer "github.com/docker/engine-api/types/container" dockernetwork "github.com/docker/engine-api/types/network" dockerstrslice "github.com/docker/engine-api/types/strslice" "io" "github.com/GoogleCloudPlatform/konlet/gce-containers-startup/metadata" api "github.com/GoogleCloudPlatform/konlet/gce-containers-startup/types" "github.com/GoogleCloudPlatform/konlet/gce-containers-startup/volumes" ) const DOCKER_UNIX_SOCKET = "unix:///var/run/docker.sock" const CONTAINER_NAME_PREFIX = "klt" // operationTimeout is the error returned when the docker operations are timeout. type operationTimeout struct { err error operationType string } type DockerApiClient interface { ImagePull(ctx context.Context, ref string, options dockertypes.ImagePullOptions) (io.ReadCloser, error) ContainerCreate(ctx context.Context, config *dockercontainer.Config, hostConfig *dockercontainer.HostConfig, networkingConfig *dockernetwork.NetworkingConfig, containerName string) (dockertypes.ContainerCreateResponse, error) ContainerStart(ctx context.Context, container string) error ContainerList(ctx context.Context, opts dockertypes.ContainerListOptions) ([]dockertypes.Container, error) ContainerRemove(ctx context.Context, containerID string, opts dockertypes.ContainerRemoveOptions) error } type OsCommandRunner interface { Run(...string) (string, error) MkdirAll(path string, perm os.FileMode) error Stat(name string) (os.FileInfo, error) } func (e operationTimeout) Error() string { return fmt.Sprintf("%s operation timeout: %v", e.operationType, e.err) } type ContainerRunner struct { Client DockerApiClient VolumesEnv *volumes.Env RandEnv *rand.Rand } // To produce deterministic results, tests can use a constant seed, while real runtime // can seed based on entropy. func generateRandomSuffix(length int, randEnv *rand.Rand) string { var letters = []rune("abcdefghijklmnopqrstuvwxyz") generated := make([]rune, length) for i := range generated { generated[i] = letters[randEnv.Intn(len(letters))] } return string(generated) } func GetDefaultRunner(osCommandRunner OsCommandRunner, metadataProvider metadata.Provider) (*ContainerRunner, error) { var dockerClient DockerApiClient var err error dockerClient, err = dockerapi.NewClient(DOCKER_UNIX_SOCKET, "", nil, nil) if err != nil { return nil, err } // In order to make container names and other randomly generated content // deterministic on the same machine during each restart cycle, we seed // the generator with hostname and boot time. var hostname string hostname, err = os.Hostname() if err != nil { return nil, err } var lastBootTime string lastBootTime, err = osCommandRunner.Run("who", "-b") if err != nil { return nil, err } hashedHostnameAndBoot := fnv.New64a() hashedHostnameAndBoot.Write([]byte(hostname)) hashedHostnameAndBoot.Write([]byte(" * * * ")) // Some separator. hashedHostnameAndBoot.Write([]byte(lastBootTime)) randEnv := rand.New(rand.NewSource(int64(hashedHostnameAndBoot.Sum64()))) return &ContainerRunner{Client: dockerClient, RandEnv: randEnv, VolumesEnv: &volumes.Env{OsCommandRunner: osCommandRunner, MetadataProvider: metadataProvider}}, nil } func (runner ContainerRunner) RunContainer(auth string, spec api.ContainerSpecStruct, detach bool) error { var id string var err error id, err = createContainer(runner, auth, spec) if err != nil { return err } err = startContainer(runner.Client, id) if err != nil { return err } return nil } func pullImage(dockerClient DockerApiClient, auth string, spec api.Container) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() authStruct := dockertypes.AuthConfig{} if auth != "" { authStruct.Username = "_token" authStruct.Password = auth } base64Auth, err := base64EncodeAuth(authStruct) if err != nil { return err } opts := dockertypes.ImagePullOptions{} opts.RegistryAuth = base64Auth log.Printf("Pulling image: '%s'", spec.Image) resp, err := dockerClient.ImagePull(ctx, spec.Image, opts) if err != nil { return err } defer resp.Close() body, err := ioutil.ReadAll(resp) if err != nil { return err } log.Printf("Received ImagePull response: (%s).\n", body) return nil } // deleteOldContainers deletes all containers started by konlet. // rawName is a container name without any generate prefixes or suffixes. func deleteOldContainers(dockerClient DockerApiClient, rawName string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() listOpts := dockertypes.ContainerListOptions{All: true} containers, err := dockerClient.ContainerList(ctx, listOpts) if err != nil { return err } idsNames := containersStartedByKonlet(containers, rawName) if len(idsNames) == 0 { log.Print("No containers created by previous runs of Konlet found.\n") return nil } for id, name := range idsNames { log.Printf("Removing a container created by a previous run of Konlet: '%s' (ID: %s)\n", name, id) rmOpts := dockertypes.ContainerRemoveOptions{ Force: true, } if err := dockerClient.ContainerRemove(ctx, id, rmOpts); err != nil { return err } } return nil } // containersStartedByKonlet filters a given list of containers to return a map // from container id to container name for containers started by konlet. // These are all containers whose names match one of two cases: // 1. the name starts with '/klt-', // 2. the name is '/<rawName>', where rawName is a legacy container name passed as an argument. // NOTE: The reason for the leading slash is Docker's convention of adding these // to names specified by the user. // NOTE: The reason for two cases above is that historically konlet started // containers without prefixes or suffixes and it would fail to delete an old // container after system update to a newer version if it only looked for the prefix. // See https://github.com/GoogleCloudPlatform/konlet/issues/50 func containersStartedByKonlet(containers []dockertypes.Container, rawName string) map[string]string { var ( // Matches containers started by konlet. namePattern1 = "/klt-" // The legacy pattern, see the comment on top of the function. namePattern2 = "/" + rawName ) idsNames := make(map[string]string) for _, container := range containers { for _, name := range container.Names { if strings.HasPrefix(name, namePattern1) || name == namePattern2 { idsNames[container.ID] = name break } } } return idsNames } func createContainer(runner ContainerRunner, auth string, spec api.ContainerSpecStruct) (string, error) { if len(spec.Containers) != 1 { return "", fmt.Errorf("Exactly one container in declaration expected.") } container := spec.Containers[0] generatedContainerName := fmt.Sprintf("%s-%s-%s", CONTAINER_NAME_PREFIX, container.Name, generateRandomSuffix(4, runner.RandEnv)) log.Printf("Configured container '%s' will be started with name '%s'.\n", container.Name, generatedContainerName) if err := pullImage(runner.Client, auth, container); err != nil { return "", err } if err := deleteOldContainers(runner.Client, container.Name); err != nil { return "", err } ctx, cancel := context.WithCancel(context.Background()) defer cancel() printWarningIfLikelyHasMistake(container.Command, container.Args) var runCommand dockerstrslice.StrSlice if container.Command != nil { runCommand = dockerstrslice.StrSlice(container.Command) } var runArgs dockerstrslice.StrSlice if container.Args != nil { runArgs = dockerstrslice.StrSlice(container.Args) } if err := runner.VolumesEnv.UnmountExistingVolumes(); err != nil { log.Printf("Error: failed to unmount volumes:\n%v", err) } containerVolumeBindingConfigurationMap, volumePrepareError := runner.VolumesEnv.PrepareVolumesAndGetBindings(spec) if volumePrepareError != nil { return "", volumePrepareError } volumeBindingConfiguration, volumeBindingFound := containerVolumeBindingConfigurationMap[container.Name] if !volumeBindingFound { return "", fmt.Errorf("Volume binding configuration for container %s not found in the map. This should not happen.", container.Name) } // Docker-API compatible types. hostPathBinds := []string{} for _, hostPathBindConfiguration := range volumeBindingConfiguration { hostPathBind := fmt.Sprintf("%s:%s", hostPathBindConfiguration.HostPath, hostPathBindConfiguration.ContainerPath) if hostPathBindConfiguration.ReadOnly { hostPathBind = fmt.Sprintf("%s:ro", hostPathBind) } hostPathBinds = append(hostPathBinds, hostPathBind) } env := []string{} for _, envVar := range container.Env { env = append(env, fmt.Sprintf("%s=%s", envVar.Name, envVar.Value)) } restartPolicyName := "always" if spec.RestartPolicy == nil || *spec.RestartPolicy == api.RestartPolicyAlways { restartPolicyName = "always" } else if *spec.RestartPolicy == api.RestartPolicyOnFailure { restartPolicyName = "on-failure" } else if *spec.RestartPolicy == api.RestartPolicyNever { restartPolicyName = "no" } else { return "", fmt.Errorf( "Invalid container declaration: Unsupported container restart policy '%s'", *spec.RestartPolicy) } opts := dockertypes.ContainerCreateConfig{ Name: generatedContainerName, Config: &dockercontainer.Config{ Entrypoint: runCommand, Cmd: runArgs, Image: container.Image, Env: env, OpenStdin: container.StdIn, Tty: container.Tty, }, HostConfig: &dockercontainer.HostConfig{ Binds: hostPathBinds, AutoRemove: false, NetworkMode: "host", Privileged: container.SecurityContext.Privileged, LogConfig: dockercontainer.LogConfig{ Type: "json-file", Config: map[string]string{ "max-size": "500m", "max-file": "3", }, }, RestartPolicy: dockercontainer.RestartPolicy{ Name: restartPolicyName, }, }, } createResp, err := runner.Client.ContainerCreate( ctx, opts.Config, opts.HostConfig, opts.NetworkingConfig, opts.Name) if ctxErr := contextError(ctx, "Create container"); ctxErr != nil { return "", ctxErr } if err != nil { return "", err } log.Printf("Created a container with name '%s' and ID: %s", generatedContainerName, createResp.ID) return createResp.ID, nil } func startContainer(dockerClient DockerApiClient, id string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() log.Printf("Starting a container with ID: %s", id) return dockerClient.ContainerStart(ctx, id) } func base64EncodeAuth(auth dockertypes.AuthConfig) (string, error) { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(auth); err != nil { return "", err } return base64.URLEncoding.EncodeToString(buf.Bytes()), nil } func contextError(ctx context.Context, operationType string) error { if ctx.Err() == context.DeadlineExceeded { return operationTimeout{err: ctx.Err(), operationType: operationType} } return ctx.Err() } func printWarningIfLikelyHasMistake(command []string, args []string) { var commandAndArgs []string if command != nil { commandAndArgs = append(commandAndArgs, command...) } if args != nil { commandAndArgs = append(commandAndArgs, args...) } if len(commandAndArgs) == 1 && containsWhitespace(commandAndArgs[0]) { fields := strings.Fields(commandAndArgs[0]) if len(fields) > 1 { log.Printf("Warning: executable \"%s\" contains whitespace, which is "+ "likely not what you intended. If your intention was to provide "+ "arguments to \"%s\" and you are using gcloud, use the "+ "\"--container-arg\" option. If you are using Google Cloud Console, "+ "specify the arguments separately under \"Command and arguments\" in "+ "\"Advanced container options\".", commandAndArgs[0], fields[0]) } else { log.Printf("Warning: executable \"%s\" contains whitespace, which is "+ "likely not what you intended. Maybe you accidentally left "+ "leading/trailing whitespace?", commandAndArgs[0]) } } } func containsWhitespace(s string) bool { for _, r := range s { if unicode.IsSpace(r) { return true } } return false }