こんにちは、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 起動もうまくいっていたということでした。
OpenHands サーバーを自宅のk8sクラスタに立ててみてるんだけど、えらくコンテナ立ち上がるの時間かかる気がするな…
— chanyou (@chanyou0311) March 7, 2025
さっきは20分くらい放置してたらいつの間にか繋がってたけど、今は20分以上 Connecting... でスタックしてしまうなあ
ということで 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 さんが書かれた記事と全く同じことを思っています。
dyoshikawaさんの記事だ。自分も使い始めていて、全く同じような感想を抱いたところだった…
— chanyou (@chanyou0311) March 8, 2025
OpenHands(OpenDevin)を使った感想「本家Devinは安い」|dyoshikawa https://t.co/cRPRX8yCmZ #zenn
今のところ知見は全くないですが、趣味でセルフホストで使うなら、ローカル LLM も視野にいれるとよいのかな。話題が尽きないので引き続きウォッチしていこうと思います。
おうち Kubernetes クラスタがこういった素振りや味見に役立っていて、持っててよかったなーとなっています。