DjangoとDockerで、オンライン実行環境を作る

2019-07-29 / PythonDjangoDocker

概要

Dockerを使うことで素早く環境をセットアップしたり、同じような環境をみんなで利用したり、といったことが可能です。そういった面でも便利なのですが、今回はプログラムの実行環境としてDockerを利用します。

今回作るようなオンライン実行環境では、プログラム上からサーバーマシンの情報を不正に取得しようとしたり、悪意のあるコードが実行される可能性がありますが、Dockerを使う事でそれらを閉じられた仮想環境内で行うことができます。

次のように、コードを入力するエディタがあって、print()などの出力も表示されています。

エラーの場合もちゃんと出力されてますね。

Githubにソースコードを置いているので、詳しいソースや試したい方はそちらを参考にしてください。

Dockerの(本当に簡易的な)チュートリアル

今回CentOS7で使う例です。他の環境の方は、コマンドを適宜置き換えてください。まずDockerを以下のようにインストールし、スタートします。

yum -y install docker
systemctl start docker
systemctl enable docker

Docker Hubには、様々なDockerイメージがあります。Pythonと検索するといくつか出てきますが、officialのものが良いです。

今回は、Python OFFICIAL REPOSITORYを使うことにします。次のコマンドで、Python環境が入っているDockerイメージを取得しましょう。

docker pull python:3.7

3.7の部分は、他にも色々あるので、好きなものを入れて大丈夫です。

ページ内にRun a single Python scriptという項目がありますね。そこのコードを試しておきましょう。まずカレントディレクトリにて、適当にpythonファイルを作ります。main.pyとしましょう。

print('Hello World')

そして、コードを試します。

 docker run -it --rm --name my-running-script -v "$PWD":/usr/src/myapp -w /usr/src/myapp python:3.7 python main.py

Hello Worldと表示されれば、問題なく動いています。これをDjango上から呼び出すように作っていきましょう。

Django上から呼び出す

重要な部分だけ書いていきます。プロジェクト全体のソースコードは、Githubのソースコードを確認してください。

historyディレクトリの作成

プロジェクト直下、つまりmanage.pyと同じ階層にhistoryというディレクトリを作っておきます。この中に、各ユーザーが入力したPythonコードのファイルが格納されていきます。

フォームの作成

forms.pyに次のようなフォームを作っておきます。オンライン実行環境での、エディタ部分、プログラムを書く画面のフォームです。

from django import forms


class EditorForm(forms.Form):
    """エディタ部分となるフォーム."""
    code = forms.CharField(
        widget=forms.Textarea,
    )

ビューの作成

views.pyにて、次のようなビューを定義しておきます。

import os
import datetime
import subprocess

from django.conf import settings
from django.urls import reverse_lazy
from django.views import generic

from .forms import EditorForm

file_dir = os.path.join(settings.BASE_DIR, 'history')
docker_cmd = 'docker run -i --rm --name my-running-script -v {}:/usr/src/myapp -w /usr/src/myapp python:3.7 python {}'


def start_docker(code):
    """dockerコンテナ内でPythonコードを実行する."""
    # historyディレクトリ内に、2019-07-29T22:58:24.1111.py(現在時間) のようなファイルを作り、中身は入力したコード
    file_name = '{}.py'.format(datetime.datetime.now().isoformat())
    file_path = os.path.join(file_dir, file_name)
    with open(file_path, 'w', encoding='utf-8') as file:
        file.write(code)

    # historyディレクトリを、コンテナにマウントするよう設定し、python 2019-07-29T22:58:24.1111.py のように実行
    cmd = docker_cmd.format(file_dir, file_name)
    ret = subprocess.run(
        cmd, timeout=15, shell=True,
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT
    )

    return ret.stdout.decode()


class Home(generic.FormView):
    """/へのアクセスで呼ばれるトップページのビュー."""
    template_name = 'app/home.html'
    form_class = EditorForm
    success_url = reverse_lazy('app:home')

    def form_valid(self, form):
        """送信ボタンでよびだされる."""
        code = form.cleaned_data['code']
        output = start_docker(code)
        context = self.get_context_data(form=form, output=output)
        return self.render_to_response(context)

今回、ビューは1つだけです。

class Home(generic.FormView):
    """/へのアクセスで呼ばれるトップページのビュー."""
    template_name = 'app/home.html'
    form_class = EditorForm
    success_url = reverse_lazy('app:home')

    def form_valid(self, form):
        """送信ボタンでよびだされる."""
        code = form.cleaned_data['code']
        output = start_docker(code)
        context = self.get_context_data(form=form, output=output)
        return self.render_to_response(context)

送信ボタンが押されると、入力されたプログラムコード部分を受け取り、それをstart_docker()に渡し、それの実行結果を受け取ります。その実行結果とフォームオブジェクトをテンプレートファイルへ渡す、ということをします。

    def form_valid(self, form):
        """送信ボタンでよびだされる."""
        code = form.cleaned_data['code']
        output = start_docker(code)
        context = self.get_context_data(form=form, output=output)
        return self.render_to_response(context)

start_docker()は、ドックストリングの通りDockerコンテナ内で入力されたPythonコードを実行し、結果を返します。

def start_docker(code):
    """dockerコンテナ内でPythonコードを実行する."""
    # historyディレクトリ内に、2019-07-29T22:58:24.1111.py(現在時間) のようなファイルを作り、中身は入力したコード
    file_name = '{}.py'.format(datetime.datetime.now().isoformat())
    file_path = os.path.join(file_dir, file_name)
    with open(file_path, 'w', encoding='utf-8') as file:
        file.write(code)

    # historyディレクトリを、コンテナにマウントするよう設定し、python 2019-07-29T22:58:24.1111.py のように実行
    cmd = docker_cmd.format(file_dir, file_name)
    ret = subprocess.run(
        cmd, timeout=15, shell=True,
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT
    )

    return ret.stdout.decode()

docker runコマンドについて軽く説明すると、---rmは実行後にコンテナを削除するオプションです。-vは、ホストのディレクトリをコンテナ内のディレクトリにマウントします。"$PWD"はカレントディレクトリです。-wは、ワーキングディレクトリの指定になります。

最初のチュートリアルで試したコマンドは、カレントディレクトリを/usr/src/myappにマウントしました。カレントディレクトリにmain.pyを作成していたので、/usr/src/myappにはmain.pyがある状態ですね。そのまま/usr/src/myappをワーキングディレクトリにしたので、python main.py は無事に実行できた、ということです。

今回の処理は、ユーザーが書いたコードはhistoryディレクトリ内に2019-07-29T22:58:24.1111.pyのような形で保存され、historyディレクトリを/usr/src/myappにマウントしました。/usr/src/myappには2019-07-29T22:58:24.1111.pyがある状態です。ワーキングディレクトリも同じにし、python 2019-07-29T22:58:24.1111.pyのように実行している、ということです。最初のチュートリアルと殆ど変わらず、極力シンプルな処理にしました。ちなみにですが、今回の処理だとユーザーが他ユーザーの書いたファイルを覗けたりします。

テンプレートファイルの作成

今回は1ページなので旨味がないのですが、一応共通のテンプレートファイルを作っておきます。base.htmlです。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <title>オンラインPython実行環境</title>

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/css/bootstrap.min.css" integrity="sha384-rwoIResjU2yc3z8GV/NPeZWAv56rSmLldC3R/AZzGRnGxQQKnKkoFVhFQhNUwEyJ" crossorigin="anonymous">
    <style>
        html, body, form {
            height: 100%;
        }
        #editor {
            height: 70%;
        }
        #output {
            height: 30%;
        }

        #output button {
            height: 10%;
        }
        #output pre {
            height: 90%;
            overflow: scroll;
        }
    </style>
  </head>
  <body>
    {% block content %}{% endblock %}

    <!-- jQuery first, then Tether, then Bootstrap JS. -->
    <script src="https://code.jquery.com/jquery-3.1.1.min.js" integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/tether/1.4.0/js/tether.min.js" integrity="sha384-DztdAPBWPRXSA/3eYEEUWrWCy7G5KFbe8fFjk5JAIxUYHKkDx6Qin1DkWx51bBrb" crossorigin="anonymous"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.6/js/bootstrap.min.js" integrity="sha384-vBWWzlZJ8ea9aCX4pEW3rVHjgjt7zpkNpZk+02D9phzyeVkE+jo0ieGizqPLForn" crossorigin="anonymous"></script>

    <!-- Ace Editor settings -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.0/ace.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.0/ext-language_tools.js"></script>
    <script src="https://cloud9ide.github.io/emmet-core/emmet.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.0/ext-emmet.js"></script>
    <script>
        var langTools = ace.require("ace/ext/language_tools");
        var editor = ace.edit("code");
        var textarea = $('textarea[name="code"]').hide();
        editor.getSession().setValue(textarea.val());
        editor.getSession().on('change', function(){
          textarea.val(editor.getSession().getValue());
        });
        editor.$blockScrolling = Infinity;
        editor.setOptions({
            enableBasicAutocompletion: true,
            enableSnippets: true,
            enableLiveAutocompletion: true,
            enableEmmet: true,
        });
        editor.setTheme("ace/theme/monokai");
        editor.getSession().setMode("ace/mode/python");
        editor.setFontSize(20);
    </script>
  </body>
</html>

<style>内では、画面の高さのレイアウトを設定しています。エディター部分が7割出力表示部分が3割、うちボタン部分が1割、表示部分9割 ということです。

<!-- Ace Editor settings -->部分は、Ace Editorというプログラムを入力するためのエディタライブラリを読み込み・設定しています。

そして、プログラムを書いたりするメインのコンテンツ部分のテンプレートファイル、home.htmlを作ります。

{% extends "app/base.html" %}

{% block content %}
<form action='' method="POST">
    <!-- コード入力エリア -->
    <div id="editor">
        {{ form.code }}
        <div id="code" class="h-100"></div>
    </div>

    <!-- 出力表示エリア -->
    <div id="output" class="bg-inverse">
        <button type="submit" class="btn btn-warning">実行</button>
        <pre class="text-white">{{ output }}</pre>
         {% csrf_token %}
    </div>   

</form>

{% endblock %}

この記事の関連記事

関連記事はありません。

コメント欄

記事にコメントする

ありがとうございました

先日この記事にて質問させて頂いた者です 分かり辛い質問になってしまい申し訳ございませんでした。 まさか記事ごと更新していただけるとは思ってもおらず、反応も遅れてしまい。。。 本当にありがとうございます

上げてくださったGithubにてファイル構成を確認させていただきました。 見まねで作っていたものとあなた様のコードを比べ動かない理由がよくわかりました。

ためしに実行してみようと、Githubにて公開していただいたdjango-docker-sample-masterを用い python manage.py runserverを実行したところ

File "manage.py", line 16 ) from exc ^ SyntaxError: invalid syntax

と出てしまいました。どうやら構文エラーのようなのですが私が使用しているソフトウェアのバージョンがおかしいということなのでしょうか?

Centos7に初期で内蔵されているpython2.7.5で実行したり、python3.6.8で実行してみたりしましたがなぜか同じ場所で引っかかってしまいます

重ね重ね申し訳ございません。もしお時間あれば教えていただけると幸いです

pythonバージョンは2.7.5ですが、python3.6コマンドで3.6.8も利用できるようにしています。 djangoのバージョンは1.11.22、 dockerのバージョンは1.13.1 です。

コメントに返信する

なりと

お返事が遅くなってしまいすみません。

そのエラーの99%は、python2系で実行しているのが原因です。python3.6など、3系で実行するように、注意深くコマンドの実行、設定をしてください。

名無し

記事を読ませていただき、大変勉強になりました。

おひとつ、質問があるのですが、(DockerもPythonも初心者な者です。。。)

ユーザーが入力したコードで、標準入力を受け取らせて、出力をさせたいのですが、この場合、Viewsの部分で、標準入力を与えるのでしょうか?

ご回答いただけると幸いです。

勉強になる記事、ありがとうございました。

コメントに返信する

なりと

次のように、stdin引数にファイルを渡すことで、それを標準入力にできます。manage.pyと同じ階層にtest.txtがあります。

    ret = subprocess.run(
        cmd, timeout=15, shell=True,
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=open('test.txt', 'r', encoding='utf-8'),
    )