| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619 |
- const Settings = require('@overleaf/settings')
- const logger = require('@overleaf/logger')
- const Docker = require('dockerode')
- const dockerode = new Docker()
- const crypto = require('crypto')
- const async = require('async')
- const LockManager = require('./DockerLockManager')
- const fs = require('fs')
- const Path = require('path')
- const _ = require('lodash')
- const ONE_HOUR_IN_MS = 60 * 60 * 1000
- logger.debug('using docker runner')
- function usingSiblingContainers() {
- return (
- Settings != null &&
- Settings.path != null &&
- Settings.path.sandboxedCompilesHostDir != null
- )
- }
- let containerMonitorTimeout
- let containerMonitorInterval
- const DockerRunner = {
- run(
- projectId,
- command,
- directory,
- image,
- timeout,
- environment,
- compileGroup,
- callback
- ) {
- if (usingSiblingContainers()) {
- const _newPath = Settings.path.sandboxedCompilesHostDir
- logger.debug(
- { path: _newPath },
- 'altering bind path for sibling containers'
- )
- // Server Pro, example:
- // '/var/lib/sharelatex/data/compiles/<project-id>'
- // ... becomes ...
- // '/opt/sharelatex_data/data/compiles/<project-id>'
- directory = Path.join(
- Settings.path.sandboxedCompilesHostDir,
- Path.basename(directory)
- )
- }
- const volumes = { [directory]: '/compile' }
- command = command.map(arg =>
- arg.toString().replace('$COMPILE_DIR', '/compile')
- )
- if (image == null) {
- image = Settings.clsi.docker.image
- }
- if (
- Settings.clsi.docker.allowedImages &&
- !Settings.clsi.docker.allowedImages.includes(image)
- ) {
- return callback(new Error('image not allowed'))
- }
- if (Settings.texliveImageNameOveride != null) {
- const img = image.split('/')
- image = `${Settings.texliveImageNameOveride}/${img[2]}`
- }
- const options = DockerRunner._getContainerOptions(
- command,
- image,
- volumes,
- timeout,
- environment,
- compileGroup
- )
- const fingerprint = DockerRunner._fingerprintContainer(options)
- const name = `project-${projectId}-${fingerprint}`
- options.name = name
- // logOptions = _.clone(options)
- // logOptions?.HostConfig?.SecurityOpt = "secomp used, removed in logging"
- logger.debug({ projectId }, 'running docker container')
- DockerRunner._runAndWaitForContainer(
- options,
- volumes,
- timeout,
- (error, output) => {
- if (error && error.statusCode === 500) {
- logger.debug(
- { err: error, projectId },
- 'error running container so destroying and retrying'
- )
- DockerRunner.destroyContainer(name, null, true, error => {
- if (error != null) {
- return callback(error)
- }
- DockerRunner._runAndWaitForContainer(
- options,
- volumes,
- timeout,
- callback
- )
- })
- } else {
- callback(error, output)
- }
- }
- )
- // pass back the container name to allow it to be killed
- return name
- },
- kill(containerId, callback) {
- logger.debug({ containerId }, 'sending kill signal to container')
- const container = dockerode.getContainer(containerId)
- container.kill(error => {
- if (
- error != null &&
- error.message != null &&
- error.message.match(/Cannot kill container .* is not running/)
- ) {
- logger.warn(
- { err: error, containerId },
- 'container not running, continuing'
- )
- error = null
- }
- if (error != null) {
- logger.error({ err: error, containerId }, 'error killing container')
- callback(error)
- } else {
- callback()
- }
- })
- },
- _runAndWaitForContainer(options, volumes, timeout, _callback) {
- const callback = _.once(_callback)
- const { name } = options
- let streamEnded = false
- let containerReturned = false
- let output = {}
- function callbackIfFinished() {
- if (streamEnded && containerReturned) {
- callback(null, output)
- }
- }
- function attachStreamHandler(error, _output) {
- if (error != null) {
- return callback(error)
- }
- output = _output
- streamEnded = true
- callbackIfFinished()
- }
- DockerRunner.startContainer(
- options,
- volumes,
- attachStreamHandler,
- (error, containerId) => {
- if (error != null) {
- return callback(error)
- }
- DockerRunner.waitForContainer(name, timeout, (error, exitCode) => {
- if (error != null) {
- return callback(error)
- }
- if (exitCode === 137) {
- // exit status from kill -9
- const err = new Error('terminated')
- err.terminated = true
- return callback(err)
- }
- if (exitCode === 1) {
- // exit status from chktex
- const err = new Error('exited')
- err.code = exitCode
- return callback(err)
- }
- containerReturned = true
- if (options != null && options.HostConfig != null) {
- options.HostConfig.SecurityOpt = null
- }
- logger.debug({ exitCode, options }, 'docker container has exited')
- callbackIfFinished()
- })
- }
- )
- },
- _getContainerOptions(
- command,
- image,
- volumes,
- timeout,
- environment,
- compileGroup
- ) {
- const timeoutInSeconds = timeout / 1000
- const dockerVolumes = {}
- for (const hostVol in volumes) {
- const dockerVol = volumes[hostVol]
- dockerVolumes[dockerVol] = {}
- if (volumes[hostVol].slice(-3).indexOf(':r') === -1) {
- volumes[hostVol] = `${dockerVol}:rw`
- }
- }
- // merge settings and environment parameter
- const env = {}
- for (const src of [Settings.clsi.docker.env, environment || {}]) {
- for (const key in src) {
- const value = src[key]
- env[key] = value
- }
- }
- // set the path based on the image year
- const match = image.match(/:([0-9]+)\.[0-9]+/)
- const year = match ? match[1] : '2014'
- env.PATH = `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/texlive/${year}/bin/x86_64-linux/`
- const options = {
- Cmd: command,
- Image: image,
- Volumes: dockerVolumes,
- WorkingDir: '/compile',
- NetworkDisabled: true,
- Memory: 1024 * 1024 * 1024 * 1024, // 1 Gb
- User: Settings.clsi.docker.user,
- Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
- HostConfig: {
- Binds: Object.entries(volumes).map(
- ([hostVol, dockerVol]) => `${hostVol}:${dockerVol}`
- ),
- LogConfig: { Type: 'none', Config: {} },
- Ulimits: [
- {
- Name: 'cpu',
- Soft: timeoutInSeconds + 5,
- Hard: timeoutInSeconds + 10,
- },
- ],
- CapDrop: 'ALL',
- SecurityOpt: ['no-new-privileges'],
- },
- }
- if (Settings.clsi.docker.seccomp_profile != null) {
- options.HostConfig.SecurityOpt.push(
- `seccomp=${Settings.clsi.docker.seccomp_profile}`
- )
- }
- if (Settings.clsi.docker.apparmor_profile != null) {
- options.HostConfig.SecurityOpt.push(
- `apparmor=${Settings.clsi.docker.apparmor_profile}`
- )
- }
- if (Settings.clsi.docker.runtime) {
- options.HostConfig.Runtime = Settings.clsi.docker.runtime
- }
- if (Settings.clsi.docker.Readonly) {
- options.HostConfig.ReadonlyRootfs = true
- options.HostConfig.Tmpfs = { '/tmp': 'rw,noexec,nosuid,size=65536k' }
- options.Volumes['/home/tex'] = {}
- }
- // Allow per-compile group overriding of individual settings
- if (
- Settings.clsi.docker.compileGroupConfig &&
- Settings.clsi.docker.compileGroupConfig[compileGroup]
- ) {
- const override = Settings.clsi.docker.compileGroupConfig[compileGroup]
- for (const key in override) {
- _.set(options, key, override[key])
- }
- }
- return options
- },
- _fingerprintContainer(containerOptions) {
- // Yay, Hashing!
- const json = JSON.stringify(containerOptions)
- return crypto.createHash('md5').update(json).digest('hex')
- },
- startContainer(options, volumes, attachStreamHandler, callback) {
- LockManager.runWithLock(
- options.name,
- releaseLock =>
- // Check that volumes exist before starting the container.
- // When a container is started with volume pointing to a
- // non-existent directory then docker creates the directory but
- // with root ownership.
- DockerRunner._checkVolumes(options, volumes, err => {
- if (err != null) {
- return releaseLock(err)
- }
- DockerRunner._startContainer(
- options,
- volumes,
- attachStreamHandler,
- releaseLock
- )
- }),
- callback
- )
- },
- // Check that volumes exist and are directories
- _checkVolumes(options, volumes, callback) {
- if (usingSiblingContainers()) {
- // Server Pro, with sibling-containers active, skip checks
- return callback(null)
- }
- const checkVolume = (path, cb) =>
- fs.stat(path, (err, stats) => {
- if (err != null) {
- return cb(err)
- }
- if (!stats.isDirectory()) {
- return cb(new Error('not a directory'))
- }
- cb()
- })
- const jobs = []
- for (const vol in volumes) {
- jobs.push(cb => checkVolume(vol, cb))
- }
- async.series(jobs, callback)
- },
- _startContainer(options, volumes, attachStreamHandler, callback) {
- callback = _.once(callback)
- const { name } = options
- logger.debug({ container_name: name }, 'starting container')
- const container = dockerode.getContainer(name)
- function createAndStartContainer() {
- dockerode.createContainer(options, (error, container) => {
- if (error != null) {
- return callback(error)
- }
- startExistingContainer()
- })
- }
- function startExistingContainer() {
- DockerRunner.attachToContainer(
- options.name,
- attachStreamHandler,
- error => {
- if (error != null) {
- return callback(error)
- }
- container.start(error => {
- if (error != null && error.statusCode !== 304) {
- callback(error)
- } else {
- // already running
- callback()
- }
- })
- }
- )
- }
- container.inspect((error, stats) => {
- if (error != null && error.statusCode === 404) {
- createAndStartContainer()
- } else if (error != null) {
- logger.err(
- { container_name: name, error },
- 'unable to inspect container to start'
- )
- callback(error)
- } else {
- startExistingContainer()
- }
- })
- },
- attachToContainer(containerId, attachStreamHandler, attachStartCallback) {
- const container = dockerode.getContainer(containerId)
- container.attach({ stdout: 1, stderr: 1, stream: 1 }, (error, stream) => {
- if (error != null) {
- logger.error(
- { err: error, containerId },
- 'error attaching to container'
- )
- return attachStartCallback(error)
- } else {
- attachStartCallback()
- }
- logger.debug({ containerId }, 'attached to container')
- const MAX_OUTPUT = 1024 * 1024 // limit output to 1MB
- function createStringOutputStream(name) {
- return {
- data: '',
- overflowed: false,
- write(data) {
- if (this.overflowed) {
- return
- }
- if (this.data.length < MAX_OUTPUT) {
- this.data += data
- } else {
- logger.error(
- {
- containerId,
- length: this.data.length,
- maxLen: MAX_OUTPUT,
- },
- `${name} exceeds max size`
- )
- this.data += `(...truncated at ${MAX_OUTPUT} chars...)`
- this.overflowed = true
- }
- },
- // kill container if too much output
- // docker.containers.kill(containerId, () ->)
- }
- }
- const stdout = createStringOutputStream('stdout')
- const stderr = createStringOutputStream('stderr')
- container.modem.demuxStream(stream, stdout, stderr)
- stream.on('error', err =>
- logger.error(
- { err, containerId },
- 'error reading from container stream'
- )
- )
- stream.on('end', () =>
- attachStreamHandler(null, { stdout: stdout.data, stderr: stderr.data })
- )
- })
- },
- waitForContainer(containerId, timeout, _callback) {
- const callback = _.once(_callback)
- const container = dockerode.getContainer(containerId)
- let timedOut = false
- const timeoutId = setTimeout(() => {
- timedOut = true
- logger.debug({ containerId }, 'timeout reached, killing container')
- container.kill(err => {
- logger.warn({ err, containerId }, 'failed to kill container')
- })
- }, timeout)
- logger.debug({ containerId }, 'waiting for docker container')
- container.wait((error, res) => {
- if (error != null) {
- clearTimeout(timeoutId)
- logger.error({ err: error, containerId }, 'error waiting for container')
- return callback(error)
- }
- if (timedOut) {
- logger.debug({ containerId }, 'docker container timed out')
- error = new Error('container timed out')
- error.timedout = true
- callback(error)
- } else {
- clearTimeout(timeoutId)
- logger.debug(
- { containerId, exitCode: res.StatusCode },
- 'docker container returned'
- )
- callback(null, res.StatusCode)
- }
- })
- },
- destroyContainer(containerName, containerId, shouldForce, callback) {
- // We want the containerName for the lock and, ideally, the
- // containerId to delete. There is a bug in the docker.io module
- // where if you delete by name and there is an error, it throws an
- // async exception, but if you delete by id it just does a normal
- // error callback. We fall back to deleting by name if no id is
- // supplied.
- LockManager.runWithLock(
- containerName,
- releaseLock =>
- DockerRunner._destroyContainer(
- containerId || containerName,
- shouldForce,
- releaseLock
- ),
- callback
- )
- },
- _destroyContainer(containerId, shouldForce, callback) {
- logger.debug({ containerId }, 'destroying docker container')
- const container = dockerode.getContainer(containerId)
- container.remove({ force: shouldForce === true, v: true }, error => {
- if (error != null && error.statusCode === 404) {
- logger.warn(
- { err: error, containerId },
- 'container not found, continuing'
- )
- error = null
- }
- if (error != null) {
- logger.error({ err: error, containerId }, 'error destroying container')
- } else {
- logger.debug({ containerId }, 'destroyed container')
- }
- callback(error)
- })
- },
- // handle expiry of docker containers
- MAX_CONTAINER_AGE: Settings.clsi.docker.maxContainerAge || ONE_HOUR_IN_MS,
- examineOldContainer(container, callback) {
- const name = container.Name || (container.Names && container.Names[0])
- const created = container.Created * 1000 // creation time is returned in seconds
- const now = Date.now()
- const age = now - created
- const maxAge = DockerRunner.MAX_CONTAINER_AGE
- const ttl = maxAge - age
- logger.debug(
- { containerName: name, created, now, age, maxAge, ttl },
- 'checking whether to destroy container'
- )
- return { name, id: container.Id, ttl }
- },
- destroyOldContainers(callback) {
- dockerode.listContainers({ all: true }, (error, containers) => {
- if (error != null) {
- return callback(error)
- }
- const jobs = []
- for (const container of containers) {
- const { name, id, ttl } = DockerRunner.examineOldContainer(container)
- if (name.slice(0, 9) === '/project-' && ttl <= 0) {
- // strip the / prefix
- // the LockManager uses the plain container name
- const plainName = name.slice(1)
- jobs.push(cb =>
- DockerRunner.destroyContainer(plainName, id, false, () => cb())
- )
- }
- }
- // Ignore errors because some containers get stuck but
- // will be destroyed next time
- async.series(jobs, callback)
- })
- },
- startContainerMonitor() {
- logger.debug(
- { maxAge: DockerRunner.MAX_CONTAINER_AGE },
- 'starting container expiry'
- )
- // guarantee only one monitor is running
- DockerRunner.stopContainerMonitor()
- // randomise the start time
- const randomDelay = Math.floor(Math.random() * 5 * 60 * 1000)
- containerMonitorTimeout = setTimeout(() => {
- containerMonitorInterval = setInterval(
- () =>
- DockerRunner.destroyOldContainers(err => {
- if (err) {
- logger.error({ err }, 'failed to destroy old containers')
- }
- }),
- ONE_HOUR_IN_MS
- )
- }, randomDelay)
- },
- stopContainerMonitor() {
- if (containerMonitorTimeout) {
- clearTimeout(containerMonitorTimeout)
- containerMonitorTimeout = undefined
- }
- if (containerMonitorInterval) {
- clearInterval(containerMonitorInterval)
- containerMonitorInterval = undefined
- }
- },
- }
- DockerRunner.startContainerMonitor()
- module.exports = DockerRunner
|