DockerRunner.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  1. const Settings = require('@overleaf/settings')
  2. const logger = require('@overleaf/logger')
  3. const Docker = require('dockerode')
  4. const dockerode = new Docker()
  5. const crypto = require('crypto')
  6. const async = require('async')
  7. const LockManager = require('./DockerLockManager')
  8. const fs = require('fs')
  9. const Path = require('path')
  10. const _ = require('lodash')
  11. const ONE_HOUR_IN_MS = 60 * 60 * 1000
  12. logger.debug('using docker runner')
  13. function usingSiblingContainers() {
  14. return (
  15. Settings != null &&
  16. Settings.path != null &&
  17. Settings.path.sandboxedCompilesHostDir != null
  18. )
  19. }
  20. let containerMonitorTimeout
  21. let containerMonitorInterval
  22. const DockerRunner = {
  23. run(
  24. projectId,
  25. command,
  26. directory,
  27. image,
  28. timeout,
  29. environment,
  30. compileGroup,
  31. callback
  32. ) {
  33. if (usingSiblingContainers()) {
  34. const _newPath = Settings.path.sandboxedCompilesHostDir
  35. logger.debug(
  36. { path: _newPath },
  37. 'altering bind path for sibling containers'
  38. )
  39. // Server Pro, example:
  40. // '/var/lib/sharelatex/data/compiles/<project-id>'
  41. // ... becomes ...
  42. // '/opt/sharelatex_data/data/compiles/<project-id>'
  43. directory = Path.join(
  44. Settings.path.sandboxedCompilesHostDir,
  45. Path.basename(directory)
  46. )
  47. }
  48. const volumes = { [directory]: '/compile' }
  49. command = command.map(arg =>
  50. arg.toString().replace('$COMPILE_DIR', '/compile')
  51. )
  52. if (image == null) {
  53. image = Settings.clsi.docker.image
  54. }
  55. if (
  56. Settings.clsi.docker.allowedImages &&
  57. !Settings.clsi.docker.allowedImages.includes(image)
  58. ) {
  59. return callback(new Error('image not allowed'))
  60. }
  61. if (Settings.texliveImageNameOveride != null) {
  62. const img = image.split('/')
  63. image = `${Settings.texliveImageNameOveride}/${img[2]}`
  64. }
  65. const options = DockerRunner._getContainerOptions(
  66. command,
  67. image,
  68. volumes,
  69. timeout,
  70. environment,
  71. compileGroup
  72. )
  73. const fingerprint = DockerRunner._fingerprintContainer(options)
  74. const name = `project-${projectId}-${fingerprint}`
  75. options.name = name
  76. // logOptions = _.clone(options)
  77. // logOptions?.HostConfig?.SecurityOpt = "secomp used, removed in logging"
  78. logger.debug({ projectId }, 'running docker container')
  79. DockerRunner._runAndWaitForContainer(
  80. options,
  81. volumes,
  82. timeout,
  83. (error, output) => {
  84. if (error && error.statusCode === 500) {
  85. logger.debug(
  86. { err: error, projectId },
  87. 'error running container so destroying and retrying'
  88. )
  89. DockerRunner.destroyContainer(name, null, true, error => {
  90. if (error != null) {
  91. return callback(error)
  92. }
  93. DockerRunner._runAndWaitForContainer(
  94. options,
  95. volumes,
  96. timeout,
  97. callback
  98. )
  99. })
  100. } else {
  101. callback(error, output)
  102. }
  103. }
  104. )
  105. // pass back the container name to allow it to be killed
  106. return name
  107. },
  108. kill(containerId, callback) {
  109. logger.debug({ containerId }, 'sending kill signal to container')
  110. const container = dockerode.getContainer(containerId)
  111. container.kill(error => {
  112. if (
  113. error != null &&
  114. error.message != null &&
  115. error.message.match(/Cannot kill container .* is not running/)
  116. ) {
  117. logger.warn(
  118. { err: error, containerId },
  119. 'container not running, continuing'
  120. )
  121. error = null
  122. }
  123. if (error != null) {
  124. logger.error({ err: error, containerId }, 'error killing container')
  125. callback(error)
  126. } else {
  127. callback()
  128. }
  129. })
  130. },
  131. _runAndWaitForContainer(options, volumes, timeout, _callback) {
  132. const callback = _.once(_callback)
  133. const { name } = options
  134. let streamEnded = false
  135. let containerReturned = false
  136. let output = {}
  137. function callbackIfFinished() {
  138. if (streamEnded && containerReturned) {
  139. callback(null, output)
  140. }
  141. }
  142. function attachStreamHandler(error, _output) {
  143. if (error != null) {
  144. return callback(error)
  145. }
  146. output = _output
  147. streamEnded = true
  148. callbackIfFinished()
  149. }
  150. DockerRunner.startContainer(
  151. options,
  152. volumes,
  153. attachStreamHandler,
  154. (error, containerId) => {
  155. if (error != null) {
  156. return callback(error)
  157. }
  158. DockerRunner.waitForContainer(name, timeout, (error, exitCode) => {
  159. if (error != null) {
  160. return callback(error)
  161. }
  162. if (exitCode === 137) {
  163. // exit status from kill -9
  164. const err = new Error('terminated')
  165. err.terminated = true
  166. return callback(err)
  167. }
  168. if (exitCode === 1) {
  169. // exit status from chktex
  170. const err = new Error('exited')
  171. err.code = exitCode
  172. return callback(err)
  173. }
  174. containerReturned = true
  175. if (options != null && options.HostConfig != null) {
  176. options.HostConfig.SecurityOpt = null
  177. }
  178. logger.debug({ exitCode, options }, 'docker container has exited')
  179. callbackIfFinished()
  180. })
  181. }
  182. )
  183. },
  184. _getContainerOptions(
  185. command,
  186. image,
  187. volumes,
  188. timeout,
  189. environment,
  190. compileGroup
  191. ) {
  192. const timeoutInSeconds = timeout / 1000
  193. const dockerVolumes = {}
  194. for (const hostVol in volumes) {
  195. const dockerVol = volumes[hostVol]
  196. dockerVolumes[dockerVol] = {}
  197. if (volumes[hostVol].slice(-3).indexOf(':r') === -1) {
  198. volumes[hostVol] = `${dockerVol}:rw`
  199. }
  200. }
  201. // merge settings and environment parameter
  202. const env = {}
  203. for (const src of [Settings.clsi.docker.env, environment || {}]) {
  204. for (const key in src) {
  205. const value = src[key]
  206. env[key] = value
  207. }
  208. }
  209. // set the path based on the image year
  210. const match = image.match(/:([0-9]+)\.[0-9]+/)
  211. const year = match ? match[1] : '2014'
  212. env.PATH = `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/texlive/${year}/bin/x86_64-linux/`
  213. const options = {
  214. Cmd: command,
  215. Image: image,
  216. Volumes: dockerVolumes,
  217. WorkingDir: '/compile',
  218. NetworkDisabled: true,
  219. Memory: 1024 * 1024 * 1024 * 1024, // 1 Gb
  220. User: Settings.clsi.docker.user,
  221. Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
  222. HostConfig: {
  223. Binds: Object.entries(volumes).map(
  224. ([hostVol, dockerVol]) => `${hostVol}:${dockerVol}`
  225. ),
  226. LogConfig: { Type: 'none', Config: {} },
  227. Ulimits: [
  228. {
  229. Name: 'cpu',
  230. Soft: timeoutInSeconds + 5,
  231. Hard: timeoutInSeconds + 10,
  232. },
  233. ],
  234. CapDrop: 'ALL',
  235. SecurityOpt: ['no-new-privileges'],
  236. },
  237. }
  238. if (Settings.clsi.docker.seccomp_profile != null) {
  239. options.HostConfig.SecurityOpt.push(
  240. `seccomp=${Settings.clsi.docker.seccomp_profile}`
  241. )
  242. }
  243. if (Settings.clsi.docker.apparmor_profile != null) {
  244. options.HostConfig.SecurityOpt.push(
  245. `apparmor=${Settings.clsi.docker.apparmor_profile}`
  246. )
  247. }
  248. if (Settings.clsi.docker.runtime) {
  249. options.HostConfig.Runtime = Settings.clsi.docker.runtime
  250. }
  251. if (Settings.clsi.docker.Readonly) {
  252. options.HostConfig.ReadonlyRootfs = true
  253. options.HostConfig.Tmpfs = { '/tmp': 'rw,noexec,nosuid,size=65536k' }
  254. options.Volumes['/home/tex'] = {}
  255. }
  256. // Allow per-compile group overriding of individual settings
  257. if (
  258. Settings.clsi.docker.compileGroupConfig &&
  259. Settings.clsi.docker.compileGroupConfig[compileGroup]
  260. ) {
  261. const override = Settings.clsi.docker.compileGroupConfig[compileGroup]
  262. for (const key in override) {
  263. _.set(options, key, override[key])
  264. }
  265. }
  266. return options
  267. },
  268. _fingerprintContainer(containerOptions) {
  269. // Yay, Hashing!
  270. const json = JSON.stringify(containerOptions)
  271. return crypto.createHash('md5').update(json).digest('hex')
  272. },
  273. startContainer(options, volumes, attachStreamHandler, callback) {
  274. LockManager.runWithLock(
  275. options.name,
  276. releaseLock =>
  277. // Check that volumes exist before starting the container.
  278. // When a container is started with volume pointing to a
  279. // non-existent directory then docker creates the directory but
  280. // with root ownership.
  281. DockerRunner._checkVolumes(options, volumes, err => {
  282. if (err != null) {
  283. return releaseLock(err)
  284. }
  285. DockerRunner._startContainer(
  286. options,
  287. volumes,
  288. attachStreamHandler,
  289. releaseLock
  290. )
  291. }),
  292. callback
  293. )
  294. },
  295. // Check that volumes exist and are directories
  296. _checkVolumes(options, volumes, callback) {
  297. if (usingSiblingContainers()) {
  298. // Server Pro, with sibling-containers active, skip checks
  299. return callback(null)
  300. }
  301. const checkVolume = (path, cb) =>
  302. fs.stat(path, (err, stats) => {
  303. if (err != null) {
  304. return cb(err)
  305. }
  306. if (!stats.isDirectory()) {
  307. return cb(new Error('not a directory'))
  308. }
  309. cb()
  310. })
  311. const jobs = []
  312. for (const vol in volumes) {
  313. jobs.push(cb => checkVolume(vol, cb))
  314. }
  315. async.series(jobs, callback)
  316. },
  317. _startContainer(options, volumes, attachStreamHandler, callback) {
  318. callback = _.once(callback)
  319. const { name } = options
  320. logger.debug({ container_name: name }, 'starting container')
  321. const container = dockerode.getContainer(name)
  322. function createAndStartContainer() {
  323. dockerode.createContainer(options, (error, container) => {
  324. if (error != null) {
  325. return callback(error)
  326. }
  327. startExistingContainer()
  328. })
  329. }
  330. function startExistingContainer() {
  331. DockerRunner.attachToContainer(
  332. options.name,
  333. attachStreamHandler,
  334. error => {
  335. if (error != null) {
  336. return callback(error)
  337. }
  338. container.start(error => {
  339. if (error != null && error.statusCode !== 304) {
  340. callback(error)
  341. } else {
  342. // already running
  343. callback()
  344. }
  345. })
  346. }
  347. )
  348. }
  349. container.inspect((error, stats) => {
  350. if (error != null && error.statusCode === 404) {
  351. createAndStartContainer()
  352. } else if (error != null) {
  353. logger.err(
  354. { container_name: name, error },
  355. 'unable to inspect container to start'
  356. )
  357. callback(error)
  358. } else {
  359. startExistingContainer()
  360. }
  361. })
  362. },
  363. attachToContainer(containerId, attachStreamHandler, attachStartCallback) {
  364. const container = dockerode.getContainer(containerId)
  365. container.attach({ stdout: 1, stderr: 1, stream: 1 }, (error, stream) => {
  366. if (error != null) {
  367. logger.error(
  368. { err: error, containerId },
  369. 'error attaching to container'
  370. )
  371. return attachStartCallback(error)
  372. } else {
  373. attachStartCallback()
  374. }
  375. logger.debug({ containerId }, 'attached to container')
  376. const MAX_OUTPUT = 1024 * 1024 // limit output to 1MB
  377. function createStringOutputStream(name) {
  378. return {
  379. data: '',
  380. overflowed: false,
  381. write(data) {
  382. if (this.overflowed) {
  383. return
  384. }
  385. if (this.data.length < MAX_OUTPUT) {
  386. this.data += data
  387. } else {
  388. logger.error(
  389. {
  390. containerId,
  391. length: this.data.length,
  392. maxLen: MAX_OUTPUT,
  393. },
  394. `${name} exceeds max size`
  395. )
  396. this.data += `(...truncated at ${MAX_OUTPUT} chars...)`
  397. this.overflowed = true
  398. }
  399. },
  400. // kill container if too much output
  401. // docker.containers.kill(containerId, () ->)
  402. }
  403. }
  404. const stdout = createStringOutputStream('stdout')
  405. const stderr = createStringOutputStream('stderr')
  406. container.modem.demuxStream(stream, stdout, stderr)
  407. stream.on('error', err =>
  408. logger.error(
  409. { err, containerId },
  410. 'error reading from container stream'
  411. )
  412. )
  413. stream.on('end', () =>
  414. attachStreamHandler(null, { stdout: stdout.data, stderr: stderr.data })
  415. )
  416. })
  417. },
  418. waitForContainer(containerId, timeout, _callback) {
  419. const callback = _.once(_callback)
  420. const container = dockerode.getContainer(containerId)
  421. let timedOut = false
  422. const timeoutId = setTimeout(() => {
  423. timedOut = true
  424. logger.debug({ containerId }, 'timeout reached, killing container')
  425. container.kill(err => {
  426. logger.warn({ err, containerId }, 'failed to kill container')
  427. })
  428. }, timeout)
  429. logger.debug({ containerId }, 'waiting for docker container')
  430. container.wait((error, res) => {
  431. if (error != null) {
  432. clearTimeout(timeoutId)
  433. logger.error({ err: error, containerId }, 'error waiting for container')
  434. return callback(error)
  435. }
  436. if (timedOut) {
  437. logger.debug({ containerId }, 'docker container timed out')
  438. error = new Error('container timed out')
  439. error.timedout = true
  440. callback(error)
  441. } else {
  442. clearTimeout(timeoutId)
  443. logger.debug(
  444. { containerId, exitCode: res.StatusCode },
  445. 'docker container returned'
  446. )
  447. callback(null, res.StatusCode)
  448. }
  449. })
  450. },
  451. destroyContainer(containerName, containerId, shouldForce, callback) {
  452. // We want the containerName for the lock and, ideally, the
  453. // containerId to delete. There is a bug in the docker.io module
  454. // where if you delete by name and there is an error, it throws an
  455. // async exception, but if you delete by id it just does a normal
  456. // error callback. We fall back to deleting by name if no id is
  457. // supplied.
  458. LockManager.runWithLock(
  459. containerName,
  460. releaseLock =>
  461. DockerRunner._destroyContainer(
  462. containerId || containerName,
  463. shouldForce,
  464. releaseLock
  465. ),
  466. callback
  467. )
  468. },
  469. _destroyContainer(containerId, shouldForce, callback) {
  470. logger.debug({ containerId }, 'destroying docker container')
  471. const container = dockerode.getContainer(containerId)
  472. container.remove({ force: shouldForce === true, v: true }, error => {
  473. if (error != null && error.statusCode === 404) {
  474. logger.warn(
  475. { err: error, containerId },
  476. 'container not found, continuing'
  477. )
  478. error = null
  479. }
  480. if (error != null) {
  481. logger.error({ err: error, containerId }, 'error destroying container')
  482. } else {
  483. logger.debug({ containerId }, 'destroyed container')
  484. }
  485. callback(error)
  486. })
  487. },
  488. // handle expiry of docker containers
  489. MAX_CONTAINER_AGE: Settings.clsi.docker.maxContainerAge || ONE_HOUR_IN_MS,
  490. examineOldContainer(container, callback) {
  491. const name = container.Name || (container.Names && container.Names[0])
  492. const created = container.Created * 1000 // creation time is returned in seconds
  493. const now = Date.now()
  494. const age = now - created
  495. const maxAge = DockerRunner.MAX_CONTAINER_AGE
  496. const ttl = maxAge - age
  497. logger.debug(
  498. { containerName: name, created, now, age, maxAge, ttl },
  499. 'checking whether to destroy container'
  500. )
  501. return { name, id: container.Id, ttl }
  502. },
  503. destroyOldContainers(callback) {
  504. dockerode.listContainers({ all: true }, (error, containers) => {
  505. if (error != null) {
  506. return callback(error)
  507. }
  508. const jobs = []
  509. for (const container of containers) {
  510. const { name, id, ttl } = DockerRunner.examineOldContainer(container)
  511. if (name.slice(0, 9) === '/project-' && ttl <= 0) {
  512. // strip the / prefix
  513. // the LockManager uses the plain container name
  514. const plainName = name.slice(1)
  515. jobs.push(cb =>
  516. DockerRunner.destroyContainer(plainName, id, false, () => cb())
  517. )
  518. }
  519. }
  520. // Ignore errors because some containers get stuck but
  521. // will be destroyed next time
  522. async.series(jobs, callback)
  523. })
  524. },
  525. startContainerMonitor() {
  526. logger.debug(
  527. { maxAge: DockerRunner.MAX_CONTAINER_AGE },
  528. 'starting container expiry'
  529. )
  530. // guarantee only one monitor is running
  531. DockerRunner.stopContainerMonitor()
  532. // randomise the start time
  533. const randomDelay = Math.floor(Math.random() * 5 * 60 * 1000)
  534. containerMonitorTimeout = setTimeout(() => {
  535. containerMonitorInterval = setInterval(
  536. () =>
  537. DockerRunner.destroyOldContainers(err => {
  538. if (err) {
  539. logger.error({ err }, 'failed to destroy old containers')
  540. }
  541. }),
  542. ONE_HOUR_IN_MS
  543. )
  544. }, randomDelay)
  545. },
  546. stopContainerMonitor() {
  547. if (containerMonitorTimeout) {
  548. clearTimeout(containerMonitorTimeout)
  549. containerMonitorTimeout = undefined
  550. }
  551. if (containerMonitorInterval) {
  552. clearInterval(containerMonitorInterval)
  553. containerMonitorInterval = undefined
  554. }
  555. },
  556. }
  557. DockerRunner.startContainerMonitor()
  558. module.exports = DockerRunner