docker_runtime.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. import atexit
  2. from functools import lru_cache
  3. import os
  4. from typing import Callable
  5. import docker
  6. import requests
  7. import tenacity
  8. from openhands.core.config import AppConfig
  9. from openhands.core.exceptions import (
  10. AgentRuntimeDisconnectedError,
  11. AgentRuntimeNotFoundError,
  12. AgentRuntimeNotReadyError,
  13. )
  14. from openhands.core.logger import DEBUG
  15. from openhands.core.logger import openhands_logger as logger
  16. from openhands.events import EventStream
  17. from openhands.runtime.builder import DockerRuntimeBuilder
  18. from openhands.runtime.impl.action_execution.action_execution_client import (
  19. ActionExecutionClient,
  20. )
  21. from openhands.runtime.impl.docker.containers import remove_all_containers
  22. from openhands.runtime.plugins import PluginRequirement
  23. from openhands.runtime.utils import find_available_tcp_port
  24. from openhands.runtime.utils.log_streamer import LogStreamer
  25. from openhands.runtime.utils.runtime_build import build_runtime_image
  26. from openhands.utils.async_utils import call_sync_from_async
  27. from openhands.utils.tenacity_stop import stop_if_should_exit
  28. CONTAINER_NAME_PREFIX = 'openhands-runtime-'
  29. def remove_all_runtime_containers():
  30. remove_all_containers(CONTAINER_NAME_PREFIX)
  31. _atexit_registered = False
  32. class DockerRuntime(ActionExecutionClient):
  33. """This runtime will subscribe the event stream.
  34. When receive an event, it will send the event to runtime-client which run inside the docker environment.
  35. Args:
  36. config (AppConfig): The application configuration.
  37. event_stream (EventStream): The event stream to subscribe to.
  38. sid (str, optional): The session ID. Defaults to 'default'.
  39. plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
  40. env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
  41. """
  42. def __init__(
  43. self,
  44. config: AppConfig,
  45. event_stream: EventStream,
  46. sid: str = 'default',
  47. plugins: list[PluginRequirement] | None = None,
  48. env_vars: dict[str, str] | None = None,
  49. status_callback: Callable | None = None,
  50. attach_to_existing: bool = False,
  51. headless_mode: bool = True,
  52. ):
  53. global _atexit_registered
  54. if not _atexit_registered:
  55. _atexit_registered = True
  56. atexit.register(remove_all_runtime_containers)
  57. self.config = config
  58. self._host_port = 30000 # initial dummy value
  59. self._container_port = 30001 # initial dummy value
  60. self._runtime_initialized: bool = False
  61. self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
  62. self.status_callback = status_callback
  63. self.docker_client: docker.DockerClient = self._init_docker_client()
  64. self.base_container_image = self.config.sandbox.base_container_image
  65. self.runtime_container_image = self.config.sandbox.runtime_container_image
  66. self.container_name = CONTAINER_NAME_PREFIX + sid
  67. self.container = None
  68. self.runtime_builder = DockerRuntimeBuilder(self.docker_client)
  69. # Buffer for container logs
  70. self.log_streamer: LogStreamer | None = None
  71. super().__init__(
  72. config,
  73. event_stream,
  74. sid,
  75. plugins,
  76. env_vars,
  77. status_callback,
  78. attach_to_existing,
  79. headless_mode,
  80. )
  81. # Log runtime_extra_deps after base class initialization so self.sid is available
  82. if self.config.sandbox.runtime_extra_deps:
  83. self.log(
  84. 'debug',
  85. f'Installing extra user-provided dependencies in the runtime image: {self.config.sandbox.runtime_extra_deps}',
  86. )
  87. def _get_action_execution_server_host(self):
  88. return self.api_url
  89. async def connect(self):
  90. self.send_status_message('STATUS$STARTING_RUNTIME')
  91. try:
  92. await call_sync_from_async(self._attach_to_container)
  93. except docker.errors.NotFound as e:
  94. if self.attach_to_existing:
  95. self.log(
  96. 'error',
  97. f'Container {self.container_name} not found.',
  98. )
  99. raise e
  100. if self.runtime_container_image is None:
  101. if self.base_container_image is None:
  102. raise ValueError(
  103. 'Neither runtime container image nor base container image is set'
  104. )
  105. self.send_status_message('STATUS$STARTING_CONTAINER')
  106. self.runtime_container_image = build_runtime_image(
  107. self.base_container_image,
  108. self.runtime_builder,
  109. platform=self.config.sandbox.platform,
  110. extra_deps=self.config.sandbox.runtime_extra_deps,
  111. force_rebuild=self.config.sandbox.force_rebuild_runtime,
  112. extra_build_args=self.config.sandbox.runtime_extra_build_args,
  113. )
  114. self.log(
  115. 'info', f'Starting runtime with image: {self.runtime_container_image}'
  116. )
  117. await call_sync_from_async(self._init_container)
  118. self.log(
  119. 'info',
  120. f'Container started: {self.container_name}. VSCode URL: {self.vscode_url}',
  121. )
  122. self.log_streamer = LogStreamer(self.container, self.log)
  123. if not self.attach_to_existing:
  124. self.log('info', f'Waiting for client to become ready at {self.api_url}...')
  125. self.send_status_message('STATUS$WAITING_FOR_CLIENT')
  126. await call_sync_from_async(self._wait_until_alive)
  127. if not self.attach_to_existing:
  128. self.log('info', 'Runtime is ready.')
  129. if not self.attach_to_existing:
  130. await call_sync_from_async(self.setup_initial_env)
  131. self.log(
  132. 'debug',
  133. f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}. VSCode URL: {self.vscode_url}',
  134. )
  135. if not self.attach_to_existing:
  136. self.send_status_message(' ')
  137. self._runtime_initialized = True
  138. @staticmethod
  139. @lru_cache(maxsize=1)
  140. def _init_docker_client() -> docker.DockerClient:
  141. try:
  142. return docker.from_env()
  143. except Exception as ex:
  144. logger.error(
  145. 'Launch docker client failed. Please make sure you have installed docker and started docker desktop/daemon.',
  146. )
  147. raise ex
  148. def _init_container(self):
  149. self.log('debug', 'Preparing to start container...')
  150. self.send_status_message('STATUS$PREPARING_CONTAINER')
  151. plugin_arg = ''
  152. if self.plugins is not None and len(self.plugins) > 0:
  153. plugin_arg = (
  154. f'--plugins {" ".join([plugin.name for plugin in self.plugins])} '
  155. )
  156. self._host_port = self._find_available_port()
  157. self._container_port = (
  158. self._host_port
  159. ) # in future this might differ from host port
  160. self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
  161. use_host_network = self.config.sandbox.use_host_network
  162. network_mode: str | None = 'host' if use_host_network else None
  163. port_mapping: dict[str, list[dict[str, str]]] | None = (
  164. None
  165. if use_host_network
  166. else {f'{self._container_port}/tcp': [{'HostPort': str(self._host_port)}]}
  167. )
  168. if use_host_network:
  169. self.log(
  170. 'warn',
  171. 'Using host network mode. If you are using MacOS, please make sure you have the latest version of Docker Desktop and enabled host network feature: https://docs.docker.com/network/drivers/host/#docker-desktop',
  172. )
  173. # Combine environment variables
  174. environment = {
  175. 'port': str(self._container_port),
  176. 'PYTHONUNBUFFERED': 1,
  177. }
  178. if self.config.debug or DEBUG:
  179. environment['DEBUG'] = 'true'
  180. if self.vscode_enabled:
  181. # vscode is on port +1 from container port
  182. if isinstance(port_mapping, dict):
  183. port_mapping[f'{self._container_port + 1}/tcp'] = [
  184. {'HostPort': str(self._host_port + 1)}
  185. ]
  186. self.log('debug', f'Workspace Base: {self.config.workspace_base}')
  187. if (
  188. self.config.workspace_mount_path is not None
  189. and self.config.workspace_mount_path_in_sandbox is not None
  190. ):
  191. # e.g. result would be: {"/home/user/openhands/workspace": {'bind': "/workspace", 'mode': 'rw'}}
  192. volumes = {
  193. self.config.workspace_mount_path: {
  194. 'bind': self.config.workspace_mount_path_in_sandbox,
  195. 'mode': 'rw',
  196. }
  197. }
  198. logger.debug(f'Mount dir: {self.config.workspace_mount_path}')
  199. else:
  200. logger.debug(
  201. 'Mount dir is not set, will not mount the workspace directory to the container'
  202. )
  203. volumes = None
  204. self.log(
  205. 'debug',
  206. f'Sandbox workspace: {self.config.workspace_mount_path_in_sandbox}',
  207. )
  208. if self.config.sandbox.browsergym_eval_env is not None:
  209. browsergym_arg = (
  210. f'--browsergym-eval-env {self.config.sandbox.browsergym_eval_env}'
  211. )
  212. else:
  213. browsergym_arg = ''
  214. try:
  215. self.container = self.docker_client.containers.run(
  216. self.runtime_container_image,
  217. command=(
  218. f'/openhands/micromamba/bin/micromamba run -n openhands '
  219. f'poetry run '
  220. f'python -u -m openhands.runtime.action_execution_server {self._container_port} '
  221. f'--working-dir "{self.config.workspace_mount_path_in_sandbox}" '
  222. f'{plugin_arg}'
  223. f'--username {"openhands" if self.config.run_as_openhands else "root"} '
  224. f'--user-id {self.config.sandbox.user_id} '
  225. f'{browsergym_arg}'
  226. ),
  227. network_mode=network_mode,
  228. ports=port_mapping,
  229. working_dir='/openhands/code/', # do not change this!
  230. name=self.container_name,
  231. detach=True,
  232. environment=environment,
  233. volumes=volumes,
  234. )
  235. self.log('debug', f'Container started. Server url: {self.api_url}')
  236. self.send_status_message('STATUS$CONTAINER_STARTED')
  237. except docker.errors.APIError as e:
  238. if '409' in str(e):
  239. self.log(
  240. 'warning',
  241. f'Container {self.container_name} already exists. Removing...',
  242. )
  243. remove_all_containers(self.container_name)
  244. return self._init_container()
  245. else:
  246. self.log(
  247. 'error',
  248. f'Error: Instance {self.container_name} FAILED to start container!\n',
  249. )
  250. except Exception as e:
  251. self.log(
  252. 'error',
  253. f'Error: Instance {self.container_name} FAILED to start container!\n',
  254. )
  255. self.log('error', str(e))
  256. self.close()
  257. raise e
  258. def _attach_to_container(self):
  259. self._container_port = 0
  260. self.container = self.docker_client.containers.get(self.container_name)
  261. for port in self.container.attrs['NetworkSettings']['Ports']: # type: ignore
  262. self._container_port = int(port.split('/')[0])
  263. break
  264. self._host_port = self._container_port
  265. self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
  266. self.log(
  267. 'debug',
  268. f'attached to container: {self.container_name} {self._container_port} {self.api_url}',
  269. )
  270. @tenacity.retry(
  271. stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
  272. retry=tenacity.retry_if_exception_type(
  273. (ConnectionError, requests.exceptions.ConnectionError)
  274. ),
  275. reraise=True,
  276. wait=tenacity.wait_fixed(2),
  277. )
  278. def _wait_until_alive(self):
  279. try:
  280. container = self.docker_client.containers.get(self.container_name)
  281. if container.status == 'exited':
  282. raise AgentRuntimeDisconnectedError(
  283. f'Container {self.container_name} has exited.'
  284. )
  285. except docker.errors.NotFound:
  286. raise AgentRuntimeNotFoundError(
  287. f'Container {self.container_name} not found.'
  288. )
  289. if not self.log_streamer:
  290. raise AgentRuntimeNotReadyError('Runtime client is not ready.')
  291. self.check_if_alive()
  292. def close(self, rm_all_containers: bool | None = None):
  293. """Closes the DockerRuntime and associated objects
  294. Parameters:
  295. - rm_all_containers (bool): Whether to remove all containers with the 'openhands-sandbox-' prefix
  296. """
  297. super().close()
  298. if self.log_streamer:
  299. self.log_streamer.close()
  300. if rm_all_containers is None:
  301. rm_all_containers = self.config.sandbox.rm_all_containers
  302. if self.config.sandbox.keep_runtime_alive or self.attach_to_existing:
  303. return
  304. close_prefix = (
  305. CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
  306. )
  307. remove_all_containers(close_prefix)
  308. def _is_port_in_use_docker(self, port):
  309. containers = self.docker_client.containers.list()
  310. for container in containers:
  311. container_ports = container.ports
  312. if str(port) in str(container_ports):
  313. return True
  314. return False
  315. def _find_available_port(self, max_attempts=5):
  316. port = 39999
  317. for _ in range(max_attempts):
  318. port = find_available_tcp_port(30000, 39999)
  319. if not self._is_port_in_use_docker(port):
  320. return port
  321. # If no port is found after max_attempts, return the last tried port
  322. return port
  323. @property
  324. def vscode_url(self) -> str | None:
  325. token = super().get_vscode_token()
  326. if not token:
  327. return None
  328. vscode_host = os.environ.get('VSCODE_HOST', "localhost")
  329. vscode_url = f'http://{vscode_host}:{self._host_port + 1}/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
  330. return vscode_url