Kubernetes内のコンテナとのインタラクティブな通信を、WebSocketを用いて実現した話

# はじめに
DockerコンテナのオーケストレーションツールであるKubernetesには、状況を確認するツールとして、[Kubernetes Dashboard](https://github.com/kubernetes/dashboard)が提供されており、ブラウザ上でクラスタの状況確認や、トラブルシューティングを行うことができます。
![k8s_dashboard.png](https://qiita-image-store.s3.amazonaws.com/0/61029/66c8577b-cb07-802b-e3d7-bc500b6a1fef.png)

このようなKubernetes Dashboardの1つの機能として、シェル機能が提供されています。この機能は、Pod中のコンテナ内でのコマンド実行を、ブラウザ上でインタラクティブに行える機能です。

![k8s_shell.gif](https://qiita-image-store.s3.amazonaws.com/0/61029/d50a5460-ef62-5057-5b84-80d854f122d7.gif)

例えばインフラにKubernetesを利用し、ユーザがその中でコンテナを作成できるようなサービスの場合、ユーザの利便性向上のためにもシェルを介したコンテナへのアクセス機能が欲しくなります。しかしながら、ユーザがKubernetes Dashboardへアクセスすることは避けたい場合もあります。そのためシェル機能を提供するためには、以下のように他クライアントを作成し、Kubernetes Dashboardとは異なるルートでのアクセス手段の提供が必要となります。

<img src="https://qiita-image-store.s3.amazonaws.com/0/61029/89f16966-88f9-52e5-044c-085cca3d72fc.png" width=80%>

このような処理を行うクライアントは、Kubernetes Dashboardの動きを模倣することで実装可能です。しかしながらこの動作に関する情報は少なく、実現に苦労しました。そこで本記事では、上記のような動作の再現方法について、サンプルプログラムも踏まえて解説します。

# ゴール
以下のような送受信プログラムをC#で作成し、予め作成しておいたKubernetesのコンテナ内でコマンドを実行します。操作はインタラクティブに実行可能です。

![dotnet_shell.gif](https://qiita-image-store.s3.amazonaws.com/0/61029/7c1577af-7237-890c-f492-8d42e0e29a9a.gif)


# 利用環境
今回利用した環境は、以下のようなものです。

- Windows 10 Home Edition
- VirtualBox 5.2.8
- Minikube 0.31.0

Minikubeは開発・テスト向けのKubernetes環境です。今回はMinikube上に、[httpd](https://hub.docker.com/_/httpd/)のコンテナを含むPodを作成し、このPodに対してアクセスしてみます。なお簡略化のため、Pod内に含まれるコンテナは1つであるとします。

# コンテナ内でコマンドを実行する方法
## Podにアクセスしてコマンドを実行するためのAPI
Kubernetesには、Podにアクセスしてコマンドを実行するためのAPIとして、次のようなものが提供されています。
`/api/v1/namespaces/{namespace}/pods/{name}/exec`
{namespace}にはアクセス先Podが存在するネームスペース、{name}にはアクセス先Podの名前が入ります。

このAPIの末尾に、パラメータとしてコマンドを記述することで、任意のコマンド実行が可能となります。
例えばlsコマンドを実行する場合は、次の形式でAPIを呼び出します。
`/api/v1/namespaces/{namespace}/pods/{name}/exec?command=ls&stdin=true&stderr=true&stdout=true&tty=true`
単一のコマンド実行の場合、コマンド実行後に通信が切断されます。通信を確立した状態で、インタラクティブなコマンド実行を可能とする場合は、次のようにbashを実行します。
`/api/v1/namespaces/{namespace}/pods/{name}/exec?command=/bin/bash&stdin=true&stderr=true&stdout=true&tty=true`
このようなAPI呼び出しを行うことにより、Podとのインタラクティブな通信が継続的に実施できます。なおstdin、stderr、stdout、ttyのパラメータは、デフォルトではfalseですが、双方向通信を行うためにはtrueにする必要があります。また、Pod内に複数のコンテナが存在する場合は、追加のパラメータとしてコンテナ名を指定する必要があります。

## 通信プロトコル
コンテナとのインタラクティブな通信には、プロトコルとしてWebSocketを利用します。WebSocketは、[RFC6455](https://triple-underscore.github.io/RFC6455-ja.html)で定義されているプロトコルであり、クライアント・サーバ間の双方向通信を実現するためのプロトコルです。通信は、Upgradeヘッダを付加したGETリクエストから始まり、ハンドシェイクを経て通信を確立した後に、双方向通信が可能になります。URIスキームとしてはwsが用いられ、SSL/TLSを用いる場合はwssとなります。

## 送受信時のデータフォーマット
上記のAPIおよび通信プロトコルを用いることで、Pod内のコンテナへのアクセスが可能となります。しかしながら、Kubernetes独自の通信プロトコルとして以下が定義されているため、通信時はこのプロトコルに沿ったデータの送受信を行う必要があります。

- 送受信はバイト列で行われる。
- 全てのバイト列の先頭バイトとして、以下を挿入する。
    - 0: stdin(標準入力)
    - 1: stdout(標準出力)
    - 2: stderr(標準エラー出力)
- コマンドの末尾には改行コード(\n)が必要

例えばコンテナ内で`ls`コマンドを実行したい場合、このプロトコルに従い、以下のバイト列を送信する必要があります。
`[0, 108, 115, 13] // 10進数表記のASCIIコード列、先頭バイトはstdinを、それ以降は"ls\n"を表す。`
このバイト列を、WebSocket通信確立後のコンテナに対して送信すると、応答としてstdoutを表す1から始まるバイト列が返ってきます。

# 簡単な送受信プログラムの作成
## 開発環境および動作の概要
ここまでで述べたAPI、プロトコル、データフォーマットを利用し、簡単な送受信プログラムを作成してみました。開発環境としては、以下を利用しています。

- IDE: Visual Studio 2017 Community
- 言語: C#
- フレームワーク: .NET Core 2.1

作成したプログラムの動作は、以下のようなものです。

- ユーザはコンソールを介して、アクセス先のネームスペースおよびPod名を入力する。
- 入力された情報を利用し、Pod内のコンテナとのWebSocket通信を確立する。
- ユーザからのコマンド入力および、Kubernetesからの情報送信を待ち受ける
   - ユーザからのコマンド入力があった場合、入力されたコマンドをバイト列に変換後、Kubernetesに送信する。
   - Kubernetesからの情報送信があった場合、受け取ったバイト列を文字列に変換後、コンソールに表示する。

## ソースコード
上記の処理を行うソースコードは、以下のようなものとなります。今回はテスト用プログラムであるため、SSL証明書チェックおよび、中間証明書の認証をスキップしています。なお、KubernetesのIPアドレスおよびPortは、`kubectl cluster-info`で確認できます。またアクセスに必要となるTokenは、kube-systemネームスペースのシークレット中にdefault-tokenとして定義されているものを利用しています。


``` C#
static void Main(string[] args)
{
    string k8sUri = "wss://IP:Port";    // kubectl cluster-infoで取得したもの
    string token = "Token"; // kube-systemシークレット中のdefault tokenで指定されているもの

    ClientWebSocket ws = new ClientWebSocket();
    ws.Options.SetRequestHeader("Authorization", "Bearer " + token);

    // SSL証明書の認証をスキップ
    ServicePointManager.ServerCertificateValidationCallback 
        = (s, certificate, chain, sslPolicyError) => true;

    // 中間証明書の認証をスキップ
    ws.Options.RemoteCertificateValidationCallback
        = (s, certificate, chain, sslPolicyErrors) => true;

    Console.Write("Kubernetes namespace: ");
    string k8sNamespace = Console.ReadLine();

    Console.Write("Kubernetes pod name: ");
    string k8sPodName = Console.ReadLine();

    string apiUri = "/api/v1/namespaces/" + k8sNamespace + "/pods/" + k8sPodName 
        + "/exec?command=/bin/bash&stdin=true&stderr=true&stdout=true&tty=true";

    // kubernetesとの接続を確立
    ws.ConnectAsync(new Uri(k8sUri + apiUri), CancellationToken.None).Wait();

    // ユーザからの入力待受用スレッド
    Task inputTask = new Task(() =>
    {
        while (true)
        {
            string command = Console.ReadLine();    // ユーザからの入力を待機

            List<byte> commandBin = new List<byte>(Encoding.UTF8.GetBytes(command));
            commandBin.Insert(0, 0);// stdin prefixを追加
            commandBin.Add(13);     // \nを追加

            // 入力コマンドをArraySegmentに変換した後に送信
            ArraySegment<byte> buff = new ArraySegment<byte>(commandBin.ToArray());
            ws.SendAsync(buff, WebSocketMessageType.Text, true, CancellationToken.None).Wait();
        }
    });
    inputTask.Start();

    // Kubernetesからの通信待受
    while (ws.State == WebSocketState.Open)
    {
        const int MessageBufferSize = 2048;

        ArraySegment<byte> buff = new ArraySegment<byte>(new byte[MessageBufferSize]);
        ws.ReceiveAsync(buff, CancellationToken.None).Wait();   // Kubernetesからの通信を待機

        List<byte> receivedBytes = new List<byte>(buff);
        receivedBytes.RemoveAt(0);              // prefixを削除
        receivedBytes.RemoveAll(b => b == 0);   // 末尾の0x00を削除(余分に確保されたバッファ)
        Console.Write(Encoding.UTF8.GetString(receivedBytes.ToArray()));
    }
}
```

このプログラムを実行することで、以下のようにコンテナ内でのコマンド実行が可能となります。
![dotnet_shell.gif](https://qiita-image-store.s3.amazonaws.com/0/61029/7c1577af-7237-890c-f492-8d42e0e29a9a.gif)



# おわりに
本記事では、KubernetesのAPIを利用し、Pod中のコンテナ内で任意のコマンドを実行する方法について述べました。この手法を応用することで、Kubernetes Dashboardやkubectlコマンドを使わずとも、コンテナ内でのコマンド実行が可能となります。似たようなことを実現したい方の参考になれば幸いです。

# 参考
- Kubernetes API関連
  - [Executing commands in Pods using K8s API | Red Hat OpenShift Blog](https://blog.openshift.com/executing-commands-in-pods-using-k8s-api/)：本記事の内容に近い記事で、参考になりました。
  - [Kubernetes v1 REST API Reference | OpenShift Enterprise 3.0](https://docs.openshift.com/enterprise/3.0/rest_api/kubernetes_v1.html)：API呼び出し時のパラメータの内容が説明されています。
  
- .NET Coreを利用したアプリケーションの作成
  - [Visual Studio 2017 での .NET Core を使用した C# Hello World アプリケーションの構築 | Microsoft Docs](https://docs.microsoft.com/ja-jp/dotnet/core/tutorials/with-visual-studio)

- Minikubeの概要および、導入方法
  - [簡単にローカルKubernetes環境を構築できるツール「Minikube」 | さくらのナレッジ](https://knowledge.sakura.ad.jp/15320/)
  - [minikube で Windows 上に Kubernetes 環境を構築する | takaya030の備忘録](http://takaya030.hatenablog.com/entry/2017/04/30/191727)