DockerRunner.coffee 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. Settings = require "settings-sharelatex"
  2. logger = require "logger-sharelatex"
  3. Docker = require("dockerode")
  4. dockerode = new Docker()
  5. crypto = require "crypto"
  6. async = require "async"
  7. LockManager = require "./DockerLockManager"
  8. fs = require "fs"
  9. Path = require 'path'
  10. _ = require "underscore"
  11. logger.info "using docker runner"
  12. usingSiblingContainers = () ->
  13. Settings?.path?.sandboxedCompilesHostDir?
  14. module.exports = DockerRunner =
  15. ERR_NOT_DIRECTORY: new Error("not a directory")
  16. ERR_TERMINATED: new Error("terminated")
  17. ERR_EXITED: new Error("exited")
  18. ERR_TIMED_OUT: new Error("container timed out")
  19. run: (project_id, command, directory, image, timeout, environment, callback = (error, output) ->) ->
  20. if usingSiblingContainers()
  21. _newPath = Settings.path.sandboxedCompilesHostDir
  22. logger.log {path: _newPath}, "altering bind path for sibling containers"
  23. # Server Pro, example:
  24. # '/var/lib/sharelatex/data/compiles/<project-id>'
  25. # ... becomes ...
  26. # '/opt/sharelatex_data/data/compiles/<project-id>'
  27. directory = Path.join(Settings.path.sandboxedCompilesHostDir, Path.basename(directory))
  28. volumes = {}
  29. volumes[directory] = "/compile"
  30. command = (arg.toString().replace?('$COMPILE_DIR', "/compile") for arg in command)
  31. if !image?
  32. image = Settings.clsi.docker.image
  33. options = DockerRunner._getContainerOptions(command, image, volumes, timeout, environment)
  34. fingerprint = DockerRunner._fingerprintContainer(options)
  35. options.name = name = "project-#{project_id}-#{fingerprint}"
  36. logger.log project_id: project_id, options: options, "running docker container"
  37. DockerRunner._runAndWaitForContainer options, volumes, timeout, (error, output) ->
  38. if error?.message?.match("HTTP code is 500")
  39. logger.log err: error, project_id: project_id, "error running container so destroying and retrying"
  40. DockerRunner.destroyContainer name, null, true, (error) ->
  41. return callback(error) if error?
  42. DockerRunner._runAndWaitForContainer options, volumes, timeout, callback
  43. else
  44. callback(error, output)
  45. return name # pass back the container name to allow it to be killed
  46. kill: (container_id, callback = (error) ->) ->
  47. logger.log container_id: container_id, "sending kill signal to container"
  48. container = dockerode.getContainer(container_id)
  49. container.kill (error) ->
  50. if error? and error?.message?.match?(/Cannot kill container .* is not running/)
  51. logger.warn err: error, container_id: container_id, "container not running, continuing"
  52. error = null
  53. if error?
  54. logger.error err: error, container_id: container_id, "error killing container"
  55. return callback(error)
  56. else
  57. callback()
  58. _runAndWaitForContainer: (options, volumes, timeout, _callback = (error, output) ->) ->
  59. callback = (args...) ->
  60. _callback(args...)
  61. # Only call the callback once
  62. _callback = () ->
  63. name = options.name
  64. streamEnded = false
  65. containerReturned = false
  66. output = {}
  67. callbackIfFinished = () ->
  68. if streamEnded and containerReturned
  69. callback(null, output)
  70. attachStreamHandler = (error, _output) ->
  71. return callback(error) if error?
  72. output = _output
  73. streamEnded = true
  74. callbackIfFinished()
  75. DockerRunner.startContainer options, volumes, attachStreamHandler, (error, containerId) ->
  76. return callback(error) if error?
  77. DockerRunner.waitForContainer name, timeout, (error, exitCode) ->
  78. return callback(error) if error?
  79. if exitCode is 137 # exit status from kill -9
  80. err = DockerRunner.ERR_TERMINATED
  81. err.terminated = true
  82. return callback(err)
  83. if exitCode is 1 # exit status from chktex
  84. err = DockerRunner.ERR_EXITED
  85. err.code = exitCode
  86. return callback(err)
  87. containerReturned = true
  88. callbackIfFinished()
  89. _getContainerOptions: (command, image, volumes, timeout, environment) ->
  90. timeoutInSeconds = timeout / 1000
  91. # if Settings.path?.synctexBinHostPath?
  92. # volumes[Settings.path.synctexBinHostPath] = "/opt/synctex:ro"
  93. dockerVolumes = {}
  94. for hostVol, dockerVol of volumes
  95. dockerVolumes[dockerVol] = {}
  96. if volumes[hostVol].slice(-3).indexOf(":r") == -1
  97. volumes[hostVol] = "#{dockerVol}:rw"
  98. # merge settings and environment parameter
  99. env = {}
  100. for src in [Settings.clsi.docker.env, environment or {}]
  101. env[key] = value for key, value of src
  102. # set the path based on the image year
  103. if m = image.match /:([0-9]+)\.[0-9]+/
  104. year = m[1]
  105. else
  106. year = "2014"
  107. env['PATH'] = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/texlive/#{year}/bin/x86_64-linux/"
  108. options =
  109. "Cmd" : command,
  110. "Image" : image
  111. "Volumes" : dockerVolumes
  112. "WorkingDir" : "/compile"
  113. "NetworkDisabled" : true
  114. "Memory" : 1024 * 1024 * 1024 * 1024 # 1 Gb
  115. "User" : Settings.clsi.docker.user
  116. "Env" : ("#{key}=#{value}" for key, value of env) # convert the environment hash to an array
  117. "HostConfig" :
  118. "Binds": ("#{hostVol}:#{dockerVol}" for hostVol, dockerVol of volumes)
  119. "LogConfig": {"Type": "none", "Config": {}}
  120. "Ulimits": [{'Name': 'cpu', 'Soft': timeoutInSeconds+5, 'Hard': timeoutInSeconds+10}]
  121. "CapDrop": "ALL"
  122. "SecurityOpt": ["no-new-privileges"]
  123. if Settings.clsi.docker.seccomp_profile?
  124. options.HostConfig.SecurityOpt.push "seccomp=#{Settings.clsi.docker.seccomp_profile}"
  125. return options
  126. _fingerprintContainer: (containerOptions) ->
  127. # Yay, Hashing!
  128. json = JSON.stringify(containerOptions)
  129. return crypto.createHash("md5").update(json).digest("hex")
  130. startContainer: (options, volumes, attachStreamHandler, callback) ->
  131. LockManager.runWithLock options.name, (releaseLock) ->
  132. # Check that volumes exist before starting the container.
  133. # When a container is started with volume pointing to a
  134. # non-existent directory then docker creates the directory but
  135. # with root ownership.
  136. DockerRunner._checkVolumes options, volumes, (err) ->
  137. return releaseLock(err) if err?
  138. DockerRunner._startContainer options, volumes, attachStreamHandler, releaseLock
  139. , callback
  140. # Check that volumes exist and are directories
  141. _checkVolumes: (options, volumes, callback = (error, containerName) ->) ->
  142. if usingSiblingContainers()
  143. # Server Pro, with sibling-containers active, skip checks
  144. return callback(null)
  145. checkVolume = (path, cb) ->
  146. fs.stat path, (err, stats) ->
  147. return cb(err) if err?
  148. return cb(DockerRunner.ERR_NOT_DIRECTORY) if not stats?.isDirectory()
  149. cb()
  150. jobs = []
  151. for vol of volumes
  152. do (vol) ->
  153. jobs.push (cb) -> checkVolume(vol, cb)
  154. async.series jobs, callback
  155. _startContainer: (options, volumes, attachStreamHandler, callback = ((error, output) ->)) ->
  156. callback = _.once(callback)
  157. name = options.name
  158. logger.log {container_name: name}, "starting container"
  159. container = dockerode.getContainer(name)
  160. createAndStartContainer = ->
  161. dockerode.createContainer options, (error, container) ->
  162. return callback(error) if error?
  163. startExistingContainer()
  164. startExistingContainer = ->
  165. DockerRunner.attachToContainer options.name, attachStreamHandler, (error)->
  166. return callback(error) if error?
  167. container.start (error) ->
  168. if error? and error?.statusCode != 304 #already running
  169. return callback(error)
  170. else
  171. callback()
  172. container.inspect (error, stats)->
  173. if error?.statusCode == 404
  174. createAndStartContainer()
  175. else if error?
  176. logger.err {container_name: name}, "unable to inspect container to start"
  177. return callback(error)
  178. else
  179. startExistingContainer()
  180. attachToContainer: (containerId, attachStreamHandler, attachStartCallback) ->
  181. container = dockerode.getContainer(containerId)
  182. container.attach {stdout: 1, stderr: 1, stream: 1}, (error, stream) ->
  183. if error?
  184. logger.error err: error, container_id: containerId, "error attaching to container"
  185. return attachStartCallback(error)
  186. else
  187. attachStartCallback()
  188. logger.log container_id: containerId, "attached to container"
  189. MAX_OUTPUT = 1024 * 1024 # limit output to 1MB
  190. createStringOutputStream = (name) ->
  191. return {
  192. data: ""
  193. overflowed: false
  194. write: (data) ->
  195. return if @overflowed
  196. if @data.length < MAX_OUTPUT
  197. @data += data
  198. else
  199. logger.error container_id: containerId, length: @data.length, maxLen: MAX_OUTPUT, "#{name} exceeds max size"
  200. @data += "(...truncated at #{MAX_OUTPUT} chars...)"
  201. @overflowed = true
  202. # kill container if too much output
  203. # docker.containers.kill(containerId, () ->)
  204. }
  205. stdout = createStringOutputStream "stdout"
  206. stderr = createStringOutputStream "stderr"
  207. container.modem.demuxStream(stream, stdout, stderr)
  208. stream.on "error", (err) ->
  209. logger.error err: err, container_id: containerId, "error reading from container stream"
  210. stream.on "end", () ->
  211. attachStreamHandler null, {stdout: stdout.data, stderr: stderr.data}
  212. waitForContainer: (containerId, timeout, _callback = (error, exitCode) ->) ->
  213. callback = (args...) ->
  214. _callback(args...)
  215. # Only call the callback once
  216. _callback = () ->
  217. container = dockerode.getContainer(containerId)
  218. timedOut = false
  219. timeoutId = setTimeout () ->
  220. timedOut = true
  221. logger.log container_id: containerId, "timeout reached, killing container"
  222. container.kill(() ->)
  223. , timeout
  224. logger.log container_id: containerId, "waiting for docker container"
  225. container.wait (error, res) ->
  226. if error?
  227. clearTimeout timeoutId
  228. logger.error err: error, container_id: containerId, "error waiting for container"
  229. return callback(error)
  230. if timedOut
  231. logger.log containerId: containerId, "docker container timed out"
  232. error = DockerRunner.ERR_TIMED_OUT
  233. error.timedout = true
  234. callback error
  235. else
  236. clearTimeout timeoutId
  237. logger.log container_id: containerId, exitCode: res.StatusCode, "docker container returned"
  238. callback null, res.StatusCode
  239. destroyContainer: (containerName, containerId, shouldForce, callback = (error) ->) ->
  240. # We want the containerName for the lock and, ideally, the
  241. # containerId to delete. There is a bug in the docker.io module
  242. # where if you delete by name and there is an error, it throws an
  243. # async exception, but if you delete by id it just does a normal
  244. # error callback. We fall back to deleting by name if no id is
  245. # supplied.
  246. LockManager.runWithLock containerName, (releaseLock) ->
  247. DockerRunner._destroyContainer containerId or containerName, shouldForce, releaseLock
  248. , callback
  249. _destroyContainer: (containerId, shouldForce, callback = (error) ->) ->
  250. logger.log container_id: containerId, "destroying docker container"
  251. container = dockerode.getContainer(containerId)
  252. container.remove {force: shouldForce == true}, (error) ->
  253. if error? and error?.statusCode == 404
  254. logger.warn err: error, container_id: containerId, "container not found, continuing"
  255. error = null
  256. if error?
  257. logger.error err: error, container_id: containerId, "error destroying container"
  258. else
  259. logger.log container_id: containerId, "destroyed container"
  260. callback(error)
  261. # handle expiry of docker containers
  262. MAX_CONTAINER_AGE: Settings.clsi.docker.maxContainerAge or oneHour = 60 * 60 * 1000
  263. examineOldContainer: (container, callback = (error, name, id, ttl)->) ->
  264. name = container.Name or container.Names?[0]
  265. created = container.Created * 1000 # creation time is returned in seconds
  266. now = Date.now()
  267. age = now - created
  268. maxAge = DockerRunner.MAX_CONTAINER_AGE
  269. ttl = maxAge - age
  270. logger.log {containerName: name, created: created, now: now, age: age, maxAge: maxAge, ttl: ttl}, "checking whether to destroy container"
  271. callback(null, name, container.Id, ttl)
  272. destroyOldContainers: (callback = (error) ->) ->
  273. dockerode.listContainers all: true, (error, containers) ->
  274. return callback(error) if error?
  275. jobs = []
  276. for container in containers or []
  277. do (container) ->
  278. DockerRunner.examineOldContainer container, (err, name, id, ttl) ->
  279. if name.slice(0, 9) == '/project-' && ttl <= 0
  280. jobs.push (cb) ->
  281. DockerRunner.destroyContainer name, id, false, () -> cb()
  282. # Ignore errors because some containers get stuck but
  283. # will be destroyed next time
  284. async.series jobs, callback
  285. startContainerMonitor: () ->
  286. logger.log {maxAge: DockerRunner.MAX_CONTAINER_AGE}, "starting container expiry"
  287. # randomise the start time
  288. randomDelay = Math.floor(Math.random() * 5 * 60 * 1000)
  289. setTimeout () ->
  290. setInterval () ->
  291. DockerRunner.destroyOldContainers()
  292. , oneHour = 60 * 60 * 1000
  293. , randomDelay
  294. DockerRunner.startContainerMonitor()