ブラウザで完結する、社内勉強会用のコンテナ実行環境を構築した話

# はじめに
先日社内の勉強会で、コンテナに関する勉強会をやることになりました。コンテナについて学ぶにはとりあえずハンズオンだろうという考えで資料を作成していましたが、

「勉強会中に実行環境用意する時間なくね...?」

という当たり前のことに気づき、勉強会参加者のための実行環境を用意する必要が出てきました。そこで今回、ブラウザだけで利用できるコンテナの実行環境を用意したので、その方法についてシェアできればと思います。

今回作成したファイルは以下にあります。
https://github.com/sogawa-yk/se-learning-hub-handson <- アプリ側
https://github.com/sogawa-yk/manifests <- k8sに適用するマニフェストたち

# 用意する環境
今回用意する環境に求められていたのは、大きく言えば次の三つです。
- ブラウザだけで簡単に利用できる
- ユーザーごとに異なる実行環境を提供する
- ユーザー数は事前にわからない（規模感はわかる）
- dockerが実行できる

この要件でぱっと思いついたのは、[kodekloud](https://kodekloud.com/)のようなサービスです。kubekloudでは、登録したユーザーごとにラボ環境を用意して、簡単にKubernetesのハンズオンを行えるようになっています。
そこで今回は、kodekloudに倣った（倣ったというには稚拙すぎる）、ユーザーごとのシェル環境（dockerコマンドが実行可能）を用意しました。
## 完成品のイメージ
ユーザーは以下のようなシェル環境起動用のページにアクセスします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532202/35301936-5454-297e-fc58-55312d00875b.png)
`Enter your pod name`の欄に、適当なユーザー名を入力して、`Start Pod`ボタンをクリックします。クリックすると以下のように起動状態が分かるようになっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532202/9de8457d-0f3a-004f-40e7-fe904f2c274c.png)
起動が完了すると、シェル環境のページにリダイレクトされ、以下のようなページが表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532202/06fda80d-d422-426b-7690-83507a589176.png)
これだけでハンズオン環境が用意できます！（ユーザーから見れば）

# どうやって作ったか
今回この環境を用意するにあたって、利用したのは以下のような技術です。
- Kubernetes（計算基盤として）
- ttyd（ブラウザでシェル環境を表示するため）
- dind（コンテナ上でdocker環境を用意するため）

これらの技術を使ってどうやって作ったか、まずは概要から説明します。
## 概要
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/532202/5114a735-b7b9-3766-80ec-ad79f5245684.png)

イメージは上図のようになります。

まずはユーザーはttyd+dindのPod起動用のページにアクセスし、そこで起動ボタンを押すと、バックエンドのロジックでKubernetes上にttyd+dindのPodと、そのPodにアクセスするためのService, Ingressが作成されます。作成完了後、作成したPodのページにリダイレクトします。ちなみに、Pod起動用のページに行くためのLBとIngressのLBを分けているのは、諸事情（実装時の知識不足）です。分けずに実装することもできますが、修正するのが面倒くさくなってしまいそのままにしています。Ingressもまとめてルールにできますが、そこも知識不足＆そこまで考えるのが面倒になりました。すみません。

なにやら大分無駄の多い実装ですが、とりあえずこれで進めます。

## Pod起動用のページを作る
Pod起動用のページで実装したロジックの大部分は、みんな大好きchatGPTさんに作ってもらいました。ありがとう、chatGPT。

「chatGPTに作ってもらったので説明は省きまーす」と言いたいところですが、それでは記事の意味がないので少し説明したいと思います。

Pod起動用のページを作るためのファイルは、以下のようなファイルになっています。

```
.
├── Dockerfile
├── app
│   ├── k8s_client.py
│   ├── routes.py
│   ├── run.py
│   └── static
│       └── index.html
├── manifests
│   ├── ingress-template.yaml
│   ├── web-shell-service-template.yaml
│   └── web-shell-template.yaml
└── requirements.txt

3 directories, 9 files
```
`app`の中にはwebサーバのflask用のコード（`run.py`, `routes.py`）と、ユーザーごとのマニフェストファイルを作成、適用するためのコード（`k8s_client.py`）、実際に表示するページのコード（`static/index.html`）が含まれています。`manifests`の中には、作成するマニフェストファイルのテンプレートが入っています。実際マニフェストを作成・適用する際は、このテンプレートをちょっとだけ編集してクラスターに適用します。

### テンプレートのマニフェストファイル
「ちょっと編集するってどれくらい編集しますのん？」と感じると思いますので、少しお見せしたいと思います。
例えば、シェル環境（ttyd+dind）のPodのマニフェストファイルのテンプレートは以下のような内容です。
```yaml
apiVersion: v1
kind: Pod
metadata:
  name: {pod_name}
  labels:
    app: web-shell
    user-id: {pod_name}
spec:
  containers:
    - name: ttyd
      image: kix.ocir.io/orasejapan/se-learning-hub/web-shell/ttyd:latest
      ports:
        - containerPort: 7681
      env:
        - name: DOCKER_HOST
          value: "tcp://localhost:2375"

    - name: docker-daemon
      image: kix.ocir.io/orasejapan/se-learning-hub/web-shell/dind:latest
      securityContext:
        privileged: true
      ports:
        - containerPort: 2375
```
編集するのはなんと、`pod_name`だけです。serviceやingressも同様です。

### マニフェストを編集・適用するロジック
では、`pod_name`はどのように編集しているかと言いますと、以下のようにしています。
```python:k8s_client.pyの一部
def create_pod(pod_name):
    # マニフェストファイルからPodを作成
    template_pod_manifest_path = '/config/web-shell-template.yaml'
    template_svc_manifest_path = '/config/web-shell-service-template.yaml'
    template_ingress_manifest_path = '/config/ingress-template.yaml'
    output_path = '/config/'
    try:
        # Podマニフェスト生成
        with open(template_pod_manifest_path, 'r') as file:
            template = file.read()
        pod_manifest_yaml = template.format(pod_name=pod_name)
        pod_manifest_path = output_path + pod_name + '.yaml'
        with open(pod_manifest_path, 'w') as file:
            file.write(pod_manifest_yaml)

        # Serviceマニフェスト生成
        with open(template_svc_manifest_path, 'r') as file:
            template = file.read()
        svc_manifest_yaml = template.format(pod_name=pod_name)
        svc_manifest_path = output_path + pod_name + '-service.yaml'
        with open(svc_manifest_path, 'w') as file:
            file.write(svc_manifest_yaml)

        # Ingressマニフェストの生成
        with open(template_ingress_manifest_path, 'r') as file:
            template = file.read()
        ingress_manifest_yaml = template.format(pod_name=pod_name)
        ingress_manifest_path = output_path + pod_name + '-ingress.yaml'
        with open(ingress_manifest_path, 'w') as file:
            file.write(ingress_manifest_yaml)        

        # マニフェスト適用
        utils.create_from_yaml(client.ApiClient(), yaml_file=pod_manifest_path, namespace='se-learning-hub')
        utils.create_from_yaml(client.ApiClient(), yaml_file=svc_manifest_path, namespace='se-learning-hub')
        utils.create_from_yaml(client.ApiClient(), yaml_file=ingress_manifest_path, namespace='se-learning-hub')
        return "Pod creation initiated successfully."
    except Exception as e:
        print(f"Failed to create pod: {str(e)}", file=sys.stderr)
        return f"Failed to create pod: {str(e)}"
```
「長くて読む気が起きないよ」という方のために、簡単に説明しますと、
1. マニフェストのテンプレートファイルを読み込み
1. pod_nameに受け取った値を入れる（これはユーザーが入力したユーザー名）
1. pod_nameを入れた結果をyamlファイルとして書き込む
1. そのyamlファイルをk8sクラスタに適用

これをpod, service, ingressに対して同様に実行しています。ちなみに、このバックエンドロジックはttyd+dindのPodと同じクラスター上にあるので、
```python
config.load_incluster_config()
```
だけでマニフェスト適用が行えます。ただ、事前にPod作成などのロール適用は必要ですが。

ここまでのロジックによって、ttyd+dindのPod, そのPod用のservice, ingressのデプロイが可能になりました！（Ingressはルール追記でええやろがい！というツッコミに対しては、前述のとおり技術不足です。やる気が出たときに修正します。）

### Podの状態を把握するロジック
ここに関してはロジックというほどでもないですが、一応Podの起動状態（Pending等）が起動ボタンを押してからリアルタイムで反映されるようにしているのでその実装について少し触れたいと思います。

`static/index.html`の中に、ステータス取得と、リダイレクトのためのjavascriptが仕込まれています。
```javascript:static/index.html
    const INGRESS_IP = "{{ ingress_ip }}";
    
    function checkStatus(podName) {
      fetch(`/api/status/${podName}`)
        .then(response => response.json())
        .then(data => {
          if (data.status === 'Running') {
            setTimeout(() => {
              window.location.href = `http://${INGRESS_IP}/env/${podName}`; // Redirect after 5 seconds
            }, 5000);
          } else {
            document.getElementById('status').innerHTML = `Status: ${data.status}`;
            setTimeout(() => checkStatus(podName), 1000); // Check status every 1 second
          }
        })
        .catch(error => {
          document.getElementById('status').innerHTML = 'Error fetching status: ' + error.message;
          console.error('Error:', error);
        });
    }
```
```python:routes.py
from flask import Blueprint, jsonify, send_from_directory, request, render_template
from k8s_client import create_pod, get_pod_status
from flask import current_app as app
import os

main = Blueprint('main', __name__)

@main.route('/')
def index():
    ingress_ip = os.getenv('INGRESS_IP')
    return render_template('index.html', ingress_ip=ingress_ip)

@main.route('/api/start-pod', methods=['POST'])
def start_pod():
    pod_name = request.form['user-name']
    create_pod(pod_name)
    return jsonify({'podName': pod_name}), 200

@main.route('/api/status/<pod_name>', methods=['GET'])
def status(pod_name):
    status = get_pod_status(pod_name)
    return jsonify({'status': status}), 200
```
flask側に`/api/status/<pod_name>`というパスで各Podの状態を取得できるエンドポイントを用意しているので、そこからPodの状態を取得します。Podの状態が`Running`の場合、５秒待ってからリダイレクトするようにしています（すぐリダイレクトするとうまく接続できなかったり、dockerdが動いていなかったりするため）。また、それ以外の状態の場合はその状態を出力（Idが`status`の要素に格納）します。INGRESS用LBのIPアドレスは、環境変数から指定します。環境変数は、kubernetesの項で設定方法を説明します。

## ttydとdindのコンテナイメージを作成する
ここは実際のシェル環境の部分なので非常に大事な部分なのですが、そんなに複雑なことは何もなく、ただそれぞれのDockerfileを作るだけでOKです。実際にKubernetesに乗っける際には、ttydのコンテナとdindのコンテナをまとめてPodとして乗せます。
```Dockerfile:dind
# Docker in Docker の公式イメージを使用
FROM docker:dind

# /etc/docker ディレクトリの作成
RUN mkdir -p /etc/docker

# Dockerデーモンの設定
# TLSを無効にしてHTTPで通信する設定ファイルを作成
RUN echo '{ "hosts": ["tcp://0.0.0.0:2375", "unix:///var/run/docker.sock"] }' > /etc/docker/daemon.json

# ポート2375を開放（Docker API用）
EXPOSE 2375

# Dockerデーモンをカスタム設定ファイルで直接起動
CMD ["dockerd", "--config-file=/etc/docker/daemon.json"]
```
```Dockerfile:ttyd
FROM ubuntu:latest

# 必要なパッケージのインストール
RUN apt-get update && apt-get install -y \
  ttyd \
  bash \
  curl \
  software-properties-common \
  apt-transport-https \
  gnupg \
  git \
  && rm -rf /var/lib/apt/lists/*

# Docker CLIをインストール
RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add - \
  && add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \
  && apt-get update \
  && apt-get install -y docker-ce-cli

# 環境変数の設定、Docker Daemonのアドレスを指定
ENV DOCKER_HOST=tcp://docker-daemon:2375

# ハンズオン資材をDL
RUN git clone https://github.com/sogawa-yk/se-learning-hub-handson.git

# ttydを使ってbashを公開
CMD ["ttyd", "bash"]
```
「dindのイメージでttydも動かせばええやんけ！なんで二つもコンテナ立てんねん！」というツッコミはごもっともなのですが、dindのイメージがなかなか扱いが難しく、ttydがうまく動かせなかったのでこのような構成になっています（逆もしかり）。

## Kubernetesにリソースをデプロイ
ここまでで用意したコンテナイメージをもとに、Kubernetesに各リソースをデプロイしていきます。今回はイングレスのLBとウェブサーバ用のLBは別のものを使っているので、デプロイするべきなのは
1. webサーバ用のDeployment
1. webサーバ用のService (LoadBalancer)
1. flaskサーバからkubernetesクラスタにpod等をアップロードするためのrole
1. roleをサービスアカウントに適用するためのRoleBinding
になります。他のリソース（ttyd+dindのPod、そのPod用のService、Ingress）は動的にwebサーバ側で`apply`していくので、事前に`apply`する必要はありません。

`apply`は、以下のコマンドで実行します。
```bash
kubectl apply -f web.yaml
kubectl apply -f web-service.yaml
kubectl apply -f role.yaml
kubectl apply -f role-binding.yaml
```

### webサーバ用のDeployment
webサーバ用のDeploymentのマニフェストファイルは以下のようなものです。環境変数の値は各自で埋めていただく必要があります。
```yaml:web.yaml.template
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: kix.ocir.io/orasejapan/se-learning-hub/web:1.6
          ports:
            - containerPort: 80
          env:
            - name: INGRESS_IP
              value: "${INGRESS_IP}"
```
環境変数を埋める際は、例えば以下のように埋めることができます。
```bash
export INGRESS_IP=<ipアドレス>
envsubst < web.yaml.template > web.yaml
```
### webサーバ用のService
webサーバ用のService (LoadBalancer)配下のようなマニフェストファイルでデプロイできます。
```yaml:web-service.yaml
---
apiVersion: v1
kind: Service
metadata:
  name: web-service
  labels:
    app: web
  annotations:
    oci.oraclecloud.com/load-balancer-type: "lb"
    service.beta.kubernetes.io/oci-load-balancer-shape: "flexible"
    service.beta.kubernetes.io/oci-load-balancer-shape-flex-min: "10"
    service.beta.kubernetes.io/oci-load-balancer-shape-flex-max: "30"
spec:
  type: LoadBalancer
  ports:
    - port: 8080
      targetPort: 80
      nodePort: 30080
  selector:
    app: web
```
このServiceとwebサーバのPodの結び付けは、`app: web`のラベルで行っています。また、今回OCI上のマネージドKubernetesサービス（OKE)を使っているので、OCI上のロードバランサ―のための設定項目を`annotations`で設定しています。

### flaskサーバからkubernetesクラスタにpod等をアップロードするためのrole
kubernetesでは、事前にroleを設定しておくことで、そのネームスペースにおいてクラスターのリソースを操作する権限を、ユーザーやサービスアカウント等に与えることができます。今回はflaskが動いているpodにKubernetesのリソースを操作する権限を与えたいので、サービスアカウントのロールを作成します。

```yaml:role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: se-learning-hub
  name: pod-manager
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch", "create", "delete"] # Pod に対する権限
  - apiGroups: [""]
    resources: ["services"]
    verbs: ["get", "list", "watch", "create", "delete"] # Service に対する権限
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses"]
    verbs: ["create", "get", "list", "watch", "delete", "update"]
```

### roleをサービスアカウントに適用するためのRoleBinding
roleを作成しただけでは、roleの定義が作成されただけで適用はされません。そのため、`RoleBinding`を作成して、サービスアカウントに作成したroleを適用する必要があります。
``` yaml:role-binding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: manage-pods
  namespace: se-learning-hub
subjects:
  - kind: ServiceAccount
    name: default
    namespace: se-learning-hub
roleRef:
  kind: Role
  name: pod-manager
  apiGroup: rbac.authorization.k8s.io
```

# さいごに
上記の様な工程を踏んで、無事にブラウザで完結する、社内勉強用のコンテナ実行環境が構築できました。まだまだ粗い部分があり、改善の余地はありますが、ハンズオンの時間くらいは耐えるくらいの完成度になっています。もし同様の環境が必要になっているような方がいらっしゃれば参考にしていただければと思います。