Pulumi (Python)でKubernetesのPods / Helm Chartをデプロイしてみた

Pulumi初心者が、Kubernetesの勉強も兼ねて評価を行った際のメモ書き。
公式ドキュメントは比較的TypeScriptの例が多いため、Pythonの事例として書き残しておく。

## 概要
タイトルの通り、PulumiでKubernetesのデプロイをお試し評価。以下は主な感想。

- Kubernetes用のテンプレート(`kubernetes-python`)が提供されているため、比較的導入のしきいは低い(プロジェクトの初期状態から`pulumi up`する程度で、シンプルな`nginx`のdeploymentが作成できる)。
    - `kubectl`コマンドが叩ける状態になっていれば、特に追加の設定はなさそう。
- PulumiのHelm Chartデプロイ機能は、単純にHelmをKickするだけでなく、独自にServideやDeploymentなどのリソースを展開＆解釈してデプロイしている。このため、Helm 2.xでもTiller Serverが不要。
    - 今回はHelm 3.xを利用したためこの恩恵は受けられず、逆に`helm list`コマンドで内容を確認できないデメリットもあり。
- `Pulumi.Output`(生成されたリソースの出力用パラメータ)の変換実装には、相応に試行錯誤が必要だった(`apply`関数の周りなどの理解がある程度難しい)。
    - 実装コードは逐次処理のPythonコードに見えるが、内部的には(PreviewやDiff制御など)宣言的な概念も扱っているため、若干理解し辛い側面もありそう。

## 評価
### 前提

Kubernetesのクラスタ(AWS EKSを利用)は構築済で、Plumi及びHelmの初期設定も完了状態を前提に置く。
テストに利用するHelm Chart (`bitnami/jenkins`)のリポジトリも事前登録した状態(`FetchOps`クラスの機能でリポジトリからも取得できそうだが、今回の評価対象からは除外)。

```
$ pulumi version
v1.8.1
```

```
$ kubectl get nodes
NAME                                            STATUS   ROLES    AGE     VERSION
ip-10-0-0-145.ap-northeast-1.compute.internal   Ready    <none>   1h51m   v1.14.7-eks-1861c5
ip-10-0-2-96.ap-northeast-1.compute.internal    Ready    <none>   1h50m   v1.14.7-eks-1861c5
```

```
$ helm version
version.BuildInfo{Version:"v3.0.2", GitCommit:"19e47ee3283ae98139d98460de796c1be1e3975f", GitTreeState:"clean", GoVersion:"go1.13.5"}

$ helm repo list
NAME   	URL
bitnami	https://charts.bitnami.com/bitnami
```

### Project初期化
`kubernetes-python`テンプレートを利用して、Pulumi Projectを新規作成(聞かれる質問はデフォルトで回答)。

```
$ mkdir python-k8s && cd python-k8s
$ pulumi new kubernetes-python

This command will walk you through creating a new Pulumi project.
 
 Enter a value or leave blank to accept the (default), and press <ENTER>.
 Press ^C at any time to quit.
 
 project name: (python-k8s)
 project description: (A minimal Kubernetes Python Pulumi program)
 Created project 'python-k8s'
 
 Please enter your desired stack name.
 To create a stack in an organization, use the format <org-name>/<stack-name> (e.g. `acmecorp/dev`).
 stack name: (dev)
 Created stack 'dev'
 
 Your new project is ready to go! ✨
 
 To perform an initial deployment, run the following commands:
 
    1. virtualenv -p python3 venv
    2. source venv/bin/activate
    3. pip3 install -r requirements.txt
 
 Then, run 'pulumi up'
```

メッセージに表示される通りに、Pythonの`virtualenv`環境の設定（及び依存ライブラリのインストール)を行う。

```
$ pip3 install virtualenv
$ virtualenv -p python3 venv
$ source venv/bin/activate
$ pip3 install -r requirements.txt
```

### 実装

Pythonの実装コード(`__main__.py`)を、以下の2種類のリソースを作成するように変更。なお、OutputにはそれぞれのServiceのホスト名(AWS Elastic Load Balancerに割当されたホスト名)を出力するように定義。

- NGINX Deployment / Service
    - NGINXのdeploymentと、外部公開用のLoadBalancer TypeのService。
- Jenkins Helm Chart
    - bitnami/jenkinsのHelm Chart。

```python
import base64
import pulumi
from pulumi_kubernetes.apps.v1 import Deployment
from pulumi_kubernetes.core.v1 import Service
from pulumi_kubernetes.helm.v2 import Chart, ChartOpts

def deploy_nginx_service():
    app_name = "nginx"
    app_labels = { "app": app_name }

    nginx_deployment = Deployment(
        app_name,
        spec={
            "selector": { "match_labels": app_labels },
            "replicas": 1,
            "template": {
                "metadata": { "labels": app_labels },
                "spec": { "containers": [{ "name": app_name, "image": "nginx" }] }
            }
        })

    nginx_service = Service(
        app_name,
        metadata={
            "labels": nginx_deployment.spec["template"]["metadata"]["labels"],
        },
        spec={
            "type": "LoadBalancer",
            "ports": [{ "port": 80, "target_port": 80, "protocol": "TCP" }],
            "selector": app_labels,
        })

    return nginx_service

def deploy_jenkins_chart():
    return Chart("jenkins", ChartOpts(
        chart="jenkins",
        repo="bitnami",
        values={},
    ))

nginx_service = deploy_nginx_service()
jenkins_chart = deploy_jenkins_chart()

pulumi.export("nginx_hostname",
    nginx_service.status.apply(
        lambda x: x["load_balancer"]["ingress"][0]["hostname"]))

pulumi.export("jenkins_hostname",
    jenkins_chart.resources.apply(
        lambda x: x["v1/Service:jenkins"].status["load_balancer"]["ingress"][0]["hostname"]))
```

### 実行

```shell
pulumi up
Previewing update (dev):

     Type                                         Name            Plan
 +   pulumi:pulumi:Stack                          python-k8s-dev  create
 +   ├─ kubernetes:helm.sh:Chart                  jenkins         create
 +   │  ├─ kubernetes:core:PersistentVolumeClaim  jenkins         create
 +   │  ├─ kubernetes:core:Secret                 jenkins         create
 +   │  ├─ kubernetes:core:Service                jenkins         create
 +   │  └─ kubernetes:apps:Deployment             jenkins         create
 +   ├─ kubernetes:apps:Deployment                nginx           create
 +   └─ kubernetes:core:Service                   nginx           create

Resources:
    + 8 to create

Do you want to perform this update? yes
Updating (dev):

     Type                                         Name            Status
 +   pulumi:pulumi:Stack                          python-k8s-dev  created
 +   ├─ kubernetes:helm.sh:Chart                  jenkins         created
 +   │  ├─ kubernetes:core:Secret                 jenkins         created
 +   │  ├─ kubernetes:core:Service                jenkins         created
 +   │  ├─ kubernetes:core:PersistentVolumeClaim  jenkins         created
 +   │  └─ kubernetes:apps:Deployment             jenkins         created
 +   ├─ kubernetes:apps:Deployment                nginx           created
 +   └─ kubernetes:core:Service                   nginx           created

Outputs:
    jenkins_hostname: "xxx.ap-northeast-1.elb.amazonaws.com"
    nginx_hostname  : "yyy.ap-northeast-1.elb.amazonaws.com"

Resources:
    + 8 created
```

`Outputs`に出力されたホスト名が、それぞれ`jenkins`、`nginx`に対応する`Service (Load Balancer)`。それぞれ`curl`でアクセスすると応答が返る。 

```
$ curl -s --head xxx.ap-northeast-1.elb.amazonaws.com | grep X-Jenkins:
X-Jenkins: 2.204.1
```

```
$ curl -s --head yyy.ap-northeast-1.elb.amazonaws.com | grep Server
Server: nginx/1.17.6
```

### リソースの確認

`kubectl`コマンドでも、生成されたリソースを確認可能。ただし、Helm ChartはPulumiが独自展開するため、表示されない(一覧が空のまま)。

```
$ kubectl get deployments
NAME             READY   UP-TO-DATE   AVAILABLE   AGE
jenkins          1/1     1            1           32m
nginx-nziiq5rs   1/1     1            1           32m
```

```
$ kubectl get services
NAME             TYPE           CLUSTER-IP      EXTERNAL-IP                            PORT(S)                      AGE
jenkins          LoadBalancer   172.20.125.15   xxx.ap-northeast-1.elb.amazonaws.com   80:32525/TCP,443:31321/TCP   33m
kubernetes       ClusterIP      172.20.0.1      <none>                                 443/TCP                      1h42m
nginx-6hbjq6d7   LoadBalancer   172.20.14.82    yyy.ap-northeast-1.elb.amazonaws.com   80:32325/TCP                 33m
```

```
$ helm list
NAME	NAMESPACE	REVISION	UPDATED	STATUS	CHART	APP VERSION
```

## 参考情報
- [Pulumi Kubernetes](https://www.pulumi.com/docs/get-started/kubernetes/)

Kubernetes向けPluginの説明。

- [Docs Reference API pulumi_kubernetes](https://www.pulumi.com/docs/reference/pkg/python/pulumi_kubernetes/)

API Reference。

- [Pulumi Kubernetes WordPress Helm Chart](https://www.pulumi.com/docs/tutorials/kubernetes/wordpress-chart/)

> In this tutorial, we’ll use the Helm API of @pulumi/kubernetes to deploy v2.1.3 of the Wordpress Helm Chart to a Kubernetes cluster. The Tiller server is not required to be installed. Pulumi will expand the Helm Chart and submit the expanded YAML to the cluster.

PulumiのHelm Providerは独自にChartを展開してデプロイするという説明。これに起因してか、`helm list`の結果には出力されない模様。

- [Docs - Intro to Pulumi - Architecture & Concepts - Programming Model - apply](https://www.pulumi.com/docs/intro/concepts/programming-model/#apply)

Ouptutの出力変換についての説明。
Pulumiが出力するOutput (`Pulumi.Output`型)を加工する際には`apply`関数を利用する必要がある (内部で特殊な処理をしているのか、単純にオブジェクト変換するとうまく動作しないことがある。例えば、`pulumi up`などの処理がブロックされて処理が進まなくなる現象に遭遇)。

> The apply method accepts a callback which will be passed the value of the Output when it is available, and which returns the new value. The result of the call to apply is a new Output whose value is the value returned from the callback, and which includes the dependencies of the original Output. If the callback itself returns an Output, the dependencies of that output are unioned into the dependencies of the returned Output.