OpenshiftでKubeflow 1.0のPipelineを試しました


Openshift 4.xがkubeflow v1.0を正式にサポートするようになっていますが、まだ日が浅くネット上情報あんまり転がっていないようなので、意外とはまった場面も結構あったので自分が試したことを共有したいと思います。ご参考になれれば幸いです。

環境構築

Openshiftは基本エンタープライズ向けのkubernetesと言われているようですが、kubernetesみたいに手軽に触れるような環境がなく、環境構築ひとまず苦労するかと思いますが、インストール自体が趣旨それますので、詳しくは割愛しますが、ローカル環境でOpenshift Clusterを構築する場合はCode Ready Container(CRC)というものを使います。

筆者の環境
Azure VM(linuxの物理マシン持っていないので):
Standard D8s v3 (8 vcpu 数、32 GiB メモリ)
※Kubeflowを動かすには6 cpu、16GB memory推奨なので、ホストPCはもう少し余裕を持たせた構成

Code Ready Container:
https://code-ready.github.io/crc/
※AzureVM上でCRCのVMを動かすイメージ(いわゆる入れ子の仮想化)

Kubeflow 1.0のインストール:
https://www.kubeflow.org/docs/openshift/install-kubeflow/

Client PC(Remote)のWebブラウザからKubeflowの画面にアクセスするためのテクニック
https://www.openshift.com/blog/accessing-codeready-containers-on-a-remote-server/

筆者は実際これらで骨折れる思いをしたので、皆さん基本読み飛ばすことをお勧めします。

Kubeflow pipeline

いうまでもなくpipelineは基本Kubeflow機械学習ツールセットの中で「パイプライン」機能を担当する部分です。pipelineは実際裏でArgoというコンテナー環境で有名なworkflowツールによって構成されている、kubeflow pipelineはただpythonコードをArgo用のmanifestにコンパイルするツール、なので問題があった時、pipelineそのものを調べるよりは、Argoを直接見たほうが幸せになれる気がします。

本題、fashion-mnistを疎通する

本記事はfashion-mnistのサンプルを題材にしてその疎通確認とトラブルシュートがメインの内容になります。

  • リンク先のREADME.mdに従って、ソース一式をkubeflowのNotebook側に配置する
git clone https://github.com/manceps/manceps-canonical.git
  • containerRuntimeExecutorについて

 pipelineユーザなら気にする必要はないが、実際Openshiftは裏がDockerが動いているわけではないので、pipelineを実行するにはcontainerRuntimeExecutorを標準のdockerからk8sapiに変更する必要がある。実際の作業としては「workflow-controller-configmap」の該当箇所を書き換えればOKです。これがないと始まりません。

  • KF_Fashion_MNIST.ipynbを道なりに進み、とりあえず一旦最後まで実行する

 Pipeline SDK(kpf)のインストールを経て、おおむね「2.3 Build Kubeflow Pipeline」あたりまで問題ないでしょう。しかし、これからが長い闘いです、心長くしてみてみましょう。

 create_run_from_pipeline_funcを実行するとpipeline側のリンクが表示されるので、pipelineの実行状況を確認します。するとまずこのようなエラーにぶつかるはず

invalid spec: templates.create-volume.outputs.parameters.create-volume-manifest: k8sapi executor does not support outputs from base image layer. must use emptyDir

最初は何言っているかよくわかりませんって感じですね、簡単にいうと、Argoというものは中間成果物を次の処理に引き渡すことで成り立つので、その中間成果物の書き込みに失敗したということ、前述したk8sapiのExecutorの場合はbase image layerいわゆる/tmp直下みたいなところに書き込めないので、「emptyDir」(k8s世界でいう一時の保管場所)を用意しろということです。ちなみにdocker Executorの場合はbase image layerに書き込むことができるので、この問題は存在しない、k8sapiを使うOpenshift特有なものになります。動かない場合は十中八九はこれが原因なので、分析の切口としていいかもしれません。

さて、対策としては言われた通りにEmptyDirを用意する、残念ながら、kpfにはそのようなapiは提供されていない模様、k8sを生で扱うことになりますが、以下のようにemptyDirのマウントをdsl.VolumeOpのvopに適用する


def add_emptydir(op):
    from kubernetes import client as k8s_client
    op.add_volume(k8s_client.V1Volume(name='outputs', empty_dir=k8s_client.V1EmptyDirVolumeSource()))
    op.container.add_volume_mount(k8s_client.V1VolumeMount(name='outputs', mount_path='/tmp/outputs'))
    return op

vop = dsl.VolumeOp(
    name="create_volume",
    resource_name="data-volume", 
    size="1Gi", 
    modes=dsl.VOLUME_MODE_RWM)

add_emptydir(vop)

結論はダメでした、「vop」に「container」が存在しないとコンパイルも通らなくなった。以前ContainerOpにemptyDirを適用できたので、あれっと思いながら、dsl.VolumeOpのソースを確認してみると確かにこいつがContainerOpと違ってcontainerプロパティを持ってない。

VolumeOpは実際kpfを通して以下Argoのmanifestになります。見ての通りresourceであり、containerではない、つまり実体はResourceOpになります。

    - name: create-volume
      resource:
        action: create
        manifest: |
          apiVersion: v1
          kind: PersistentVolumeClaim
          metadata:
            name: '{{workflow.name}}-data-volume'
          spec:
            accessModes:
            - ReadWriteMany
            resources:
              requests:
                storage: 1Gi
   outputs:
        parameters:
          - name: create-pvc-manifest  ←これが原因でエラー
            valueFrom: {jsonPath: '{}'}
          - name: create-pvc-name
            valueFrom: {jsonPath: '{.metadata.name}'}
          - name: create-pvc-size
            valueFrom: {jsonPath: '{.status.capacity.storage}'}

Argoがこれを見て裏でPVC(Persitant Volume Claim)のyamlをkubectl createで投げるだけなので、そもそもContainerは必要ないね、outputsはArgoで必須ではなくkpfが作ったもので逆にArgoを阻害する格好になってしまってます。

dsl.VolumeOpは本来kpfでPVCを作ったり消したりするpipelineのコンポネントで、PVCのライフサイクルをpipeline内で完結できる便利なもの、Openshiftの場合これが利用できず、k8sを弄れば作れなくはないが本質それるので、この問題いまのところはoc createであらかじめPVCを作る羽目になりそうです。ちなみに、このissueが報告されています。

ArgoがEmptyDirを自動でつけてくれればなとさえ思います。同意であれば、このissueを高評価してください、他力本願ですいません。。。

  • add_pvolumesについて

上の問題を一旦置いとき、train_op(data_path, model_file).add_pvolumes({data_path: vop.volume})あたりを見てみます。ここで出てきたadd_pvolumeとは、前述VolumeOpで作ったPVCをこのコンテナーで利用するという意味で、おそらくVolumeOpが失敗する限り、後続のworkflowも全滅するはずで、この時点で諦めがつきました。

実際add_pvolumeのmanifestを見てみると、コンテナーのPVCをつなげてくれるようにコンパイルされていて、結構有能だと感じましたが、使えなくて残念です。

    # Create MNIST training component.
    mnist_training_container = train_op(data_path, model_file) \
                                    .add_pvolumes({data_path: vop.volume})
  • それでも動くまでやってみた

ここで終わると申し訳ないので一応動くまで、PVC(Persitant Volume Claim)をあらかじめ作ってマウントするアプローチしてみました。

PVCはOpenshfitのUIなり、oc createなり作っておいてください、ちなみにpipelineは「kubeflow」のNamespaceで動いているので、そこで作ること。
ここは「fashion-mnist-pvc」のPVCを作ったとする。するとpipeline部分のコードは以下のようになりました。

PVCを作る方法をとりましたが、少し変則だが、VolumeOpを使うことも可能、VolumeOpはoutputsを出力しないようにすればEmptyDir問題が回避できpvc自体は作れるようになる、ただpvcのnameは動的なので、この例の場合'{{workflow.name}}-data-volume'という具合にworkflow内部でpvcのnameをゲットできます。したがって最終的このようになりました。少しはすっきりなった。

    import kfp.onprem as onprem

    vop = dsl.VolumeOp(
      name="create_volume",
      resource_name="data-volume", 
      size="1Gi", 
      modes=dsl.VOLUME_MODE_RWM)

    #VolumeOpをoutputsの出力しないようにする
    vop.outputs={}

    # add_pvolumeをコメントアウト   
    mnist_training_container = train_op(data_path, model_file) #.add_pvolumes({data_path: vop.volume})
  mnist_training_container.after(vop)

    # VolumeOpで作ったpvcは'{{workflow.name}}-data-volume'のnameになるので、workflow内でお互いに認識できる
    # onprem.mount_pvcはkpf標準で提供しているpvcをマウントするapi
    # ちなみに内部で「V1PersistentVolumeClaimVolumeSource」固定なので、前述のEmptyDirの提供がない根拠はこれです
    mnist_training_container.apply(onprem.mount_pvc('{{workflow.name}}-data-volume', 'train-mount',"/fashion-mnist"))

    # Create MNIST prediction component.
    mnist_predict_container = predict_op(data_path, model_file, image_number) #.add_pvolumes({data_path: mnist_training_container.pvolume})    
    mnist_predict_container.apply(onprem.mount_pvc('{{workflow.name}}-data-volume', 'predict-mount',"/fashion-mnist"))

    # add_pvolumeを使えば、順番制御を兼ねているが、ここではafterで指定する必要がある
    mnist_predict_container.after(mnist_training_container)

    # Print the result of the prediction
    mnist_result_container = dsl.ContainerOp(
        name="print_prediction",
        image='library/bash:4.4.23',
        pvolumes={data_path: mnist_predict_container.pvolume},
        arguments=['cat', f'{data_path}/result.txt']
    )

    mnist_result_container.after(mnist_predict_container)
    mnist_result_container.apply(onprem.mount_pvc('{{workflow.name}}-data-volume', 'result-mount',"/fashion-mnist"))

これで晴れて青信号が点灯するかと思いますが、いかがでしょう。VolumeOpの問題は根本的な解決ではなく、物足りない感が否めませんが、pipelineの基本(VolumeOp,ContainerOp,func_to_container_opあたり)を確認できたのではないかなと思います。

これまでのこと考える

  • Openshiftでのpipelineは中間成果物(outputs)の保存には大きな問題だと感じます。Openshfitはrootユーザでの起動は制限されているので、base image layerへの書き込み権限がなく、そのためEmptyDirなりなんなり保管場所を要求してくる、EmptyDir自体永続ではないので、ほとんどの場合PVCの利用は必要ではないでしょうか。しかし、このVolumeOpの問題でpipelineの使い勝手を大きく損なう形になってしまい、現時点でまだフル機能の利用は難しい状況と言わざるを得ない。
  • DXの観点からocを使うより、pipeline内部でPVCの生成から消滅まで完結できるのが理想、そうでないと消し忘れのPVCいっぱいで溢れてしまう恐れがある。
  • Argo側がEmptyDir機能の実装が実現できればVolumeOp自体解決できるが、Argoはpnsを勧めておりk8sapiの面倒あんまり積極ではないように見受けられるので、そう簡単にはいかない。
  • PVC以外なら、kubeflow minio(s3互換)への書き出しは可能なので、そこで活路が見つかるかもしれない。
  • 実はArgo内部でk8s_resourceのタスクはkubectlコマンドを投入するためのContainerを起動しています、そのcontainerはkpf側で制御できないのが問題で、もしkpf側でkubectlコマンド用のContainerを使うように実装を変えればこの問題は解消できるでしょう。

このサンプルを通していろいろ問題が見えてきました。kubeflow自体はこれからも発展し続けると思いますが、Openshiftのサポートはどこまでいけるか引き続き注目していきたい。今回の内容はこれで終わりですが、pipelineについてまだ見切れてないので、またネタがたまったら更新していきたい。