GolangでDockerでドカドカするコンテストサーバを組んだ

  • 21
    Like
  • 0
    Comment

初めに

この記事はGo Advent Calendar13日目の記事です。

コンテストサーバとは

競技プログラミング1のコンテストを開催するためのサーバです。
一般に、問題の公開、ユーザの提出コードを判定、その結果をもとにランキングの作成などの機能が求められます。
UIはWebだったり専用アプリケーションだったりしますが今回は使い勝手の良いWebを採用しました。

製作にあたって直面した課題

  1. Web UI製作経験なし。
  2. C++、CoffeeScript(Node.JS)、Go以外の言語をほぼ触ったことがない。
  3. DataBaseに関しては知識のみ、実際に運用したことはない。
  4. ユーザの提出コードを判定するために実行している際に使うサンドボックスの製作に必要なLinux関連の知識があまりない。

といったかなり致命的な問題が並んでいて厳しい状況でした。
最初はC++での製作を試みていましたがC++には満足のいくHTTPライブラリが見つからなかったため諦め、Goを利用することにしました。
GoがWebアプリ制作に強いことは当時知らなかったので本当に偶然でしたが後から考えるととても幸運でした。
フロントエンドに関してはGoは山ほど情報が転がっているので敢えてここで語ることは特になさそうです。
DataBaseは悩んでいたところmattn大先生にTwitterで教えていただきgenmaiを利用しました。
ここでは主にコードを実行した部分について書きたいと思います。

サンドボックスとして利用するDocker

Dockerは近年話題の準仮想環境です。
今回Dockerをユーザの提出コードを実行するときのサンドボックスとして利用しました。
ユーザのコードを実行する際、実行時間、CPU使用率、メモリ使用量、ネットワークアクセス、ファイルアクセスなどを制限する必要があります。
また、実行結果として実行時間、メモリ使用量、標準出力/エラーを取得する必要があります。
CPU、メモリ、ネットワークに関してはDockerの機能を利用して制限することができます。しかしメモリは使用量も取得する必要があるためcgroupを別途利用しました。cgroupライブラリは生憎見つからなかったため操作するコードを書きました。

また、GoでDockerを操作するためには色々なライブラリがありますがDocker公式で公開されているライブラリがあるのでそれを使いました...が現在は既にDeprecatedになっているようです。Readmeに書いてあるものも見た感じほぼ同じようなので多少は参考になると思います。
以下はこのライブラリを用いてコンテナを作成するサンプルです。

cfg := container.Config{}
cfg.Tty = false // これをtrueにすると出力に入力も一緒に流れてきます。
cfg.AttachStderr = true
cfg.AttachStdout = true
cfg.AttachStdin = true
cfg.OpenStdin = true
cfg.StdinOnce = true
cfg.Image = img // Docker Imageのname:version
cfg.Hostname = "localhost"
cfg.Cmd = cmd // 実行コマンドをexecなどと同様に文字列の配列で渡す(例: []string{"/bin/echo", "Hello, world!"})

hcfg := container.HostConfig{}
hcfg.CPUQuota = int64(1000 * CPUUsage) // CPUUsageが100の時1コアをフルで利用できるように調整
hcfg.CPUPeriod = 100000
hcfg.NetworkMode = "none" // ネットワークを無効化
hcfg.Binds = binds // ホストと共有するディレクトリを指定(例: []string{"/tmp:/host_tmp"})
hcfg.CgroupParent = "/" + cgroupName // cgroupのグループ名

_, err = cli.ContainerCreate(ctx, &cfg, &hcfg, nil/*ネットワーク設定*/, name/*コンテナ名*/)

しかしこのままだと実行時間の制限をかけることができません。Dockerの機能にあるinspectを見るとコンテナの開始時間及び終了時間を取得することができますが誤差が100ms以上ありこれは競技プログラミングにおける計測では大きすぎるため断念しました。(それぐらいの誤差を許容できるのであれば以下のようなめんどくさいことをする必要はないと思います。)
代案を考えているとまさに求めていた本が発売されました。そこではtimeコマンドを利用して計測していたため予めtimeコマンドをインストールしたイメージを用意し実行コマンドを以下のように変更しました。これで実行権限も同時に変更しファイルアクセスも制限できます。

var timer = []string{"/usr/bin/time", "-q", "-f", "%e %x", "-o", "/tmp/time.txt", "/usr/bin/timeout", strconv.FormatInt((time /*単位ms*/ + 999) / 1000, 10), "/usr/bin/sudo", "-u", "nobody"}

newCmd := make([]string, 0, len(cmd) + len(timer))

for i := range timer {
    newCmd = append(newCmd, timer[i])
}

for i := range cmd {
    newCmd = append(newCmd, cmd[i])
}

そして実行します。ネットであまり記事が見つからなかったため一応実行と標準入出力のコードも載せますが、長いので読み飛ばしてください。

※2017/11/03追記: 公式で提供されている安全なライブラリがあったのでそちらを使うことを強く推奨します: github.com/docker/docker/pkg/stdcopy

stdinErr := make(chan error, 1)
stdoutErr := make(chan error, 1)
stderrErr := make(chan error, 1)

attachment := func(opt types.ContainerAttachOptions, done chan<- error, out *string) {
    ctx := context.Background()
    hijack, err := cli.ContainerAttach(ctx, e.Name, opt)

    if err != nil {
        panic(err)
    }
    done <- nil
    if opt.Stdin {
        hijack.Conn.Write([]byte(input))
        hijack.CloseWrite()
        hijack.Close()
        hijack.Conn.Close() // どれをcloseするかの情報が見つからなかったため一応全部閉じています。
        done <- nil
        return
    }

    defer hijack.Close()
    defer hijack.Conn.Close()

    var buf bytes.Buffer
    for {
        b := make([]byte, 128)
        size, err := hijack.Reader.Read(b)

        // 制限しないとメモリを食い尽くす可能性があります
        if out != nil && len(*out) < 100*1024*1024 {
            buf.Write(b[0:size])

            if buf.Len() >= 8 {
                var size uint32
                bin := buf.Bytes()

                for i, v := range bin[4:8] {
                    shift := uint32((3 - i) * 8)
                    size |= uint32(v) << shift
                }
                if buf.Len() >= int(size+8) {
                    *out += string(bin[8 : size+8])
                    buf.Reset()
                    buf.Write(bin[size+8:])
                }
            }
        }

        if err != nil {
            if err.Error() == "EOF" {
                done <- nil
            } else {
                done <- err
            }

            return
        }
    }
}
var stdout, stderr string

go attachment(types.ContainerAttachOptions{Stream: true, Stdout: true}, stdoutErr, &stdout)
go attachment(types.ContainerAttachOptions{Stream: true, Stderr: true}, stderrErr, &stderr)
go attachment(types.ContainerAttachOptions{Stream: true, Stdin: true}, stdinErr, nil)
<-stdinErr
<-stdoutErr
<-stderrErr
<-stdinErr

err := cli.ContainerStart(context.Background(), name /*コンテナ名*/, types.ContainerStartOptions{})
if err != nil {
    panic(err)
}
<-stdoutErr
<-stderrErr
//attachを共通化することでもう少し効率化することもできます。

プログラムから強制的にコンテナを停止したい場合はcli.ContainerKill(context.Background(), name, "SIGKILL")とすればOKです。
また、先ほど/tmp/time.txtにtimeコマンドの実行時間とExit Statusを書き込みました。これを取り出すにはCopyFromContainerを用います。

rc, _, err := cli.CopyFromContainer(context.Background(), name/*コンテナ名*/, "/tmp/time.txt")

if err != nil {
    panic(err)
}

tarStream := tar.NewReader(rc)
tarStream.Next()
buf := new(bytes.Buffer)
buf.ReadFrom(tarStream)

buf.String() //これでstringとして取り出せます。

あとで気付いたのですがこれだとnobodyでも/tmpに書き込む権限が残ったままとなっているので修正する必要がありそうです。
また、DockerのライブラリはDocker本体が頻繁に更新されていることもあり結構インターフェースにも変更があるので注意が必要です。

終わりに

以上がDockerでドカドカするコンテストサーバを組んで得たDocker操作の知見です。コードを貼ってばかりになってしまいました...。
これを作って以来ほぼ全てのツールはGoで作るようになりました。Goは非常にライブラリが豊富で開発が非常に楽で楽しいです。
今後も色々なGoのライブラリを利用してみようと思います。


  1. 問題が与えられ、それを解くプログラムを早く正確に書くことを競う競技。詳しくはWikipedia競技プログラミングWikiなどを参考にしてください。