UNPKG

serverless-offline-reasint

Version:

Emulate AWS λ and API Gateway locally when developing your Serverless project

407 lines (327 loc) 10.3 kB
import { createHash } from "node:crypto" import { createWriteStream } from "node:fs" import { readFile, unlink, writeFile } from "node:fs/promises" import { platform } from "node:os" import { dirname, join, sep } from "node:path" import { LambdaClient, GetLayerVersionCommand } from "@aws-sdk/client-lambda" import { log, progress } from "@serverless/utils/log.js" import { execa } from "execa" import { ensureDir, pathExists } from "fs-extra" import isWsl from "is-wsl" import jszip from "jszip" import pRetry from "p-retry" import DockerImage from "./DockerImage.js" const { stringify } = JSON const { floor, log: mathLog } = Math const { parseFloat } = Number const { entries, hasOwn } = Object export default class DockerContainer { #containerId = null #dockerOptions = null #env = null #functionKey = null #handler = null #image = null #imageNameTag = null #lambdaClient = null #layers = null #port = null #provider = null #runtime = null #servicePath = null constructor( env, functionKey, handler, runtime, layers, provider, servicePath, dockerOptions, ) { this.#dockerOptions = dockerOptions this.#env = env this.#functionKey = functionKey this.#handler = handler this.#imageNameTag = this.#baseImage(runtime) this.#image = new DockerImage(this.#imageNameTag) this.#layers = layers this.#provider = provider this.#runtime = runtime this.#servicePath = servicePath } #baseImage(runtime) { return `lambci/lambda:${runtime}` } async start(codeDir) { await this.#image.pull() log.debug("Run Docker container...") let permissions = "ro" if (!this.#dockerOptions.readOnly) { permissions = "rw" } // https://github.com/serverless/serverless/blob/v1.57.0/lib/plugins/aws/invokeLocal/index.js#L291-L293 const dockerArgs = [ "-v", `${codeDir}:/var/task:${permissions},delegated`, "-p", 9001, "-e", "DOCKER_LAMBDA_STAY_OPEN=1", // API mode "-e", "DOCKER_LAMBDA_WATCH=1", // Watch mode ] if (this.#layers.length > 0) { log.verbose(`Found layers, checking provider type`) if (this.#provider.name.toLowerCase() === "aws") { let layerDir = this.#dockerOptions.layersDir if (!layerDir) { layerDir = join(this.#servicePath, ".serverless-offline", "layers") } layerDir = join(layerDir, this.#getLayersSha256()) if (await pathExists(layerDir)) { log.verbose( `Layers already exist for this function. Skipping download.`, ) } else { log.verbose(`Storing layers at ${layerDir}`) // Only initialise if we have layers, we're using AWS, and they don't already exist this.#lambdaClient = new LambdaClient({ apiVersion: "2015-03-31", region: this.#provider.region, }) log.verbose(`Getting layers`) await Promise.all( this.#layers.map((layerArn) => this.#downloadLayer(layerArn, layerDir), ), ) } if ( this.#dockerOptions.hostServicePath && layerDir.startsWith(this.#servicePath) ) { layerDir = layerDir.replace( this.#servicePath, this.#dockerOptions.hostServicePath, ) } dockerArgs.push("-v", `${layerDir}:/opt:ro,delegated`) } else { log.warning( `Provider ${this.#provider.name} is Unsupported. Layers are only supported on aws.`, ) } } entries(this.#env).forEach(([key, value]) => { dockerArgs.push("-e", `${key}=${value}`) }) if (platform() === "linux" && !isWsl) { // Add `host.docker.internal` DNS name to access host from inside the container // https://github.com/docker/for-linux/issues/264 const gatewayIp = await this.#getBridgeGatewayIp() if (gatewayIp) { dockerArgs.push("--add-host", `host.docker.internal:${gatewayIp}`) } } if (this.#dockerOptions.network) { dockerArgs.push("--network", this.#dockerOptions.network) } const { stdout: containerId } = await execa("docker", [ "create", ...dockerArgs, this.#imageNameTag, this.#handler, ]) const dockerStart = execa("docker", ["start", "-a", containerId], { all: true, }) await new Promise((resolve, reject) => { dockerStart.all.on("data", (data) => { const str = String(data) log.error(str) if (str.includes("Lambda API listening on port")) { resolve() } }) dockerStart.on("error", (err) => { reject(err) }) }) // parse `docker port` output and get the container port let containerPort const { stdout: dockerPortOutput } = await execa("docker", [ "port", containerId, ]) // NOTE: `docker port` may output multiple lines. // // e.g.: // 9001/tcp -> 0.0.0.0:49153 // 9001/tcp -> :::49153 // // Parse each line until it finds the mapped port. for (const line of dockerPortOutput.split("\n")) { const result = line.match(/^9001\/tcp -> (.*):(\d+)$/) if (result && result.length > 2) { ;[, , containerPort] = result break } } if (!containerPort) { throw new Error("Failed to get container port") } this.#containerId = containerId this.#port = containerPort await pRetry(() => this.#ping(), { // default, factor: 2, // milliseconds minTimeout: 10, // default retries: 10, }) } async #downloadLayer(layerArn, layerDir) { const [, layerName] = layerArn.split(":layer:") const layerZipFile = `${layerDir}/${layerName}.zip` const layerProgress = progress.get(`layer-${layerName}`) log.verbose(`[${layerName}] ARN: ${layerArn}`) log.verbose(`[${layerName}] Getting Info`) layerProgress.notice(`Retrieving "${layerName}": Getting info`) const getLayerVersionCommand = new GetLayerVersionCommand({ LayerName: layerArn, }) try { let layer = null try { layer = await this.#lambdaClient.send(getLayerVersionCommand) } catch (err) { log.warning(`[${layerName}] ${err.code}: ${err.message}`) return } if ( hasOwn(layer, "CompatibleRuntimes") && !layer.CompatibleRuntimes.includes(this.#runtime) ) { log.warning( `[${layerName}] Layer is not compatible with ${this.#runtime} runtime`, ) return } const { CodeSize: layerSize, Location: layerUrl } = layer.Content // const layerSha = layer.Content.CodeSha256 await ensureDir(layerDir) log.verbose( `Retrieving "${layerName}": Downloading ${this.#formatBytes( layerSize, )}...`, ) layerProgress.notice( `Retrieving "${layerName}": Downloading ${this.#formatBytes( layerSize, )}`, ) const res = await fetch(layerUrl) if (!res.ok) { log.warning( `[${layerName}] Failed to fetch from ${layerUrl} with ${res.statusText}`, ) return } const fileStream = createWriteStream(layerZipFile) await new Promise((resolve, reject) => { res.body.pipe(fileStream) res.body.on("error", (err) => { reject(err) }) fileStream.on("finish", () => { resolve() }) }) log.verbose(`Retrieving "${layerName}": Unzipping to .layers directory`) layerProgress.notice( `Retrieving "${layerName}": Unzipping to .layers directory`, ) const data = await readFile(layerZipFile) const zip = await jszip.loadAsync(data) await Promise.all( entries(zip.files).map(async ([filename, jsZipObj]) => { const fileData = await jsZipObj.async("nodebuffer") if (filename.endsWith(sep)) { return undefined } await ensureDir(join(layerDir, dirname(filename))) return writeFile(join(layerDir, filename), fileData, { mode: zip.files[filename].unixPermissions, }) }), ) log.verbose(`[${layerName}] Removing zip file`) await unlink(layerZipFile) } finally { layerProgress.remove() } } async #getBridgeGatewayIp() { let gateway try { ;({ stdout: gateway } = await execa("docker", [ "network", "inspect", "bridge", "--format", "{{(index .IPAM.Config 0).Gateway}}", ])) } catch (err) { log.error(err.stderr) throw err } return gateway.split("/")[0] } async #ping() { const url = `http://${this.#dockerOptions.host}:${this.#port}/2018-06-01/ping` const res = await fetch(url) if (!res.ok) { throw new Error(`Failed to fetch from ${url} with ${res.statusText}`) } return res.text() } async request(event) { const url = `http://${this.#dockerOptions.host}:${this.#port}/2015-03-31/functions/${this.#functionKey}/invocations` const res = await fetch(url, { body: stringify(event), headers: { "Content-Type": "application/json" }, method: "post", }) if (!res.ok) { throw new Error(`Failed to fetch from ${url} with ${res.statusText}`) } return res.json() } async stop() { if (this.#containerId) { try { await execa("docker", ["stop", this.#containerId]) await execa("docker", ["rm", this.#containerId]) } catch (err) { log.error(err.stderr) throw err } } } #formatBytes(bytes, decimals = 2) { if (bytes === 0) return "0 Bytes" const k = 1024 const dm = decimals < 0 ? 0 : decimals const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] const i = floor(mathLog(bytes) / mathLog(k)) return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}` } #getLayersSha256() { return createHash("sha256").update(stringify(this.#layers)).digest("hex") } get isRunning() { return this.#containerId !== null && this.#port !== null } }