Docker Engine APIでサンドボックス環境を試してみる

概要

CI自体の開発や、教育支援システム開発などでは、何度も同じ環境でコードを実行し、再現性のある結果を出力する必要があります。
CIの例はイメージしやすいと思いますが、教育支援システムにプログラムの自動採点機能を設けるとすると、その採点ロジックは実際にコードを実行し、期待する結果が出力できているかを照合する処理を実装します。
このような機能は、サンドボックス用の Docker や Kubernetes に対して、サーバーサイドアプリケーションからコンテナ作成依頼をする形で実現されると思います。
自分自身、そういったユースケースを考えることが多かったため、興味の範疇で上記を実現するためのスニペットを書いてみました。

試すこと

Docker SDK を使用して、以下を試してみたいと思います。

  1. コンテナを作成
  2. コンテナでのコマンド実行
  3. 非同期での標準出力の拾い上げ

準備物

  1. コンテナ内で実行するサンプルコード
  2. コンテナを作成、実行、標準出力を取得するためのクライアント実装

まずは 1 から。
次のコードを使用して、30秒間、毎秒ログを吐きます。

package main

import (
    "context"
    "log"
    "os"
    "time"
)

func main() {
    l := log.New(os.Stdout, "echo: ", 0)
    ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
    defer cancel()

    l.Println("start")

    for {
        select {
        case <-ctx.Done():
            l.Println("finish")
            return
        default:
            l.Println("hello")
            time.Sleep(time.Second)
        }
    }
}

このコードをビルドし、Docker Image (echo:latest) を作ります。

次に、2 のクライアントコードを示します。
Docker SDK を使用して、「コンテナ作成 -> コンテナの開始 -> Exec -> コンテナの標準出力をコピーして標準出力に表示」を行います。

package main

import (
    "bytes"
    "context"
    "fmt"
    "os"
    "time"

    "github.com/docker/docker/api/types"
    "github.com/docker/docker/api/types/container"
    "github.com/docker/docker/client"
    "github.com/docker/docker/pkg/stdcopy"
    "github.com/google/uuid"
)

type runConfig struct {
    Image string
    Tag   string
    Cmd   []string
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
    defer cancel()

    c := runConfig{
        Image: "echo",
        Tag:   "latest",
        Cmd:   []string{"/echo"},
    }
    if err := run(ctx, c); err != nil {
        fmt.Println(err)
        os.Exit(1)
        return
    }
}

func run(ctx context.Context, runCfg runConfig) error {
    cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
    if err != nil {
        return fmt.Errorf("new client error: %w", err)
    }

    u, err := uuid.NewUUID()
    if err != nil {
        return fmt.Errorf("new uuid error: %w", err)
    }

    name := runCfg.Image + "_" + u.String()
    containerCfg := &container.Config{
        Image: fmt.Sprintf("%s:%s", runCfg.Image, runCfg.Tag),
        Tty:   true,
    }
    resp, err := cli.ContainerCreate(ctx, containerCfg, nil, nil, nil, name)
    if err != nil {
        return fmt.Errorf("create container error: %w", err)
    }
    defer func() {
        cli.ContainerRemove(ctx, name, types.ContainerRemoveOptions{Force: true})
    }()

    containerID := resp.ID

    if err := cli.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil {
        return fmt.Errorf("start container error: %w", err)
    }

    // wait for container starting
    for {
        resp, err := cli.ContainerInspect(ctx, containerID)
        if err != nil {
            return fmt.Errorf("inspect container error: %w", err)
        }
        if !resp.State.Running {
            if resp.State.Dead {
                return fmt.Errorf("container dead")
            }
            if e := resp.State.Error; e != "" {
                return fmt.Errorf("container error: %s", e)
            }
            if c := resp.State.ExitCode; c != 0 {
                return fmt.Errorf("container exited code: %d", c)
            }
            time.Sleep(500 * time.Millisecond)
            continue
        }
        break
    }

    execCfg := types.ExecConfig{
        AttachStderr: true,
        AttachStdout: true,
        Cmd:          runCfg.Cmd,
    }
    exec, err := cli.ContainerExecCreate(ctx, containerID, execCfg)
    if err != nil {
        return fmt.Errorf("create exec error: %w", err)
    }

    out, err := cli.ContainerExecAttach(ctx, exec.ID, types.ExecStartCheck{})
    if err != nil {
        return fmt.Errorf("attach exec error: %w", err)
    }

    stdo := new(bytes.Buffer)
    stde := new(bytes.Buffer)

    end := make(chan struct{}, 1)
    defer close(end)

    go func() {
        _, err := stdcopy.StdCopy(stdo, stde, out.Reader)
        if err != nil {
            panic(fmt.Errorf("copy error: %w", err))
        }
        end <- struct{}{}
    }()

    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            fmt.Println(stdo)
        case <-end:
            return nil
        }
    }
}

tickerの周期を短くすることで、よりリアルタイムに標準出力を拾えるようになります。

まとめ

教育支援システムのユースケースを考えるならば、プログラムコードをコンテナに埋め込んだり、プログラムコードから Docker Image を作成したりする処理も必要になります。
その場合は、cli.ImageBuild(...)cli.ImageCreate(...)を使用することでイメージを用意することもできるので、上記と組み合わせて実現できそうです。