こんにちは、chanyou です。

OpenHands を自宅の Kubernetes クラスタで動かしてみました。

構成や、ハマった点とかをメモしておきます。 あくまで自宅の趣味のクラスタでとりあえず動くようにした構成なので、参考程度にお願いします。

定義した Kubernetes オブジェクト

話が早いと思うので、最初に YAML をペタっと貼っておきます。

apiVersion: v1
kind: Namespace
metadata:
  name: openhands
  labels:
    app: openhands
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: openhands
  namespace: openhands
  labels:
    app: openhands
spec:
  replicas: 1
  selector:
    matchLabels:
      app: openhands
  template:
    metadata:
      labels:
        app: openhands
    spec:
      containers:
        # dind-daemon の postStart が完了してから openhands-app を起動する
        - name: dind-daemon
          image: docker:28.0.1-dind
          env:
            - name: DOCKER_TLS_CERTDIR
              value: ""
          securityContext:
            privileged: true
          lifecycle:
            postStart:
              exec:
                command:
                  - "sh"
                  - "-c"
                  - |
                    echo "Waiting for Docker daemon to be ready..."
                    until docker info; do
                      echo "Docker daemon not ready yet, sleeping..."
                      sleep 1
                    done
                    echo "Docker daemon is ready."
        - name: openhands-app
          image: docker.all-hands.dev/all-hands-ai/openhands:0.28
          env:
            - name: SANDBOX_RUNTIME_CONTAINER_IMAGE
              value: "docker.all-hands.dev/all-hands-ai/runtime:0.28-nikolaik"
            - name: LOG_ALL_EVENTS
              value: "true"
            - name: DOCKER_HOST
              value: "tcp://localhost:2375"
          ports:
            - containerPort: 3000
          volumeMounts:
            - name: openhands-state
              mountPath: /.openhands-state
      hostAliases:
        - ip: "127.0.0.1"
          hostnames:
            - "host.docker.internal"
      volumes:
        - name: openhands-state
          persistentVolumeClaim:
            claimName: openhands-state
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: openhands-state
  namespace: openhands
spec:
  storageClassName: longhorn
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
---
apiVersion: v1
kind: Service
metadata:
  name: openhands
  namespace: openhands
  labels:
    app: openhands
spec:
  type: ClusterIP
  selector:
    app: openhands
  ports:
    - name: http
      port: 80
      targetPort: 3000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: openhands-ingress
  namespace: openhands
  labels:
    app: openhands
  annotations:
    kubernetes.io/ingress.class: "nginx"
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    nginx.ingress.kubernetes.io/auth-url: "https://oauth2-proxy.example.com/oauth2/auth"
    nginx.ingress.kubernetes.io/auth-signin: "https://oauth2-proxy.example.com/oauth2/start?rd=https://openhands.example.com"
spec:
  rules:
    - host: openhands.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: openhands
                port:
                  name: http
  tls:
    - hosts:
        - openhands.example.com
      secretName: openhands-tls

解説

Docker outside of Docker から Docker in Docker へ

OpenHands のドキュメント では、以下のようにホスト側の docker.sock をコンテナに渡すことで、ホスト側の Docker で Runtime コンテナの起動を行えるようにしています。

docker pull docker.all-hands.dev/all-hands-ai/runtime:0.28-nikolaik

docker run -it --rm --pull=always \
    -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.28-nikolaik \
    -e LOG_ALL_EVENTS=true \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -v ~/.openhands-state:/.openhands-state \
    -p 3000:3000 \
    --add-host host.docker.internal:host-gateway \
    --name openhands-app \
    docker.all-hands.dev/all-hands-ai/openhands:0.28

いわゆる Docker outside of Docker の構成で、これを愚直に Kubernetes の node に対して行うと node の環境が汚れて始末が大変そうなので避けたいです。

Docker in Docker の構成を Kubernetes で取るには、サイドカーとして Docker デーモンを動かして OpenHands コンテナから参照させるとよいようです。1

docker:28.0.1-dind イメージをサイドカーとして立てる方針としました。

dind サイドカーが起動してから OpenHands を起動する

ここが一番ハマったポイントでした。OpenHands 起動時に Docker デーモンが立ち上がっていないと、OpenHands 経由で Runtime がうまく立ち上がらず、接続待ちでスタックしてしまいました。

当時のつぶやきが残っていたんですが、スタックしたりしなかったりしたんですよね。

これは OpenHands 起動時に dind イメージが ready になっていたら、OpenHands からの Runtime 起動もうまくいっていたということでした。

ということで dind の準備が整ってから OpenHands が起動するように工夫します。

通常 Pod の containers は同時に起動するのですが postStart を設定することで、起動順を制御できるようでした。2

postStart を含むコンテナが定義された場合は、そのコマンドが完了してから次のコンテナの起動が開始する挙動のようです。

postStart のコマンドは LLM の手を借りながら、サクッと組み上げました。

          lifecycle:
            postStart:
              exec:
                command:
                  - "sh"
                  - "-c"
                  - |
                    echo "Waiting for Docker daemon to be ready..."
                    until docker info; do
                      echo "Docker daemon not ready yet, sleeping..."
                      sleep 1
                    done
                    echo "Docker daemon is ready."

host.docker.internal を hostAliases として定義する

OpenHands の起動コマンドで --add-host host.docker.internal:host-gateway オプションがつけられているように、ローカルの Runtime コンテナは host.docker.internal 経由で操作される挙動のようです。

local runtime url 相当のパラメータを変えることで対応できそうですが、デフォルト設定に乗っかったほうがトラブルが少なそうだったので、以下のように hostAliases を指定して Docker Desktop に近い環境としました。

      hostAliases:
        - ip: "127.0.0.1"
          hostnames:
            - "host.docker.internal"

.openhands-state を永続化する

Pod の再起動によって API Token などの設定や過去の会話が揮発してしまうので、 PVC で永続化を行います。

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: openhands-state
  namespace: openhands
spec:
  storageClassName: longhorn
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

自分の環境だと Longhorn を使っているので、それでサクッと作りました。

Deployment では以下のように /.openhands-state にマウントしています。

          volumeMounts:
            - name: openhands-state
              mountPath: /.openhands-state

何らかの認証を挟む

外部からのアクセスは Ingress, Service を経由した一般的な構成です。 2025-03-08 時点では OpenHands のサーバーには認証機能がないため、全世界に公開すると誰でも LLM の API を使い放題になってしまいます。破産です。

これは環境によりけりですが、自分の場合は OAuth2 Proxy を導入済みだったので、そちらで OAuth の認証だけ被せる形をとりました。

    nginx.ingress.kubernetes.io/auth-url: "https://oauth2-proxy.example.com/oauth2/auth"
    nginx.ingress.kubernetes.io/auth-signin: "https://oauth2-proxy.example.com/oauth2/start?rd=https://openhands.example.com"

まとめ

メモなので特にまとめることはないんですが、 Kubernetes における dind パターンや postStart による起動順の制御については、今回調べてみるまで知らなかったので勉強になりました。

ただ privileged を true にする必要があるので、リスクコントロールが重要そうです。お仕事ってなると運用負荷もありますしセルフホストせずに Devin などのクラウドサービスを利用するのが良いんでしょうね。

OpenHands はまだ使い倒せてはないですが、費用感については dyoshikawa さんが書かれた記事と全く同じことを思っています。

今のところ知見は全くないですが、趣味でセルフホストで使うなら、ローカル LLM も視野にいれるとよいのかな。話題が尽きないので引き続きウォッチしていこうと思います。

おうち Kubernetes クラスタがこういった素振りや味見に役立っていて、持っててよかったなーとなっています。