Serverless Virus Scanning with AWS Lambda

Point an S3 ObjectCreated:* notification at a container-image Lambda that runs clamscan against a signature database mounted from EFS, tag the object with the verdict, then copy it out of the incoming prefix — no always-on scanner, and no unscanned byte ever reachable by a reader.

This article sits under Automated Virus Scanning Integration, part of Backend Validation & Cloud Storage Architecture. The parent guide covers the ClamAV protocol and the queue in front of the scanner; this page is about the parts that are specific to Lambda — packaging a 1.4 GB database into a 250 MB deployment budget, cold starts, EFS throughput, and the exact errors the runtime produces when you get those wrong.

When to use this approach

  • Files arrive through direct-to-cloud uploads, so there is no server in the request path with the bytes in hand.
  • Volume is spiky — a few hundred objects an hour with 50× bursts. Lambda scales to zero between them; a resident clamd fleet does not.
  • Scans finish in well under 15 minutes and files are under a couple of gigabytes. Above that, run ClamAV as a long-lived daemon on ECS instead: Lambda’s hard 900-second ceiling and 10 GB ephemeral disk are not negotiable.

Prerequisites

  1. Node 20 (nodejs20.x) or a Lambda container image based on public.ecr.aws/lambda/nodejs:20, and @aws-sdk/client-s3 v3.600 or later.
  2. An S3 bucket with an incoming/ prefix that nothing can read, plus ready/ and quarantine/ prefixes — the layout described in quarantine bucket patterns for infected uploads.
  3. An EFS file system with an access point at /clamav, in the same VPC subnets as the function.
  4. An execution role carrying this policy plus AWSLambdaVPCAccessExecutionRole:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:GetObjectTagging", "s3:PutObjectTagging",
                 "s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::media-uploads/*"
    },
    {
      "Effect": "Allow",
      "Action": ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite"],
      "Resource": "arn:aws:elasticfilesystem:eu-west-1:123456789012:file-system/fs-0abc123"
    }
  ]
}

How the trigger path actually works

An S3 notification is an asynchronous invocation. S3 hands the event to Lambda’s internal queue and returns immediately; your function’s return value goes nowhere. That has four consequences worth designing around.

Delivery is at-least-once, so the same key can be scanned twice — make the handler idempotent by treating the tag write as the commit point. Retries are automatic: two of them by default, spaced by an internal backoff, with MaximumEventAgeInSeconds capped at six hours. A permanent failure after the last retry is silently dropped unless you attach an on-failure destination, so always configure one. And S3 encodes keys in the event record: spaces arrive as +, everything else percent-encoded.

Serverless virus scanning pipeline An upload to the incoming prefix triggers an S3 event to a scanner Lambda, which reads signatures from an EFS mount, then routes the object to the ready or quarantine prefix, with failures going to an SQS dead-letter queue. S3 incoming/ unreadable prefix ObjectCreated Scanner Lambda clamscan, 2 GB /mnt/clamav 1.44 GB signatures mmap clean ready/ prefix scan-status=clean infected quarantine/ prefix denied by policy on-failure DLQ after 2 retries scan error
Only the incoming prefix emits events; the destination prefixes must not, or every promotion re-invokes the scanner.

Scope the notification with FilterRules: [{ Name: "prefix", Value: "incoming/" }]. A bucket-wide filter plus a handler that writes back into the same bucket is an infinite loop that bills by the millisecond.

Packaging ClamAV for the runtime

A zip deployment package is capped at 250 MB unzipped, and the official ClamAV signature set is roughly 1.44 GB on disk once main.cvd, daily.cld and bytecode.cvd are unpacked. It does not fit, and a layer is immutable anyway — signatures would go stale until the next deploy. A container image gives you 10 GB, which fits, but freezes the database at build time.

The split that works: binaries in the image, database on EFS. The image carries clamscan and libclamav (about 42 MB), and the function mounts an EFS access point that a scheduled freshclam task keeps current. One database serves every concurrent invocation.

Filesystem layout of a ClamAV Lambda The Lambda execution environment holds the handler bundle in /var/task, ClamAV binaries in /opt and the object under scan in /tmp, while the signature database lives on an EFS mount written by a separate freshclam task. Lambda execution environment /var/task esbuild bundle of the handler 1.8 MB /opt/bin clamscan plus libclamav, read-only 42 MB /tmp the object under scan, 512 MB by default 2,048 MB everything except /tmp is immutable per deploy freshclam task hourly, EventBridge writes daily.cld /mnt/clamav (EFS) 1.44 GB signature set shared by all invocations NFS
Binaries ship immutably with the image; only the signature set lives on writable shared storage, so updates never require a deploy.

Have freshclam write into /mnt/clamav/incoming-db/ and then rename() each file into place. ClamAV memory-maps daily.cld; rewriting it under a running scan produces LibClamAV Error: cli_loaddb(): Can't open file /mnt/clamav/daily.cld, while an atomic rename leaves existing readers on the old inode until they exit.

Implementation

import {
  S3Client,
  HeadObjectCommand,
  GetObjectCommand,
  PutObjectTaggingCommand,
  CopyObjectCommand,
  DeleteObjectCommand,
} from "@aws-sdk/client-s3";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createWriteStream } from "node:fs";
import { rm } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import { randomUUID } from "node:crypto";
import type { Readable } from "node:stream";
import type { S3Event } from "aws-lambda";

const run = promisify(execFile);
const s3 = new S3Client({ maxAttempts: 5 });

const DB_DIR = process.env.CLAMAV_DB_DIR ?? "/mnt/clamav";
const MAX_SCAN_BYTES = 1_610_612_736; // 1.5 GiB — must stay under ephemeral storage

type Verdict = { status: "clean" | "infected" | "unscannable"; signature: string };

async function scan(path: string): Promise<Verdict> {
  try {
    await run(
      "/opt/bin/clamscan",
      [`--database=${DB_DIR}`, "--max-filesize=1500M", "--max-scansize=1500M",
       "--max-recursion=8", "--max-files=5000", "--stdout", path],
      { maxBuffer: 1_048_576 },
    );
    return { status: "clean", signature: "none" };
  } catch (err) {
    const e = err as { code?: number; stdout?: string; stderr?: string };
    if (e.code === 1) {
      const found = /:\s(\S+)\sFOUND/.exec(e.stdout ?? "");
      return { status: "infected", signature: found?.[1] ?? "unknown" };
    }
    // Exit 2 is a scan error, never a clean result. Let it retry.
    throw new Error(`clamscan exited ${e.code}: ${(e.stderr ?? "").trim()}`);
  }
}

async function tag(bucket: string, key: string, v: Verdict): Promise<void> {
  await s3.send(new PutObjectTaggingCommand({
    Bucket: bucket,
    Key: key,
    Tagging: {
      TagSet: [
        { Key: "scan-status", Value: v.status },
        { Key: "scan-signature", Value: v.signature },
        { Key: "scan-at", Value: new Date().toISOString() },
      ],
    },
  }));
}

async function route(bucket: string, key: string, v: Verdict): Promise<string> {
  const target = key.replace(
    /^incoming\//,
    v.status === "clean" ? "ready/" : "quarantine/",
  );
  await s3.send(new CopyObjectCommand({
    Bucket: bucket,
    CopySource: encodeURI(`${bucket}/${key}`),
    Key: target,
    TaggingDirective: "COPY",
    MetadataDirective: "COPY",
  }));
  await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
  return target;
}

export async function handler(event: S3Event): Promise<void> {
  for (const record of event.Records) {
    const bucket = record.s3.bucket.name;
    const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, " "));
    const local = `/tmp/${randomUUID()}`;
    const started = Date.now();

    const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
    if ((head.ContentLength ?? 0) > MAX_SCAN_BYTES) {
      const verdict: Verdict = { status: "unscannable", signature: "oversize" };
      await tag(bucket, key, verdict);
      await route(bucket, key, verdict);
      continue;
    }

    try {
      const obj = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
      await pipeline(obj.Body as Readable, createWriteStream(local));
      const verdict = await scan(local);
      await tag(bucket, key, verdict);
      const target = await route(bucket, key, verdict);
      console.log(JSON.stringify({
        key, target, ...verdict,
        bytes: head.ContentLength, ms: Date.now() - started,
      }));
    } finally {
      await rm(local, { force: true });
    }
  }
}

The parameters that carry weight

  • decodeURIComponent(key.replace(/\+/g, " ")) reverses S3’s event encoding. Skip it and any key with a space fails with NoSuchKey.
  • HeadObjectCommand before GetObject costs one request and saves a guaranteed timeout: an object larger than /tmp can never be scanned, so it is tagged unscannable and quarantined rather than retried three times.
  • randomUUID() for the local path avoids collisions when two records in one batch share a basename, and stops a hostile key such as incoming/../../etc/passwd from steering the write.
  • --max-recursion=8 and --max-files=5000 bound archive expansion. Without them a nested archive can hold the CPU for the full timeout; the deeper defence is described in detecting and blocking zip bomb uploads.
  • Exit code 1 versus 2 is the whole safety property. 0 is clean, 1 is a detection, 2 is a scan error — treating 2 as clean promotes unscanned files.
  • TaggingDirective: "COPY" carries the verdict onto the promoted object so a bucket policy can deny reads of anything not tagged scan-status=clean.
  • rm in a finally, because /tmp survives between warm invocations. Leaked files fill 512 MB in a few dozen scans.

Pair the verdict with type checking — validating file signatures with libmagic catches the polyglots ClamAV has no signature for.

Cold starts: the database load dominates

clamscan parses the entire signature set into memory on every process start. On a warm container the runtime is already up but the process is not — each invocation forks a fresh clamscan, so the load cost is paid per invocation unless you keep a clamd daemon alive in the sandbox and talk to it over a Unix socket.

Cold versus warm invocation timeline A cold invocation spends 1.1 seconds on init, 4.8 seconds loading the signature database, then under two seconds fetching and scanning; a warm invocation with a resident daemon takes 1.6 seconds in total. Cold start 7.5 s total load signature database 4.8 s Warm invoke 1.6 s total database already resident in clamd 0 s 2 s 4 s 6 s 8 s init 1.1 s load DB 4.8 s GetObject 0.4 s scan 1.2 s
Loading signatures is 64% of a cold invocation — the only optimisation that matters is not repeating it.

Two levers help. Start clamd from the image’s entrypoint before the handler is registered so the database loads once per sandbox, and use clamdscan --fdpass in place of clamscan; warm invocations then drop to the fetch-plus-scan cost. Add provisioned concurrency only if the p99 matters to a user-visible state change — at two provisioned environments you are paying about $9 a month per environment to avoid a 7-second tail on a pipeline nobody watches. Note that SnapStart does not help here; it is unavailable for container images.

Sizing the EFS mount

Every cold sandbox reads the full 1.44 GB database over NFS. Throughput mode is therefore the single biggest determinant of cold-start latency, and Bursting mode is a trap: baseline throughput is 50 KiB/s per GiB stored, so a 10 GiB file system sustains 0.5 MiB/s once its burst credits run out.

Database load time by EFS throughput mode Provisioned throughput loads the database in 2.8 seconds and elastic in 4.8, bursting with credits takes 14.4 seconds, and bursting after credits are exhausted takes about 2880 seconds, beyond any Lambda timeout. Provisioned 512 MiB/s 2.8 s Elastic 4.8 s Bursting, credits left 14.4 s Bursting, credits gone 2,880 s — every invocation times out 0 5 s 10 s 15 s
Bursting mode works in staging and fails in production: credits accrue with stored bytes, and a database-only file system stores almost nothing.

Use Elastic throughput unless your invocation rate is high and steady enough to justify provisioned. Then cap ReservedConcurrentExecutions at something the mount can feed — 200 simultaneous cold starts pull 288 GB from EFS in a few seconds and will hit the per-file-system limit regardless of mode. Capping concurrency also protects downstream steps such as queueing transcode jobs with SQS and Lambda from a thundering herd of promotions.

Configuration reference

Setting Value Effect
MemorySize 2048 MB Must exceed the resident database (~1.6 GB). CPU is proportional; 1,769 MB buys one full vCPU and clamscan is single-threaded, so more memory past 2 GB buys nothing.
Timeout 300 s Covers a cold load plus a slow archive. Keep it well under the SQS visibility timeout if you buffer with a queue.
EphemeralStorage.Size 2048 MB The /tmp budget, 512 MB by default, 10,240 MB maximum. Must exceed MAX_SCAN_BYTES.
ReservedConcurrentExecutions 50 Bounds EFS read load and S3 request rate.
MaximumRetryAttempts 2 Async invocation retries. 0 plus a destination is better if scans are expensive.
MaximumEventAgeInSeconds 3600 Discards events older than an hour instead of scanning stale keys after an outage.
DestinationConfig.OnFailure SQS ARN Where permanently failed events land. Without it they vanish.
FileSystemConfig.LocalMountPath /mnt/clamav Must begin with /mnt; anything else fails validation at deploy time.

Configuration gotchas

Runtime exited with error: signal: killed

The sandbox ran out of memory while libclamav was loading signatures. There is no stack trace because the kernel killed the process. Raise MemorySize to at least 2048 MB; check the Max Memory Used line in the REPORT log entry to size it.

Task timed out after 30.00 seconds

Almost always the database load, not the scan. Confirm by checking whether the log ends before any of your own log lines appear. Fix the EFS throughput mode first, then raise the timeout.

ENOSPC: no space left on device, write

/tmp filled up — either the object is bigger than ephemeral storage, or earlier invocations leaked files into the warm sandbox. The finally { await rm(local, { force: true }) } block and the HeadObject size guard cover both.

EFSMountFailureException / EFSMountTimeoutException

The function is not in the same subnets as the mount targets, or the mount target’s security group does not allow inbound TCP 2049 from the function’s security group. Note that a VPC-attached function has no internet route unless you add a NAT gateway or a VPC endpoint — this is the usual reason the S3 calls hang after the mount finally succeeds.

Verification

Push the EICAR test string, which every scanner detects and which is completely harmless:

# Write the EICAR test file and drop it into the scanned prefix.
printf 'X5O!P%%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > eicar.com
aws s3 cp eicar.com s3://media-uploads/incoming/eicar.com

# Within a few seconds the object should have moved and been tagged.
aws s3api get-object-tagging --bucket media-uploads --key quarantine/eicar.com
# {
#   "TagSet": [
#     { "Key": "scan-status", "Value": "infected" },
#     { "Key": "scan-signature", "Value": "Win.Test.EICAR_HDB-1" },
#     { "Key": "scan-at", "Value": "2026-07-26T09:14:22.118Z" }
#   ]
# }

# The source key must be gone.
aws s3api head-object --bucket media-uploads --key incoming/eicar.com
# An error occurred (404) when calling the HeadObject operation: Not Found

Then check the shape of the workload over a day with CloudWatch Logs Insights, which reads the structured line the handler emits:

fields @timestamp, key, status, bytes, ms
| filter ispresent(status)
| stats count() as scans, avg(ms) as avg_ms, pct(ms, 99) as p99_ms by status

If p99_ms sits near your timeout, cold starts are still dominating. If unscannable is more than a fraction of a percent, your size guard is below the real distribution of uploads — worth cross-checking against the object sizes you already record when indexing file metadata in PostgreSQL.

Frequently Asked Questions

Can I scan the object without writing it to /tmp?

Yes — stream the GetObject body straight into clamd over the INSTREAM protocol and you never touch disk, which removes the ephemeral storage ceiling entirely. That requires a resident daemon in the sandbox rather than a forked clamscan; the wire protocol is documented in the parent guide on virus scanning integration.

How much does this cost per 10,000 files?

At 2,048 MB and a 1.6-second warm scan, 10,000 invocations is about 32,768 GB-seconds, roughly $0.55 in duration plus $0.002 in requests. The EFS file system adds a fixed floor — around $4.50 a month for 15 GB of standard storage, more with provisioned throughput.

What happens to files that were uploaded before the scanner was deployed?

Nothing — S3 notifications are not retroactive. Run a one-off S3 Batch Operations job that invokes the same function against a manifest of existing keys, or set a lifecycle rule to expire the unscanned backlog, as described in setting up S3 lifecycle rules for temporary uploads.

Should the client wait for the verdict before showing the file?

No. Return immediately from the upload and let the UI poll or subscribe for the state change; a scan is 1–8 seconds and a cold start can be worse. Show the object as pending until the tag flips to clean.

Why not put an SQS queue between S3 and Lambda?

You should, once volume grows. A queue gives you batching, a controllable redrive policy and partial-batch responses, none of which asynchronous invocation offers. The direct notification is simply the smallest thing that is correct, and the two-retry-plus-destination path already prevents silent loss.