Repository: google/turbinia
Pull request #1141 (2022-09-07 12:10:20)

Add containerd
Created containerd support in turbinia using container-explorer.

aarontp: Looks like the tests failed, but I'm re-running them and maybe they'll pass now after some of the other fixes that went in this week.
aarontp: FYI, it looks like this is the current error the e2e test is failing with:
```
# Failed Tasks
* ContainerdEnumerationTask (/evidence/artifact_disk.dd:/p1): Error enumerating containerd containers: Error decoding container-explorer output: Expecting value: line 1 column 1 (char 0)
    * `/evidence/123456789/1663118926-db24cc9c96f14ddb8b8cdbc12bedf5cc-ContainerdEnumerationTask/worker-log.txt`
```

Patch:
diff --git a/docker/api_server/.dockerignore b/docker/api_server/.dockerignore
new file mode 100644
index 0000000..a544cc2
--- /dev/null
+++ b/docker/api_server/.dockerignore
@@ -0,0 +1,2 @@
+nodemodules
+dist
\ No newline at end of file
diff --git a/docker/api_server/Dockerfile b/docker/api_server/Dockerfile
index f7c18a6..72525c8 100644
--- a/docker/api_server/Dockerfile
+++ b/docker/api_server/Dockerfile
@@ -1,6 +1,15 @@
+# Build stage for the web ui
+FROM node:lts-alpine as build-stage
+WORKDIR /tmp/web
+COPY web/package*.json ./
+RUN npm install
+COPY web/. .
+RUN npm run build
+
+# Build Turbinia API Server, copy from build, and setup rest of requirements
 FROM ubuntu:20.04
-
 ENV DEBIAN_FRONTEND=noninteractive 
+COPY --from=build-stage /tmp/web/dist /web/dist
 RUN apt-get update && apt-get -y install python3-pip git wget supervisor curl
 
 RUN pip3 install pip --upgrade
diff --git a/docker/controller/Dockerfile b/docker/controller/Dockerfile
new file mode 100644
index 0000000..9f68558
--- /dev/null
+++ b/docker/controller/Dockerfile
@@ -0,0 +1,44 @@
+FROM ubuntu:20.04
+
+ENV DEBIAN_FRONTEND=noninteractive 
+RUN apt-get update && apt-get -y install \
+    apt-transport-https \
+    apt-utils \
+    ca-certificates \
+    git \
+    libterm-readline-gnu-perl \
+    python3-pip \
+    software-properties-common \
+    lsof \
+    vim \
+    sudo \
+    && rm -rf /var/lib/apt/lists/*
+
+RUN pip3 install pip --upgrade
+RUN pip3 install urllib3 cryptography requests --upgrade
+
+ADD requirements.txt /tmp/
+RUN cd /tmp/ && pip3 install -r requirements.txt
+
+ADD . /tmp/
+# unshallow and fetch all tags so our build systems pickup the correct git tag if it's a shallow clone
+RUN if $(cd /tmp/ && git rev-parse --is-shallow-repository); then cd /tmp/ && git fetch --prune --unshallow && git fetch --depth=1 origin +refs/tags/*:refs/tags/*; fi
+
+RUN cd /tmp/ && python3 setup.py install
+
+RUN useradd -r -s /bin/nologin -G disk,sudo turbinia
+RUN echo "turbinia ALL = (root) NOPASSWD: ALL" > /etc/sudoers.d/turbinia
+
+RUN mkdir /etc/turbinia && mkdir -p /mnt/turbinia/ && mkdir -p /var/lib/turbinia/ \
+    && mkdir -p /var/log/turbinia/ && chown -R turbinia:turbinia /mnt/turbinia/ \
+    && mkdir -p /etc/turbinia/ \
+    && chown -R turbinia:turbinia /var/lib/turbinia/ \
+    && chown -R turbinia:turbinia /etc/turbinia/ \
+    && chown -R turbinia:turbinia /var/log/turbinia/
+
+COPY docker/controller/start.sh /home/turbinia/start.sh
+COPY k8s/tools/load-test.sh /home/turbinia/load-test.sh
+RUN chmod +rwx /home/turbinia/start.sh
+RUN chmod +rwx /home/turbinia/load-test.sh
+USER turbinia
+CMD ["/home/turbinia/start.sh"]
\ No newline at end of file
diff --git a/docker/controller/README.md b/docker/controller/README.md
new file mode 100644
index 0000000..fe29bf5
--- /dev/null
+++ b/docker/controller/README.md
@@ -0,0 +1,6 @@
+## Docker configuration file for the Turbinia controller
+
+This Docker configuration is for building Turbinia controller image, which can
+be used to run the `turbiniactl` client to make requests, access Turbinia logs
+stored within a Filestore instance, perform load tests, and help troubleshoot
+issues within the GKE cluster.
diff --git a/docker/controller/build.sh b/docker/controller/build.sh
new file mode 100755
index 0000000..c1c5738
--- /dev/null
+++ b/docker/controller/build.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+docker build -t turbinia-controller:$1 .
+docker tag turbinia-controller:$1 us-docker.pkg.dev/osdfir-registry/turbinia/release/turbinia-controller:$1
\ No newline at end of file
diff --git a/docker/controller/cloudbuild.yaml b/docker/controller/cloudbuild.yaml
new file mode 100644
index 0000000..1d078ca
--- /dev/null
+++ b/docker/controller/cloudbuild.yaml
@@ -0,0 +1,19 @@
+# Google Cloud Build configuration for Turbinia controller
+steps:
+  - name: gcr.io/cloud-builders/docker
+    args:
+      [
+        "build",
+        "-t",
+        "us-docker.pkg.dev/osdfir-registry/turbinia/release/turbinia-controller:$TAG_NAME",
+        "-t",
+        "us-docker.pkg.dev/osdfir-registry/turbinia/release/turbinia-controller:latest",
+        "-f",
+        "docker/controller/Dockerfile",
+        ".",
+      ]
+    timeout: 4800s
+timeout: 4800s
+images:
+  - us-docker.pkg.dev/osdfir-registry/turbinia/release/turbinia-controller:latest
+  - us-docker.pkg.dev/osdfir-registry/turbinia/release/turbinia-controller:$TAG_NAME
diff --git a/docker/controller/start.sh b/docker/controller/start.sh
new file mode 100644
index 0000000..c2f92fd
--- /dev/null
+++ b/docker/controller/start.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+
+# Only write the Turbinia configuration file if TURBINIA_CONF is not empty and the file does not exist (or is 0 bytes in size)
+if [ ! -z "$TURBINIA_CONF" ] && [ ! -s /etc/turbinia/turbinia.conf ]
+then
+    echo "${TURBINIA_CONF}" | base64 -d > /etc/turbinia/turbinia.conf
+fi
+
+# Allows pod to run without being terminated in a GKE cluster
+tail -f /dev/null
\ No newline at end of file
diff --git a/docker/local/docker-compose.yml b/docker/local/docker-compose.yml
index b536cf0..74699d5 100644
--- a/docker/local/docker-compose.yml
+++ b/docker/local/docker-compose.yml
@@ -22,7 +22,7 @@ services:
 
         volumes:
             - $PWD/evidence:/evidence
-            - $PWD/conf/:/etc/turbinia/
+            - $PWD/conf/turbinia.conf:/etc/turbinia/turbinia.conf
 
         environment:
             - LC_ALL=C.UTF-8
@@ -36,7 +36,7 @@ services:
 
         volumes:
             - $PWD/evidence:/evidence
-            - $PWD/conf/:/etc/turbinia/
+            - $PWD/conf/turbinia.conf:/etc/turbinia/turbinia.conf
 
         environment:
             - LC_ALL=C.UTF-8
@@ -51,7 +51,7 @@ services:
 
         volumes:
             - $PWD/evidence:/evidence
-            - $PWD/conf/:/etc/turbinia/
+            - $PWD/conf/turbinia.conf:/etc/turbinia/turbinia.conf
 
         environment:
             - LC_ALL=C.UTF-8
@@ -66,7 +66,7 @@ services:
 
     #     volumes:
     #      - $PWD/evidence:/evidence
-    #      - $PWD/conf/:/etc/turbinia/
+    #      - $PWD/conf/turbinia.conf:/etc/turbinia/turbinia.conf
 
     #     environment:
     #      - LC_ALL=C.UTF-8
diff --git a/docker/vscode/Dockerfile b/docker/vscode/Dockerfile
index ea1dca4..6f073a7 100644
--- a/docker/vscode/Dockerfile
+++ b/docker/vscode/Dockerfile
@@ -26,5 +26,12 @@ RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] http://packages.c
 && apt-get update -y \
 && apt-get install google-cloud-sdk -y
 
+# Install NodeJS for frontend development
+RUN curl -sL https://deb.nodesource.com/setup_18.x -o nodesource_setup.sh
+RUN bash nodesource_setup.sh
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    nodejs \
+  && rm -rf /var/lib/apt/lists/*
+
 # Insert dummy command to override base image CMD
 CMD ["/usr/bin/ps"]
diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile
index 78474d1..3cc50a9 100644
--- a/docker/worker/Dockerfile
+++ b/docker/worker/Dockerfile
@@ -109,6 +109,11 @@ RUN cp /home/turbinia/password.lst /root/
 COPY k8s/tools/check-lockfile.py /home/turbinia/check-lockfile.py
 RUN chown turbinia:turbinia /home/turbinia/check-lockfile.py
 
+# Install container-explorer
+RUN wget -O /tmp/container-explorer-setup.sh https://raw.githubusercontent.com/google/container-explorer/main/script/setup.sh
+RUN chmod +x /tmp/container-explorer-setup.sh
+RUN sudo /tmp/container-explorer-setup.sh install
+
 ADD . /tmp/
 # unshallow and fetch all tags so our build systems pickup the correct git tag if it's a shallow clone
 RUN if $(cd /tmp/ && git rev-parse --is-shallow-repository); then cd /tmp/ && git fetch --prune --unshallow && git fetch --depth=1 origin +refs/tags/*:refs/tags/*; fi
diff --git a/docs/developer/developing-new-tasks.md b/docs/developer/developing-new-tasks.md
index c95b6e5..70eabef 100644
--- a/docs/developer/developing-new-tasks.md
+++ b/docs/developer/developing-new-tasks.md
@@ -22,8 +22,8 @@ to the `run()` method where most of our code will go:
 
 *   Running any pre- or post-processors that need to run to prepare the
     Evidence.
-*   If the Evidence is file-based, the pre-processor will add the path to the
-    processed evidence in `evidence.local_path`.
+*   Updating the `evidence.local_path` to be the local path of the evidence on
+    the worker machine the Task is currently running on.
 *   Setting up temporary directories (available as `self.output_dir` and
     `self.tmp_dir`).
 *   Preparing a TurbiniaResult object to save results into.
@@ -113,7 +113,7 @@ scheduled to process it.
 
 Each Task can set the Evidence state that is required (e.g. mounted, attached,
 etc) prior to execution by setting the state in `Task.REQUIRED_STATES`.  Each
-Evidence object can specify which states it supports in the
+Evidence object specifies which states it supports in the
 `Evidence.POSSIBLE_STATES` list attribute for that Task (e.g. see the
 [`GoogleCloudDisk` possible states
 here](https://github.com/google/turbinia/blob/cc79288ae36cfec749381b80694b4c1290d76583/turbinia/evidence.py#L661)).
@@ -128,16 +128,24 @@ docstrings](https://github.com/google/turbinia/blob/cc79288ae36cfec749381b80694b
 
 As mentioned above, the pre-processor that runs before the Task is executed
 will set the path `evidence.local_path` to point to the local Evidence. If the
-Task generates any new Evidence objects, you must set the `.source_path`
+Task generates any new output Evidence objects, you must set the `.source_path`
 attribute for that object before you add it to the results.  The `.source_path`
 is the original path the Evidence is created with and the `.local_path` is the
 path to access the Evidence after any pre-processors are run (e.g. the path it
-was mounted on if it was mounted, etc).  See the [docstrings for these
-attributes in the Evidence
+was mounted on if it was mounted, etc). In most cases, Tasks should use
+`.local_path` when processing or operating on the input Evidence and
+`.source_path` for the output Evidence that is created by the Task (and will
+be processed by other Tasks).
+
+Since not all Tasks can process all types/states of Evidence (e.g. device files
+and mounted directories), they can also reference other more specific paths for
+the input evidence if needed (e.g. `device_path` or `mount_path`), but
+generally this should not be needed as long as you set the
+`TurbiniaTask.REQUIRED_STATES` for the Task to match your actual requirements
+since the `local_path` should always be created by the pre-processors.
+See the [docstrings for these attributes in the Evidence
 object](https://github.com/google/turbinia/blob/cc79288ae36cfec749381b80694b4c1290d76583/turbinia/evidence.py#L127)
-for more details on the differences, but in summary, Tasks should use
-`.local_path` to process the incoming Evidence and `.source_path` for newly
-created Evidence.
+for more details. 
 
 ### Recipe configuration
 Tasks can also specify a set of variables that can be configured and set
diff --git a/docs/user/gke-sre.md b/docs/user/gke-sre.md
index 3db7144..5019246 100644
--- a/docs/user/gke-sre.md
+++ b/docs/user/gke-sre.md
@@ -34,6 +34,32 @@ these failures.
 - If the issue seems to be related to the third party tool, file a bug to the
   associated repo else file one for the Turbinia team.
 
+### Turbinia Controller
+
+In addition to the troubleshooting steps above, you may also consider deploying
+the Turbinia controller to the GKE cluster for further troubleshooting. The
+controller pod has the Turbinia client installed and is configured to use your
+Turbinia GKE instance. You may create Turbinia requests from this pod to process
+GCP disks within your project as well as have access to all Turbinia logs and output
+stored in the Filestore path. To deploy the Turbinia controller, please take the following steps.
+
+If using Turbinia Pubsub
+
+```
+./k8s/tools/deploy-pubsub-gke.sh --deploy-controller
+```
+
+If using Turbinia Celery/Redis
+
+```
+./k8s/tools/deploy-celery-gke.sh --deploy-controller
+```
+
+Please note that the commands above will also deploy the rest of the infrastructure so
+if you'd like to deploy the pod to an existing infrastructure, you can run
+`kubectl create -f k8s/common/turbinia-controller.yaml`. Please ensure that you
+have the correct `turbiniavolume` filestore path prior to deploying.
+
 ## GKE Infrastructure
 
 ### Preparation
@@ -106,7 +132,7 @@ demand determined by the CPU utilization average across all pods. As demand
 increases, the number of pods scale up until the CPU utilization is below a
 determined threshold. Once processing is complete, the number of Worker pods
 will scale down. The current autoscaling policy is configured in the
-[turbinia-autoscale-cpu.yaml](https://github.com/google/turbinia/blob/master/k8s/gcp-pubsub/turbinia-autoscale-cpu.yaml)
+[turbinia-autoscale-cpu.yaml](https://github.com/google/turbinia/blob/master/k8s/common/turbinia-autoscale-cpu.yaml)
 file.
 
 There is a default setting of 3 Worker pods to run at any given time with the
@@ -114,7 +140,7 @@ ability to scale up to 50 Worker pods across all nodes in the GKE cluster.
 In order to update the minimum number of Worker pods running at a given time,
 update the `minReplicas` value with the desired number of pods. In order to update
 the max number of pods to scale, update the `maxReplicas` value with the desired
-number. These changes should be updated in the [turbinia-autoscale-cpu.yaml](https://github.com/google/turbinia/blob/master/k8s/gcp-pubsub/turbinia-autoscale-cpu.yaml)
+number. These changes should be updated in the [turbinia-autoscale-cpu.yaml](https://github.com/google/turbinia/blob/master/k8s/common/turbinia-autoscale-cpu.yaml)
 file then applied through the following command.
 
 - `$ kubectl replace -f turbinia-autoscale-cpu.yaml`
@@ -206,6 +232,53 @@ A verbose cheatsheet can also be found [here](https://kubernetes.io/docs/referen
 
   - `$ kubectl top nodes`
 
+## GKE Load Testing
+
+If you'd like to perform some performance testing, troubleshooting GKE related issues,
+or would like to test out a new features capability within GKE, a load test script is
+available for use within `k8s/tools/load-test.sh`. Prior to running, please ensure you
+review the script and update any variables for your test. Most importantly, the load test
+script does not currently support the creation of test GCP disks and would need to be created
+prior to running the script. By default, the script will look for GCP disks with the naming
+convention of `<DISK_NAME-i>`, `i` being a range of `1` and `MAX_DISKS`. Once test data has
+been created, you can run the script on any machine or pod that has the Turbinia client
+installed and configured to the correct Turbinia GKE instance. Please run the following
+command to execute the load test, passing in a path to store the load test results.
+
+```
+./k8s/tools/load-test.sh /OUTPUT/LOADTEST/RESULTS
+```
+
+To check for any failed Tasks once the load test is complete.
+
+```
+turbinia@turbinia-controller-6bfcc5db99-sdpvg:/$ grep "Failed" -A 1 /mnt/turbiniavolume/loadtests/test-disk-25gb-*
+/mnt/turbiniavolume/loadtests/test-disk-25gb-1.log:# Failed Tasks
+/mnt/turbiniavolume/loadtests/test-disk-25gb-1.log-* None
+--
+/mnt/turbiniavolume/loadtests/test-disk-25gb-2.log:# Failed Tasks
+/mnt/turbiniavolume/loadtests/test-disk-25gb-2.log-* None
+```
+
+To check for average run times of each request once the load test is complete.
+
+```
+turbinia@turbinia-controller-6bfcc5db99-sdpvg:/$ tail -n 3 /mnt/turbiniavolume/loadtests/test-disk-25gb-*
+==> /mnt/turbiniavolume/loadtests/test-disk-25gb-1.log <==
+real    12m7.661s
+user    0m5.069s
+sys     0m1.253s
+
+==> /mnt/turbiniavolume/loadtests/test-disk-25gb-2.log <==
+real    12m7.489s
+user    0m5.069s
+sys     0m1.249s
+```
+
+To check for any issues with disks not properly mounting, within the Turbinia controller,
+please trying running `losetup -a` to check attached loop devices, `lsof | grep <device>`
+to check for any remaining file handles left on a loop device or disk.
+
 ## GKE Metrics and Monitoring
 
 In order to monitor the Turbinia infrastructure within Kubernetes,
@@ -217,7 +290,7 @@ through manifest files.
 The Turbinia Server and Workers are instrumented with Prometheus code and expose
 application metrics.
 
-- Service manifest files were created for both the Turbinia [Server](https://github.com/google/turbinia/blob/master/k8s/gcp-pubsub/turbinia-server-metrics-service.yaml) and [Worker](https://github.com/google/turbinia/blob/master/k8s/gcp-pubsub/turbinia-worker-metrics-service.yaml).
+- Service manifest files were created for both the Turbinia [Server](https://github.com/google/turbinia/blob/master/k8s/common/turbinia-server-metrics-service.yaml) and [Worker](https://github.com/google/turbinia/blob/master/k8s/common/turbinia-worker-metrics-service.yaml).
 - The files create two services named `turbinia-server-metrics` and `turbinia-worker-metrics` which expose port 9200 to
   poll application metrics.
 - The Prometheus service, which is listening on port 9090 scrapes these services
diff --git a/docs/user/install-gke-pubsub.md b/docs/user/install-gke-pubsub.md
index 8e673aa..db871b5 100644
--- a/docs/user/install-gke-pubsub.md
+++ b/docs/user/install-gke-pubsub.md
@@ -31,7 +31,7 @@ Please follow these steps for deploying Turbinia to GKE. Ensure that the `.clust
   - `./k8s/tools/deploy-pubsub-gke.sh`
 - The deployment script will automatically enable GCP APIs, create the cluster and GCP resources then deploy Turbinia to the cluster. At the end of the run, you should have a fully functioning Turbinia environment within GKE to use.
 - Run the following command if you'd like to cleanup the newly created Turbinia environment
-  - `./k8s/tools/cleanup-pubsub-gke.sh`
+  - `./k8s/tools/destroy-pubsub-gke.sh`
   - **Note this will delete the Turbinia cluster including all processed output and log files as well as associated GCP resources**
 
 ### **Making processing requests in GKE**
diff --git a/k8s/celery/destroy-celery.sh b/k8s/celery/destroy-celery.sh
index 133a580..e26540a 100755
--- a/k8s/celery/destroy-celery.sh
+++ b/k8s/celery/destroy-celery.sh
@@ -1,8 +1,11 @@
 #!/bin/sh
-# Turbinia Kubernetes destroy script for Celery/Redis config.
-# This script can be used to destroy the Turbinia deployment in Kubernetes.
+# Turbinia GKE Celery/Redis destroy script.
+# This script can be used to destroy the Turbinia Celery/Redis deployment in GKE.
 # Please use the destroy-celery-gke.sh script if you'd like to also delete
 # the cluster and other GCP resources created as part of the deployment.
+# Requirements:
+# - have 'gcloud' and 'kubectl' installed.
+# - authenticate against your GKE cluster with "gcloud container clusters get-credentials"
 
 kubectl delete configmap turbinia-config
 kubectl delete -f redis-server.yaml
@@ -12,5 +15,7 @@ kubectl delete -f turbinia-server-metrics-service.yaml
 kubectl delete -f turbinia-worker-metrics-service.yaml 
 kubectl delete -f turbinia-worker.yaml 
 kubectl delete -f turbinia-server.yaml
+kubectl delete -f turbinia-api-service.yaml
+kubectl delete -f turbinia-api-server.yaml
 kubectl delete -f turbinia-volume-claim-filestore.yaml
 kubectl delete -f turbinia-volume-filestore.yaml
\ No newline at end of file
diff --git a/k8s/celery/setup-celery.sh b/k8s/celery/setup-celery.sh
index eb742a3..c4c7a3c 100755
--- a/k8s/celery/setup-celery.sh
+++ b/k8s/celery/setup-celery.sh
@@ -1,8 +1,11 @@
 #!/bin/sh
-# Turbinia Kubernetes deployment script for Celery/Redis config.
-# This script can be used to deploy Turbinia to a Kubernetes environment.
+# Turbinia GKE Celery/Redis deployment script.
+# This script can be used to deploy Turbinia configured with Celery/Redis to GKE.
 # Please use the deploy-celery-gke.sh script if you'd also like to create
 # the GKE cluster and associated GCP resources required by Turbinia.
+# Requirements:
+# - have 'gcloud' and 'kubectl' installed.
+# - authenticate against your GKE cluster with "gcloud container clusters get-credentials"
 
 TURBINIA_CONF=$1
 if [ -z $1 ]; then
@@ -19,6 +22,8 @@ kubectl create -f redis-service.yaml
 kubectl rollout status -w deployment/redis-server
 kubectl create -f turbinia-server.yaml
 kubectl create -f turbinia-worker.yaml
+kubectl create -f turbinia-api-server.yaml
+kubectl create -f turbinia-api-service.yaml
 kubectl create -f turbinia-server-metrics-service.yaml
 kubectl create -f turbinia-worker-metrics-service.yaml
 kubectl create -f turbinia-autoscale-cpu.yaml
diff --git a/k8s/celery/turbinia-autoscale-cpu.yaml b/k8s/common/turbinia-autoscale-cpu.yaml
similarity index 100%
rename from k8s/celery/turbinia-autoscale-cpu.yaml
rename to k8s/common/turbinia-autoscale-cpu.yaml
diff --git a/k8s/gcp-pubsub/turbinia-worker.yaml b/k8s/common/turbinia-controller.yaml
similarity index 72%
rename from k8s/gcp-pubsub/turbinia-worker.yaml
rename to k8s/common/turbinia-controller.yaml
index 5bc2f10..2ba2fb3 100644
--- a/k8s/gcp-pubsub/turbinia-worker.yaml
+++ b/k8s/common/turbinia-controller.yaml
@@ -1,26 +1,25 @@
 apiVersion: apps/v1
 kind: Deployment
 metadata:
-  name: turbinia-worker
+  name: turbinia-controller
   labels:
-    app: turbinia-worker
+    app: turbinia-controller
 spec:
-  replicas: 3
+  replicas: 1
   selector:
     matchLabels:
-      app: turbinia-worker
+      app: turbinia-controller
   template:
     metadata:
       annotations:
         prometheus.io/port: "9200"
         prometheus.io/scrape: "true"
       labels:
-        app: turbinia-worker
+        app: turbinia-controller
     spec:
-      serviceAccountName: <SA_NAME>
+      serviceAccountName: turbinia
       # The grace period needs to be set to the largest task timeout as
       # set in the turbinia configuration file.
-      terminationGracePeriodSeconds: 86400
       initContainers:
         - name: init-filestore
           image: busybox:1.28
@@ -29,15 +28,8 @@ spec:
             - mountPath: "/mnt/turbiniavolume"
               name: turbiniavolume
       containers:
-        - name: worker
-          image: us-docker.pkg.dev/osdfir-registry/turbinia/release/turbinia-worker:latest
-          lifecycle:
-            preStop:
-              exec:
-                command:
-                  - "/bin/sh"
-                  - "-c"
-                  - "touch /tmp/turbinia-to-scaledown.lock && sleep 5 && /usr/bin/python3 /home/turbinia/check-lockfile.py"
+        - name: controller
+          image: us-docker.pkg.dev/osdfir-registry/turbinia/release/turbinia-controller:latest
           securityContext:
             privileged: true
           env:
@@ -53,9 +45,6 @@ spec:
                 fieldRef:
                   fieldPath: spec.nodeName
           volumeMounts:
-            - mountPath: "/dev"
-              name: dev
-              readOnly: true
             - mountPath: "/var/run/lock"
               name: lockfolder
               readOnly: false
@@ -71,9 +60,6 @@ spec:
               memory: "8192Mi"
               cpu: "32000m"
       volumes:
-        - name: dev
-          hostPath:
-            path: /dev
         - name: lockfolder
           hostPath:
             path: /var/run/lock
diff --git a/k8s/celery/turbinia-server-metrics-service.yaml b/k8s/common/turbinia-server-metrics-service.yaml
similarity index 100%
rename from k8s/celery/turbinia-server-metrics-service.yaml
rename to k8s/common/turbinia-server-metrics-service.yaml
diff --git a/k8s/celery/turbinia-server.yaml b/k8s/common/turbinia-server.yaml
similarity index 87%
rename from k8s/celery/turbinia-server.yaml
rename to k8s/common/turbinia-server.yaml
index ce1338a..7d83cb1 100644
--- a/k8s/celery/turbinia-server.yaml
+++ b/k8s/common/turbinia-server.yaml
@@ -45,6 +45,13 @@ spec:
               name: turbiniavolume
           ports:
             - containerPort: 9200
+          resources: 
+            requests:
+              memory: "256Mi"
+              cpu: "500m"
+            limits:
+              memory: "8192Mi"
+              cpu: "4000m"
       volumes:
         - name: turbiniavolume
           persistentVolumeClaim:
diff --git a/k8s/celery/turbinia-volume-claim-filestore.yaml b/k8s/common/turbinia-volume-claim-filestore.yaml
similarity index 100%
rename from k8s/celery/turbinia-volume-claim-filestore.yaml
rename to k8s/common/turbinia-volume-claim-filestore.yaml
diff --git a/k8s/celery/turbinia-volume-filestore.yaml b/k8s/common/turbinia-volume-filestore.yaml
similarity index 100%
rename from k8s/celery/turbinia-volume-filestore.yaml
rename to k8s/common/turbinia-volume-filestore.yaml
diff --git a/k8s/celery/turbinia-worker-metrics-service.yaml b/k8s/common/turbinia-worker-metrics-service.yaml
similarity index 100%
rename from k8s/celery/turbinia-worker-metrics-service.yaml
rename to k8s/common/turbinia-worker-metrics-service.yaml
diff --git a/k8s/celery/turbinia-worker.yaml b/k8s/common/turbinia-worker.yaml
similarity index 100%
rename from k8s/celery/turbinia-worker.yaml
rename to k8s/common/turbinia-worker.yaml
diff --git a/k8s/gcp-pubsub/destroy-pubsub.sh b/k8s/gcp-pubsub/destroy-pubsub.sh
index 6014994..691621d 100755
--- a/k8s/gcp-pubsub/destroy-pubsub.sh
+++ b/k8s/gcp-pubsub/destroy-pubsub.sh
@@ -1,8 +1,10 @@
 #!/bin/sh
-# Turbinia GKE destroy script
-# This script can be used to destroy the Turbinia infrastructure in GKE.
+# Turbinia GKE Pubsub destroy script.
+# This script can be used to destroy the Turbinia Pubsub deployment in GKE.
+# Please use the destroy-pubsub-gke.sh script if you'd like to also delete
+# the cluster and other GCP resources created as part of the deployment.
 # Requirements:
-# - have 'gcloud' installed.
+# - have 'gcloud' and 'kubectl' installed.
 # - authenticate against your GKE cluster with "gcloud container clusters get-credentials"
 
 kubectl delete configmap turbinia-config
diff --git a/k8s/gcp-pubsub/setup-pubsub.sh b/k8s/gcp-pubsub/setup-pubsub.sh
index 066e19d..6ec149b 100755
--- a/k8s/gcp-pubsub/setup-pubsub.sh
+++ b/k8s/gcp-pubsub/setup-pubsub.sh
@@ -1,8 +1,10 @@
 #!/bin/sh
-# Turbinia GKE deployment script
-# This script can be used to deploy Turbinia to GKE.
+# Turbinia GKE Pubsub deployment script.
+# This script can be used to deploy Turbinia configured with Pubsub to GKE.
+# Please use the deploy-celery-gke.sh script if you'd also like to create
+# the GKE cluster and associated GCP resources required by Turbinia.
 # Requirements:
-# - have 'gcloud' installed.
+# - have 'gcloud' and 'kubectl' installed.
 # - authenticate against your GKE cluster with "gcloud container clusters get-credentials"
 
 TURBINIA_CONF=$1
diff --git a/k8s/gcp-pubsub/turbinia-autoscale-cpu.yaml b/k8s/gcp-pubsub/turbinia-autoscale-cpu.yaml
deleted file mode 100644
index 51d6685..0000000
--- a/k8s/gcp-pubsub/turbinia-autoscale-cpu.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-apiVersion: autoscaling/v1
-kind: HorizontalPodAutoscaler
-metadata:
-  name: turbinia-worker-autoscaling
-spec:
-  scaleTargetRef:
-    apiVersion: apps/v1
-    kind: Deployment
-    name: turbinia-worker
-  minReplicas: 3
-  maxReplicas: 50
-  targetCPUUtilizationPercentage: 90
diff --git a/k8s/gcp-pubsub/turbinia-server-metrics-service.yaml b/k8s/gcp-pubsub/turbinia-server-metrics-service.yaml
deleted file mode 100644
index 8c58c62..0000000
--- a/k8s/gcp-pubsub/turbinia-server-metrics-service.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
-  name: turbinia-server-metrics
-  labels:
-    app: turbinia-server-metrics
-spec:
-  ports:
-  - port: 9200
-    targetPort: 9200
-  selector:
-    app: turbinia-server
\ No newline at end of file
diff --git a/k8s/gcp-pubsub/turbinia-server.yaml b/k8s/gcp-pubsub/turbinia-server.yaml
deleted file mode 100644
index de2a354..0000000
--- a/k8s/gcp-pubsub/turbinia-server.yaml
+++ /dev/null
@@ -1,52 +0,0 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: turbinia-server
-  labels:
-    app: turbinia-server
-spec:
-  replicas: 1
-  selector:
-    matchLabels:
-      app: turbinia-server
-  template:
-    metadata:
-      annotations:
-        prometheus.io/port: "9200"
-        prometheus.io/scrape: "true"
-      labels:
-        app: turbinia-server
-    spec:
-      serviceAccountName: <SA_NAME>
-      initContainers:
-        - name: init-filestore
-          image: busybox:1.28
-          command: ['sh', '-c', 'chmod go+w /mnt/turbiniavolume']
-          volumeMounts:
-            - mountPath: "/mnt/turbiniavolume"
-              name: turbiniavolume
-      containers:
-        - name: server
-          image: us-docker.pkg.dev/osdfir-registry/turbinia/release/turbinia-server:latest
-          env:
-            - name: TURBINIA_CONF
-              valueFrom:
-                configMapKeyRef:
-                  name: turbinia-config
-                  key: TURBINIA_CONF
-            - name: TURBINIA_EXTRA_ARGS
-              value: "-d"
-            - name: NODE_NAME
-              valueFrom:
-                fieldRef:
-                  fieldPath: spec.nodeName
-          volumeMounts:
-            - mountPath: /mnt/turbiniavolume
-              name: turbiniavolume
-          ports:
-            - containerPort: 9200
-      volumes:
-        - name: turbiniavolume
-          persistentVolumeClaim:
-            claimName: turbiniavolume-claim
-            readOnly: false
\ No newline at end of file
diff --git a/k8s/gcp-pubsub/turbinia-volume-claim-filestore.yaml b/k8s/gcp-pubsub/turbinia-volume-claim-filestore.yaml
deleted file mode 100644
index 1fd326c..0000000
--- a/k8s/gcp-pubsub/turbinia-volume-claim-filestore.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-apiVersion: v1
-kind: PersistentVolumeClaim
-metadata:
-  name: turbiniavolume-claim
-spec:
-  accessModes:
-  - ReadWriteMany
-  storageClassName: ""
-  volumeName: turbiniavolume
-  resources:
-    requests:
-      storage: 1T
\ No newline at end of file
diff --git a/k8s/gcp-pubsub/turbinia-volume-filestore.yaml b/k8s/gcp-pubsub/turbinia-volume-filestore.yaml
deleted file mode 100644
index 1b95ef1..0000000
--- a/k8s/gcp-pubsub/turbinia-volume-filestore.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-apiVersion: v1
-kind: PersistentVolume
-metadata:
-  name: turbiniavolume
-spec:
-  capacity:
-    storage: 1T
-  accessModes:
-  - ReadWriteMany
-  nfs:
-    path: /turbiniavolume
-    server: <IP_ADDRESS>
\ No newline at end of file
diff --git a/k8s/gcp-pubsub/turbinia-worker-metrics-service.yaml b/k8s/gcp-pubsub/turbinia-worker-metrics-service.yaml
deleted file mode 100644
index 6dd22ad..0000000
--- a/k8s/gcp-pubsub/turbinia-worker-metrics-service.yaml
+++ /dev/null
@@ -1,12 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
-  name: turbinia-worker-metrics
-  labels:
-    app: turbinia-worker-metrics
-spec:
-  ports:
-  - port: 9200
-    targetPort: 9200
-  selector:
-    app: turbinia-worker
\ No newline at end of file
diff --git a/k8s/tools/deploy-celery-gke.sh b/k8s/tools/deploy-celery-gke.sh
index a920bbe..503a08e 100755
--- a/k8s/tools/deploy-celery-gke.sh
+++ b/k8s/tools/deploy-celery-gke.sh
@@ -25,6 +25,7 @@ if [[ "$*" == *--help ||  "$*" == *-h ]] ; then
   echo "--build-experimental           Deploy Turbinia experimental docker image"
   echo "--no-cluster                   Do not create the cluster"
   echo "--no-filestore                 Do not deploy Turbinia Filestore"
+  echo "--deploy-controller            Deploy Turbinia controller for load testing and troubleshooting" 
   echo "--deploy-dfdewey               Deploy dfDewey datastores"
   exit 1
 fi
@@ -114,6 +115,7 @@ echo "Deploying cluster to project $DEVSHELL_PROJECT_ID"
 # Setup appropriate directories and copy of deployment templates and Turbinia config
 echo "Copying over template deployment files to $DEPLOYMENT_FOLDER"
 mkdir -p $DEPLOYMENT_FOLDER
+cp common/* $DEPLOYMENT_FOLDER
 cp celery/* $DEPLOYMENT_FOLDER
 if [[ "$*" == *--deploy-dfdewey* ]] ; then
   cp dfdewey/* $DEPLOYMENT_FOLDER
@@ -207,6 +209,12 @@ fi
 echo "Deploying Turbinia to $CLUSTER_NAME cluster"
 ./setup-celery.sh $TURBINIA_CONFIG
 
+# Deploy Turbinia Controller
+if [[ "$*" == *--deploy-controller* ]] ; then
+  echo "--deploy-controller specified. Deploying Turbinia controller."
+  kubectl create -f turbinia-controller.yaml
+fi
+
 # Deploy dfDewey
 if [[ "$*" == *--deploy-dfdewey* ]] ; then
   echo "Deploying dfDewey datastores to $CLUSTER_NAME cluster"
diff --git a/k8s/tools/deploy-pubsub-gke.sh b/k8s/tools/deploy-pubsub-gke.sh
index b3adf31..1af42a1 100755
--- a/k8s/tools/deploy-pubsub-gke.sh
+++ b/k8s/tools/deploy-pubsub-gke.sh
@@ -22,7 +22,6 @@ if [[ "$*" == *--help ]] ; then
   echo "Options:"
   echo "--build-dev                    Deploy Turbinia development docker image"
   echo "--build-experimental           Deploy Turbinia experimental docker image"
-  echo "--no-gcloud-auth               Use Turbinia service account instead of gcloud authentication"
   echo "--no-cloudfunctions            Do not deploy Turbinia Cloud Functions"
   echo "--no-appengine                 Do not enable App Engine"
   echo "--no-datastore                 Do not configure Turbinia Datastore"
@@ -30,6 +29,7 @@ if [[ "$*" == *--help ]] ; then
   echo "--no-gcs                       Do not create a GCS bucket"
   echo "--no-pubsub                    Do not create the PubSub and PSQ topic/subscription"
   echo "--no-cluster                   Do not create the cluster"
+  echo "--deploy-controller            Deploy Turbinia controller for load testing and troubleshooting" 
   echo "--deploy-dfdewey               Deploy dfDewey datastores"
   exit 1
 fi
@@ -70,43 +70,36 @@ if [[ -z "$DEVSHELL_PROJECT_ID" ]] ; then
   fi
 fi
 
+# TODO: Do real check to make sure credentials have adequate roles
+if [[ $( gcloud -q --project $DEVSHELL_PROJECT_ID auth list --filter="status:ACTIVE" --format="value(account)" | wc -l ) -eq 0 ]] ; then
+  echo "No gcloud credentials found.  Use 'gcloud auth login' and 'gcloud auth application-default login' to log in"
+  exit 1
+fi
+
 # Enable IAM services
 gcloud -q --project $DEVSHELL_PROJECT_ID services enable iam.googleapis.com
 
-# Create Turbinia service account.
+
+# Create Turbinia service account with necessary IAM roles. The service account will be used at
+# container runtime in order to have the necessary permissions to attach and detach GCP disks, to
+# access GCP Pubsub, Datastore, Cloud Functions, GCS, and to write logs to stackdriver logging and 
+# error reporting.
 SA_NAME="turbinia"
 SA_MEMBER="serviceAccount:$SA_NAME@$DEVSHELL_PROJECT_ID.iam.gserviceaccount.com"
-
 if [[ -z "$(gcloud -q --project $DEVSHELL_PROJECT_ID iam service-accounts list --format='value(name)' --filter=name:/$SA_NAME@)" ]] ; then
-  # Create service account
   gcloud --project $DEVSHELL_PROJECT_ID iam service-accounts create "${SA_NAME}" --display-name "${SA_NAME}"
-fi
-
-# Grant IAM roles to the service account
-echo "Grant permissions on service account"
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/cloudfunctions.admin'
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/cloudsql.admin'
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/compute.admin'
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/container.admin'
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/datastore.indexAdmin'
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/editor'
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/logging.logWriter'
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/pubsub.admin'
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/redis.admin'
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/servicemanagement.admin'
-gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/storage.admin'
-
-# Use local `gcloud auth` credentials.
-if [[ "$*" == *--no-gcloud-auth* ]] ; then
-  # Create and fetch the service account key
-  echo "Fetch and store service account key"
-  gcloud --project $DEVSHELL_PROJECT_ID iam service-accounts keys create ~/$INSTANCE_ID.json --iam-account "$SA_NAME@$DEVSHELL_PROJECT_ID.iam.gserviceaccount.com"
-  export GOOGLE_APPLICATION_CREDENTIALS=~/$INSTANCE_ID.json
-
-# TODO: Do real check to make sure credentials have adequate roles
-elif [[ $( gcloud -q --project $DEVSHELL_PROJECT_ID auth list --filter="status:ACTIVE" --format="value(account)" | wc -l ) -eq 0 ]] ; then
-  echo "No gcloud credentials found.  Use 'gcloud auth login' and 'gcloud auth application-default login' to log in"
-  exit 1
+  # Grant IAM roles to the service account
+  echo "Grant permissions on service account"
+  gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/cloudfunctions.admin'
+  gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/editor'
+  gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/cloudsql.admin'
+  gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/datastore.indexAdmin'
+  gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/logging.logWriter'
+  gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/errorreporting.writer'
+  gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/pubsub.admin'
+  gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/servicemanagement.admin'
+  gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/storage.admin'
+  gcloud projects add-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/compute.admin'
 fi
 
 echo "Enabling Compute API"
@@ -123,18 +116,18 @@ fi
 if [[ "$*" == *--build-dev* ]] ; then
   TURBINIA_SERVER_IMAGE="us-docker.pkg.dev\/osdfir-registry\/turbinia\/release\/turbinia-server-dev:latest"
   TURBINIA_WORKER_IMAGE="us-docker.pkg.dev\/osdfir-registry\/turbinia\/release\/turbinia-worker-dev:latest"
-  echo "Setting docker image to $TURBINIA_SERVER_IMAGE and $TURBINIA_WORKER_IMAGE"
 elif [[ "$*" == *--build-experimental* ]] ; then
   TURBINIA_SERVER_IMAGE="us-docker.pkg.dev\/osdfir-registry\/turbinia\/release\/turbinia-server-experimental:latest"
   TURBINIA_WORKER_IMAGE="us-docker.pkg.dev\/osdfir-registry\/turbinia\/release\/turbinia-worker-experimental:latest"
-  echo "Setting docker image to $TURBINIA_SERVER_IMAGE and $TURBINIA_WORKER_IMAGE"
 fi
 
+echo "Setting docker image to $TURBINIA_SERVER_IMAGE and $TURBINIA_WORKER_IMAGE"
 echo "Deploying cluster to project $DEVSHELL_PROJECT_ID"
 
 # Setup appropriate directories and copy of deployment templates and Turbinia config
 echo "Copying over template deployment files to $DEPLOYMENT_FOLDER"
 mkdir -p $DEPLOYMENT_FOLDER
+cp common/* $DEPLOYMENT_FOLDER
 cp gcp-pubsub/* $DEPLOYMENT_FOLDER
 if [[ "$*" == *--deploy-dfdewey* ]] ; then
   cp dfdewey/* $DEPLOYMENT_FOLDER
@@ -206,6 +199,7 @@ sed -i -e "s/^INSTANCE_ID = .*$/INSTANCE_ID = '$INSTANCE_ID'/g" $TURBINIA_CONFIG
 sed -i -e "s/^TURBINIA_PROJECT = .*$/TURBINIA_PROJECT = '$DEVSHELL_PROJECT_ID'/g" $TURBINIA_CONFIG
 sed -i -e "s/^TURBINIA_ZONE = .*$/TURBINIA_ZONE = '$ZONE'/g" $TURBINIA_CONFIG
 sed -i -e "s/^TURBINIA_REGION = .*$/TURBINIA_REGION = '$REGION'/g" $TURBINIA_CONFIG
+sed -i -e "s/^CLOUD_PROVIDER = .*$/CLOUD_PROVIDER = 'GCP'/g" $TURBINIA_CONFIG
 
 # Create File Store instance and update deployment files with created instance
 if [[ "$*" != *--no-filestore* ]] ; then  
@@ -219,12 +213,15 @@ fi
 
 echo "Updating $TURBINIA_CONFIG config with Filestore configuration"
 FILESTORE_IP=$(gcloud -q --project $DEVSHELL_PROJECT_ID filestore instances describe $FILESTORE_NAME --zone=$ZONE --format='value(networks.ipAddresses)' --flatten="networks[].ipAddresses[]")
-FILESTORE_MOUNT="'\/mnt\/$FILESTORE_NAME'"
+FILESTORE_LOGS="'\/mnt\/$FILESTORE_NAME\/logs'"
+FILESTORE_OUTPUT="'\/mnt\/$FILESTORE_NAME\/output'"
 sed -i -e "s/<IP_ADDRESS>/$FILESTORE_IP/g" turbinia-volume-filestore.yaml
-sed -i -e "s/turbiniavolume/$FILESTORE_NAME/g" *.yaml
+sed -i -e "s/turbiniavolume/$FILESTORE_NAME/g" turbinia-volume-filestore.yaml turbinia-volume-claim-filestore.yaml turbinia-server.yaml turbinia-worker.yaml 
 sed -i -e "s/storage: .*/storage: $FILESTORE_CAPACITY/g" turbinia-volume-filestore.yaml turbinia-volume-claim-filestore.yaml
-sed -i -e "s/^LOG_DIR = .*$/LOG_DIR = $FILESTORE_MOUNT/g" $TURBINIA_CONFIG
+sed -i -e "s/^LOG_DIR = .*$/LOG_DIR = $FILESTORE_LOGS/g" $TURBINIA_CONFIG
 sed -i -e "s/^MOUNT_DIR_PREFIX = .*$/MOUNT_DIR_PREFIX = '\/mnt\/turbinia'/g" $TURBINIA_CONFIG
+sed -i -e "s/^SHARED_FILESYSTEM = .*$/SHARED_FILESYSTEM = True/g" $TURBINIA_CONFIG
+sed -i -e "s/^OUTPUT_DIR = .*$/OUTPUT_DIR = $FILESTORE_OUTPUT/g" $TURBINIA_CONFIG
 
 #Create Google Cloud Storage Bucket
 if [[ "$*" != *--no-gcs* ]] ; then  
@@ -291,6 +288,12 @@ fi
 echo "Deploying Turbinia to $CLUSTER_NAME cluster"
 ./setup-pubsub.sh $TURBINIA_CONFIG
 
+# Deploy Turbinia Controller
+if [[ "$*" == *--deploy-controller* ]] ; then
+  echo "--deploy-controller specified. Deploying Turbinia controller."
+  kubectl create -f turbinia-controller.yaml
+fi
+
 # Deploy dfDewey
 if [[ "$*" == *--deploy-dfdewey* ]] ; then
   echo "Deploying dfDewey datastores to $CLUSTER_NAME cluster"
@@ -323,4 +326,4 @@ echo "Creating a copy of Turbinia config in $TURBINIA_OUT"
 cp $TURBINIA_CONFIG $TURBINIA_OUT
 
 echo "Turbinia GKE was succesfully deployed!"
-echo "Authenticate via: gcloud container clusters get-credentials $CLUSTER_NAME --zone $ZONE" 
+echo "Authenticate via: gcloud container clusters get-credentials $CLUSTER_NAME --zone $ZONE"
\ No newline at end of file
diff --git a/k8s/tools/cleanup-pubsub-gke.sh b/k8s/tools/destroy-pubsub-gke.sh
similarity index 95%
rename from k8s/tools/cleanup-pubsub-gke.sh
rename to k8s/tools/destroy-pubsub-gke.sh
index 5184882..208ea90 100755
--- a/k8s/tools/cleanup-pubsub-gke.sh
+++ b/k8s/tools/destroy-pubsub-gke.sh
@@ -23,7 +23,7 @@ cd $DIR/..
 if [[ "$*" == *--help ]] ; then
   echo "Turbinia cleanup script for Turbinia within Kubernetes"
   echo "Options:"
-  echo "--no-gcloud-auth               Do not use gcloud authentication and service key instead"
+  echo "--no-service-account           Do not delete the Turbinia service account"
   echo "--no-cloudfunctions            Do not cleanup Turbinia Cloud Functions"
   echo "--no-datastore                 Do not cleanup Turbinia Datastore"
   echo "--no-filestore                 Do not cleanup Turbinia Filestore share"
@@ -131,23 +131,22 @@ if [[ "$*" != *--no-datastore* ]] ; then
 fi
 
 # Remove the service account if it was being used.
-if [[ "$*" == *--no-gcloud-auth* ]] ; then
+if [[ "$*" == *--no-service-account* ]] ; then
   SA_NAME="turbinia"
   SA_MEMBER="serviceAccount:$SA_NAME@$DEVSHELL_PROJECT_ID.iam.gserviceaccount.com"
 
   # Delete IAM roles from the service account
   echo "Delete permissions on service account"
   gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/cloudfunctions.admin'
+  gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/editor'
   gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/cloudsql.admin'
-  gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/compute.admin'
-  gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/container.admin'
   gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/datastore.indexAdmin'
-  gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/editor'
   gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/logging.logWriter'
+  gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/errorreporting.writer'
   gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/pubsub.admin'
-  gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/redis.admin'
   gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/servicemanagement.admin'
   gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/storage.admin'
+  gcloud projects remove-iam-policy-binding $DEVSHELL_PROJECT_ID --member=$SA_MEMBER --role='roles/compute.admin'
 
   # Delete service account
   echo "Delete service account"
diff --git a/k8s/tools/helper.sh b/k8s/tools/helper.sh
index 1eaec75..a453a2f 100644
--- a/k8s/tools/helper.sh
+++ b/k8s/tools/helper.sh
@@ -1,4 +1,21 @@
 # Setup some useful things
 alias k=kubectl
-ke() { kubectl exec --stdin --tty $1 -- /bin/bash; }
-export ke
+# Apply a yaml file
+alias kaf='kubectl apply -f'
+# Delete a deployment
+alias kdf='kubectl delete -f'
+# Exec into a pod
+alias ke='_ke(){ kubectl exec --stdin --tty $1 -- /bin/bash; unset -f _ke; }; _ke'
+# Get pods
+alias kgp='kubectl get pods'
+# Get deployment
+alias kgd='kubectl get deployment'
+# Describe pods
+alias kdp='kubectl describe pods'
+# Get nodes
+alias kgn='kubectl get nodes -o wide'
+# Get logs
+alias kl='kubectl logs'
+alias kl1h='kubectl logs --since 1h'
+alias kl1m='kubectl logs --since 1m'
+alias kl1s='kubectl logs --since 1s'
\ No newline at end of file
diff --git a/tools/fraken/main.go b/tools/fraken/main.go
index 3cd8b8f..63fcb41 100644
--- a/tools/fraken/main.go
+++ b/tools/fraken/main.go
@@ -62,9 +62,11 @@ var (
 	rulePathFlag      = flag.String("rules", "", "Specify a particular path to a file or folder containing the Yara rules to use")
 	magicPathFlag     = flag.String("magic", "misc/file-type-signatures.txt", "A path under the rules path that contains File Magics")
 	yaraRulesFlag     = flag.String("extrayara", "", "Any additional Yara rules to be used")
+	testRulesFlag     = flag.Bool("testrules", false, "Test the given rules for syntax validity and then exit")
 	magics            = make(map[string]string)
 	externalVariables = []string{"filepath", "filename", "filetype", "extension", "owner"}
 	maxGoroutines     = 10
+	maxScanFilesize   = 1073741824 /* 1 Gb */
 )
 
 func initMagics() error {
@@ -356,37 +358,39 @@ func filesystemScan(wait chan struct{}, c chan *Detection) {
 		wg.Add(1)
 		wait <- struct{}{}
 		go func() {
-			matches, _ := scanFile(scanner, filePath, fileInfo)
-			for _, match := range matches {
-				var description, reference string
-				score := 50
-				for _, m := range match.Metas {
-					var parsedScore int
-					if m.Identifier == "score" {
-						switch g := m.Value.(type) {
-						case int:
-							parsedScore = m.Value.(int)
-						case string:
-							parsedScore, err = strconv.Atoi(strings.TrimSpace(m.Value.(string)))
-						default:
-							log.Printf("Unable to parse score for rule %v (type %v)): %v\n", match.Rule, g, m)
+			if fileInfo.Size() <= int64(maxScanFilesize) {
+				matches, _ := scanFile(scanner, filePath, fileInfo)
+				for _, match := range matches {
+					var description, reference string
+					score := 50
+					for _, m := range match.Metas {
+						var parsedScore int
+						if m.Identifier == "score" {
+							switch g := m.Value.(type) {
+							case int:
+								parsedScore = m.Value.(int)
+							case string:
+								parsedScore, err = strconv.Atoi(strings.TrimSpace(m.Value.(string)))
+							default:
+								log.Printf("Unable to parse score for rule %v (type %v)): %v\n", match.Rule, g, m)
+							}
+							score = parsedScore
 						}
-						score = parsedScore
-					}
-					if strings.HasPrefix(m.Identifier, "desc") {
-						description = m.Value.(string)
-					}
-					if m.Identifier == "reference" || strings.HasPrefix(m.Identifier, "report") {
-						reference = m.Value.(string)
-					}
-					if m.Identifier == "context" {
-						v := strings.ToLower(m.Value.(string))
-						if v == "yes" || v == "true" || v == "1" {
-							score = 0
+						if strings.HasPrefix(m.Identifier, "desc") {
+							description = m.Value.(string)
+						}
+						if m.Identifier == "reference" || strings.HasPrefix(m.Identifier, "report") {
+							reference = m.Value.(string)
+						}
+						if m.Identifier == "context" {
+							v := strings.ToLower(m.Value.(string))
+							if v == "yes" || v == "true" || v == "1" {
+								score = 0
+							}
 						}
 					}
+					c <- newDetection(filePath, match.Rule, description, reference, score)
 				}
-				c <- newDetection(filePath, match.Rule, description, reference, score)
 			}
 			<-wait
 			wg.Done()
@@ -440,13 +444,17 @@ func scanFile(s Scanner, filePath string, fileInfo os.FileInfo) (yara.MatchRules
 func main() {
 	flag.Parse()
 
-	if *scanPathFlag == "" || *rulePathFlag == "" {
-		log.Println("Usage: fraken -folder <path to scan> -rules <path to rules> [-magic <path to magics>] [-extrayara <path to file>]")
+	if (!*testRulesFlag && *scanPathFlag == "") || *rulePathFlag == "" {
+		log.Println("Usage: fraken -folder <path to scan> -rules <path to rules> [-magic <path to magics>] [-extrayara <path to file>] [-testrules]")
 		os.Exit(1)
 	}
 	if err := scanner.init(); err != nil {
 		log.Fatalf("Error initialising Yara engine: %v\n", err)
 	}
+	if *testRulesFlag {
+		log.Println("Rules test OK")
+		os.Exit(0)
+	}
 	if err := initMagics(); err != nil {
 		log.Println("Error initialising Magic file (continuing without it): ", err)
 	}
diff --git a/turbinia/config/rules/suspicious_cron.yar b/turbinia/config/rules/suspicious_cron.yar
index 98c1fbf..9b1b05f 100644
--- a/turbinia/config/rules/suspicious_cron.yar
+++ b/turbinia/config/rules/suspicious_cron.yar
@@ -15,5 +15,5 @@ rule suspicious_cron {
         $shell = "sh"
         $pgmem = "pg_mem"
    condition:
-      (filepath matches /cron/ or filepath matches /spool\/at/) and (($wget or all of ($options*)) or any of ($curl*) or $shm or $pgmem or (for all i in (1..#shell) : (@shell[i] > @pipechar[i])))
+      (filepath matches /cron/ or filepath matches /spool\/at/) and (($wget or all of ($options*)) or any of ($curl*) or $shm or $pgmem or (for all i in (1..#shell) : ((@shell[i] > @pipechar[i]) and (@shell[i] < @pipechar[i] + 10))))
 }
diff --git a/turbinia/config/turbinia_config_tmpl.py b/turbinia/config/turbinia_config_tmpl.py
index c24b2fc..ef2dc8f 100644
--- a/turbinia/config/turbinia_config_tmpl.py
+++ b/turbinia/config/turbinia_config_tmpl.py
@@ -156,6 +156,11 @@ DEPENDENCIES = [{
     'programs': ['bulk_extractor'],
     'docker_image': None,
     'timeout': 14400
+}, {
+    'job': 'ContainerdEnumerationJob',
+    'programs': ['/opt/container-explorer/bin/ce'],
+    'docker_image': None,
+    'timeout': 1200
 }, {
     'job': 'DfdeweyJob',
     'programs': ['dfdewey'],
diff --git a/turbinia/evidence.py b/turbinia/evidence.py
index 02def14..73a9d18 100644
--- a/turbinia/evidence.py
+++ b/turbinia/evidence.py
@@ -17,16 +17,20 @@
 from __future__ import unicode_literals
 
 from enum import IntEnum
+from collections import defaultdict
+
 import json
 import logging
 import os
 import sys
+import inspect
 import filelock
 
 from turbinia import config
 from turbinia import TurbiniaException
 from turbinia.lib.docker_manager import GetDockerPath
 from turbinia.processors import archive
+from turbinia.processors import containerd
 from turbinia.processors import docker
 from turbinia.processors import mount_local
 from turbinia.processors import resource_manager
@@ -38,6 +42,68 @@ if config.CLOUD_PROVIDER:
 log = logging.getLogger('turbinia')
 
 
+def evidence_class_names(all_classes=False):
+  """Returns a list of class names for the evidence module.
+
+  Args:
+    all_classes (bool): Flag to determine whether to include all classes
+        in the module.
+  
+  Returns:
+    class_names (list[str]): A list of class names within the Evidence module,
+        minus the ignored class names.
+  """
+  predicate = lambda member: inspect.isclass(member) and not inspect.isbuiltin(
+      member)
+  class_names = inspect.getmembers(sys.modules[__name__], predicate)
+  if not all_classes:
+    # TODO: Non-evidence types should be moved out of the evidence module,
+    # so that we no longer have to ignore certain classes here. Especially
+    # 'output' and 'report' types.
+    # Ignore classes that are not real Evidence types and the base class.
+    ignored_classes = (
+        'BinaryExtraction', 'BulkExtractorOutput', 'Evidence', 'EvidenceState',
+        'EvidenceCollection', 'ExportedFileArtifact', 'FilteredTextFile',
+        'FinalReport', 'IntEnum', 'PlasoCsvFile', 'PlasoFile', 'PhotorecOutput',
+        'ReportText', 'TextFile', 'VolatilityReport', 'TurbiniaException')
+    class_names = filter(
+        lambda class_tuple: class_tuple[0] not in ignored_classes, class_names)
+  return list(class_names)
+
+
+def map_evidence_attributes():
+  """Creates a dictionary that maps evidence types to their
+       constructor attributes.
+
+  Returns:
+    object_attribute_mapping (defaultdict): A mapping of evidence types
+        and their constructor attributes.
+  """
+  object_attribute_mapping = defaultdict(list)
+  for class_name, class_type in evidence_class_names():
+    try:
+      attributes_signature = inspect.signature(class_type)
+      attributes = attributes_signature.parameters.keys()
+      for attribute in attributes:
+        if not object_attribute_mapping[class_name]:
+          object_attribute_mapping[class_name] = defaultdict(dict)
+        # Ignore 'args' and 'kwargs' attributes.
+        if attribute not in ('args', 'kwargs'):
+          object_attribute_mapping[class_name][attribute] = {
+              'required': bool(attribute in class_type.REQUIRED_ATTRIBUTES),
+              'type': 'str'
+          }
+      # Add optional attributes.
+      for optional_attribute in Evidence.OPTIONAL_ATTRIBUTES:
+        object_attribute_mapping[class_name][optional_attribute] = {
+            'required': False,
+            'type': 'str'
+        }
+    except ValueError as exception:
+      log.info(exception)
+  return object_attribute_mapping
+
+
 def evidence_decode(evidence_dict, strict=False):
   """Decode JSON into appropriate Evidence object.
 
@@ -138,15 +204,24 @@ class Evidence:
         to the saved_path location.
     source (str): String indicating where evidence came from (including tool
         version that created it, if appropriate).
-    local_path (str): Path to the processed data (can be a blockdevice or a
-        mounted directory, etc).  This is the path that most Tasks should use to
-        access evidence after it's been processed on the worker (i.e. when the
-        Tasks `run()` method is called).  The last pre-processor to run should
-        set this path.
+    local_path (str): Generic path to the evidence data after pre-processing
+        has been run.  This is the path that most Tasks and any code that runs
+        after the pre-processors should use to access evidence. Depending on
+        the pre-processors and `REQUIRED_STATE` for the Task being run, this
+        could point to a blockdevice or a mounted directory. The last
+        pre-processor to run should always set this path. For example if the
+        Evidence is a `RawDisk`, the `source_path` will be a path to the image
+        file, then the pre-processors will (optionally, depending on the Task
+        requirements) create a loop device and mount it which will set the
+        `device_path` and `mount_path` respectively. After that, the
+        `local_path` should point to whatever path the last pre-processor has
+        created, in this case the mount_path.
     source_path (str): Path to the original un-processed source data for the
         Evidence.  This is the path that Evidence should be created and set up
-        with initially.  Tasks should generally not use this path, but instead
-        use the `local_path`.
+        with initially and used any time prior to when the pre-processors run.
+        Tasks should generally not use `source_path`, but instead use the
+        `local_path` (or other more specific paths like `device_path` or
+        `mount_path` depending on the Task requirements).
     mount_path (str): Path to a mounted file system (if relevant).
     credentials (list): Decryption keys for encrypted evidence.
     tags (dict): Extra tags associated with this evidence.
@@ -173,6 +248,10 @@ class Evidence:
   # The list of attributes a given piece of Evidence requires to be set
   REQUIRED_ATTRIBUTES = []
 
+  # An optional set of attributes that are generally used to describe
+  # a given piece of Evidence.
+  OPTIONAL_ATTRIBUTES = {'name', 'source', 'description', 'tags'}
+
   # The list of EvidenceState states that the Evidence supports in its
   # pre/post-processing (e.g. MOUNTED, ATTACHED, etc).  See `preprocessor()`
   # docstrings for more info.
@@ -218,6 +297,10 @@ class Evidence:
           'Unable to initialize object, {0:s} is a copyable '
           'evidence and needs a source_path'.format(self.type))
 
+    # TODO: Validating for required attributes breaks some units tests.
+    # Github issue: https://github.com/google/turbinia/issues/1136
+    # self.validate()
+
   def __str__(self):
     return '{0:s}:{1:s}:{2!s}'.format(self.type, self.name, self.source_path)
 
@@ -459,7 +542,7 @@ class Evidence:
         message = (
             'Evidence validation failed: Required attribute {0:s} for class '
             '{1:s} is not set. Please check original request.'.format(
-                attribute, self.name))
+                attribute, self.type))
         raise TurbiniaException(message)
 
 
@@ -491,8 +574,16 @@ class EvidenceCollection(Evidence):
 
 
 class Directory(Evidence):
-  """Filesystem directory evidence."""
-  pass
+  """Filesystem directory evidence.
+
+  Attributes:
+    source_path: The path to the source directory used as evidence.
+  """
+  REQUIRED_ATTRIBUTES = ['source_path']
+
+  def __init__(self, source_path=None, *args, **kwargs):
+    super(Directory, self).__init__(source_path=source_path, *args, **kwargs)
+    self.source_path = source_path
 
 
 class CompressedDirectory(Evidence):
@@ -502,16 +593,15 @@ class CompressedDirectory(Evidence):
     compressed_directory: The path to the compressed directory.
     uncompressed_directory: The path to the uncompressed directory.
   """
-
+  REQUIRED_ATTRIBUTES = ['source_path']
   POSSIBLE_STATES = [EvidenceState.DECOMPRESSED]
 
-  def __init__(
-      self, compressed_directory=None, uncompressed_directory=None, *args,
-      **kwargs):
+  def __init__(self, source_path=None, *args, **kwargs):
     """Initialization for CompressedDirectory evidence object."""
-    super(CompressedDirectory, self).__init__(*args, **kwargs)
-    self.compressed_directory = compressed_directory
-    self.uncompressed_directory = uncompressed_directory
+    super(CompressedDirectory, self).__init__(
+        source_path=source_path, *args, **kwargs)
+    self.compressed_directory = None
+    self.uncompressed_directory = None
     self.copyable = True
 
   def _preprocess(self, tmp_dir, required_states):
@@ -566,17 +656,17 @@ class RawDisk(Evidence):
   """Evidence object for Disk based evidence.
 
   Attributes:
-    device_path (str): Path to a relevant 'raw' data source (ie: a block
+    source_path (str): Path to a relevant 'raw' data source (ie: a block
         device or a raw disk image).
     mount_partition: The mount partition for this disk (if any).
   """
-
+  REQUIRED_ATTRIBUTES = ['source_path']
   POSSIBLE_STATES = [EvidenceState.ATTACHED]
 
-  def __init__(self, *args, **kwargs):
+  def __init__(self, source_path=None, *args, **kwargs):
     """Initialization for raw disk evidence object."""
+    super(RawDisk, self).__init__(source_path=source_path, *args, **kwargs)
     self.device_path = None
-    super(RawDisk, self).__init__(*args, **kwargs)
 
   def _preprocess(self, _, required_states):
     if self.size is None:
@@ -592,7 +682,7 @@ class RawDisk(Evidence):
       self.state[EvidenceState.ATTACHED] = False
 
 
-class DiskPartition(RawDisk):
+class DiskPartition(Evidence):
   """Evidence object for a partition within Disk based evidence.
 
   More information on dfVFS types:
@@ -605,17 +695,27 @@ class DiskPartition(RawDisk):
     partition_size (int): Size of the partition in bytes.
     path_spec (dfvfs.PathSpec): Partition path spec.
   """
-
   POSSIBLE_STATES = [EvidenceState.ATTACHED, EvidenceState.MOUNTED]
 
   def __init__(
       self, partition_location=None, partition_offset=None, partition_size=None,
       lv_uuid=None, path_spec=None, important=True, *args, **kwargs):
     """Initialization for raw volume evidence object."""
-
     self.partition_location = partition_location
-    self.partition_offset = partition_offset
-    self.partition_size = partition_size
+    if partition_offset:
+      try:
+        self.partition_offset = int(partition_offset)
+      except ValueError as exception:
+        log.error(
+            'Unable to cast partition_offset attribute to integer. {0!s}'
+            .format(exception))
+    if partition_size:
+      try:
+        self.partition_size = int(partition_size)
+      except ValueError as exception:
+        log.error(
+            'Unable to cast partition_size attribute to integer. {0!s}'.format(
+                exception))
     self.lv_uuid = lv_uuid
     self.path_spec = path_spec
     self.important = important
@@ -711,7 +811,7 @@ class DiskPartition(RawDisk):
         self.state[EvidenceState.ATTACHED] = False
 
 
-class GoogleCloudDisk(RawDisk):
+class GoogleCloudDisk(Evidence):
   """Evidence object for a Google Cloud Disk.
 
   Attributes:
@@ -720,22 +820,23 @@ class GoogleCloudDisk(RawDisk):
     disk_name: The cloud disk name.
   """
 
-  REQUIRED_ATTRIBUTES = ['disk_name', 'project', 'resource_id', 'zone']
+  REQUIRED_ATTRIBUTES = ['disk_name', 'project', 'zone']
   POSSIBLE_STATES = [EvidenceState.ATTACHED, EvidenceState.MOUNTED]
 
   def __init__(
       self, project=None, zone=None, disk_name=None, mount_partition=1, *args,
       **kwargs):
     """Initialization for Google Cloud Disk."""
+    super(GoogleCloudDisk, self).__init__(*args, **kwargs)
     self.project = project
     self.zone = zone
     self.disk_name = disk_name
     self.mount_partition = mount_partition
     self.partition_paths = None
-    super(GoogleCloudDisk, self).__init__(*args, **kwargs)
     self.cloud_only = True
     self.resource_tracked = True
     self.resource_id = self.disk_name
+    self.device_path = None
 
   def _preprocess(self, _, required_states):
     # The GoogleCloudDisk should never need to be mounted unless it has child
@@ -775,11 +876,14 @@ class GoogleCloudDiskRawEmbedded(GoogleCloudDisk):
   REQUIRED_ATTRIBUTES = ['disk_name', 'project', 'zone', 'embedded_path']
   POSSIBLE_STATES = [EvidenceState.ATTACHED]
 
-  def __init__(self, embedded_path=None, *args, **kwargs):
+  def __init__(
+      self, embedded_path=None, project=None, zone=None, disk_name=None,
+      mount_partition=1, *args, **kwargs):
     """Initialization for Google Cloud Disk containing a raw disk image."""
+    super(GoogleCloudDiskRawEmbedded, self).__init__(
+        project=project, zone=zone, disk_name=disk_name, mount_partition=1,
+        *args, **kwargs)
     self.embedded_path = embedded_path
-    super(GoogleCloudDiskRawEmbedded, self).__init__(*args, **kwargs)
-
     # This Evidence needs to have a GoogleCloudDisk as a parent
     self.context_dependent = True
 
@@ -853,8 +957,8 @@ class ReportText(Evidence):
   """Text data for general reporting."""
 
   def __init__(self, text_data=None, *args, **kwargs):
-    self.text_data = text_data
     super(ReportText, self).__init__(copyable=True, *args, **kwargs)
+    self.text_data = text_data
 
 
 class FinalReport(ReportText):
@@ -909,11 +1013,12 @@ class RawMemory(Evidence):
     module_list (list): Module used for the analysis
     """
 
-  REQUIRED_ATTRIBUTES = ['module_list', 'profile']
+  REQUIRED_ATTRIBUTES = ['source_path', 'module_list', 'profile']
 
-  def __init__(self, module_list=None, profile=None, *args, **kwargs):
+  def __init__(
+      self, source_path=None, module_list=None, profile=None, *args, **kwargs):
     """Initialization for raw memory evidence object."""
-    super(RawMemory, self).__init__(*args, **kwargs)
+    super(RawMemory, self).__init__(source_path=source_path, *args, **kwargs)
     self.profile = profile
     self.module_list = module_list
 
@@ -933,6 +1038,7 @@ class DockerContainer(Evidence):
     _docker_root_directory(str): Full path to the docker root directory.
   """
 
+  REQUIRED_ATTRIBUTES = ['container_id']
   POSSIBLE_STATES = [EvidenceState.CONTAINER_MOUNTED]
 
   def __init__(self, container_id=None, *args, **kwargs):
@@ -941,7 +1047,6 @@ class DockerContainer(Evidence):
     self.container_id = container_id
     self._container_fs_path = None
     self._docker_root_directory = None
-
     self.context_dependent = True
 
   @property
@@ -978,18 +1083,22 @@ class EwfDisk(Evidence):
   """Evidence object for a EWF based evidence.
 
   Attributes:
-    device_path (str): Path to the mounted loop device.
+    device_path (str): Path to a relevant 'raw' data source (ie: a block.
     ewf_path (str): Path to mounted EWF image.
     ewf_mount_path (str): Path to EWF mount directory.
   """
+  REQUIRED_ATTRIBUTES = ['source_path', 'ewf_path', 'ewf_mount_path']
   POSSIBLE_STATES = [EvidenceState.ATTACHED]
 
-  def __init__(self, *args, **kwargs):
+  def __init__(
+      self, source_path=None, ewf_path=None, ewf_mount_path=None, *args,
+      **kwargs):
     """Initialization for EWF evidence object."""
-    self.device_path = None
-    self.ewf_path = None
-    self.ewf_mount_path = None
     super(EwfDisk, self).__init__(*args, **kwargs)
+    self.source_path = source_path
+    self.ewf_path = ewf_path
+    self.ewf_mount_path = ewf_mount_path
+    self.device_path = None
 
   def _preprocess(self, _, required_states):
     if EvidenceState.ATTACHED in required_states or self.has_child_evidence:
@@ -1003,3 +1112,53 @@ class EwfDisk(Evidence):
     if self.state[EvidenceState.ATTACHED]:
       self.state[EvidenceState.ATTACHED] = False
       mount_local.PostprocessUnmountPath(self.ewf_mount_path)
+
+
+class ContainerdContainer(Evidence):
+  """Evidence object for a containerd evidence.
+
+  Attributes:
+    namespace (str): Namespace of the container to be mounted.
+    container_id (str): ID of the container to be mounted.
+    _image_path (str): Path where disk image is mounted.
+    _container_fs_path (str): Path where containerd filesystem is mounted.
+  """
+
+  POSSIBLE_STATES = [EvidenceState.CONTAINER_MOUNTED]
+
+  def __init__(self, namespace=None, container_id=None, *args, **kwargs):
+    """Initialization of containerd container."""
+    super(ContainerdContainer, self).__init__(*args, **kwargs)
+    self.namespace = namespace
+    self.container_id = container_id
+    self._image_path = None
+    self._container_fs_path = None
+
+    self.context_dependent = True
+
+  @property
+  def name(self):
+    if self._name:
+      return self._name
+
+    if self.parent_evidence:
+      return ':'.join((self.parent_evidence.name, self.container_id))
+    else:
+      return ':'.join((self.type, self.container_id))
+
+  def _preprocess(self, _, required_states):
+    if EvidenceState.CONTAINER_MOUNTED in required_states:
+      self._image_path = self.parent_evidence.mount_path
+
+      # Mount containerd container
+      self._container_fs_path = containerd.PreprocessMountContainerdFS(
+          self._image_path, self.namespace, self.container_id)
+      self.mount_path = self._container_fs_path
+      self.local_path = self.mount_path
+      self.state[EvidenceState.CONTAINER_MOUNTED] = True
+
+  def _postprocess(self):
+    if self.state[EvidenceState.CONTAINER_MOUNTED]:
+      # Unmount the container
+      mount_local.PostprocessUnmountPath(self._container_fs_path)
+      self.state[EvidenceState.CONTAINER_MOUNTED] = False
diff --git a/turbinia/jobs/__init__.py b/turbinia/jobs/__init__.py
index 3879242..8649154 100644
--- a/turbinia/jobs/__init__.py
+++ b/turbinia/jobs/__init__.py
@@ -15,6 +15,7 @@
 """Turbinia jobs."""
 from turbinia.jobs import binary_extractor
 from turbinia.jobs import bulk_extractor
+from turbinia.jobs import containerd
 from turbinia.jobs import dfdewey
 from turbinia.jobs import docker
 from turbinia.jobs import file_system_timeline
diff --git a/turbinia/jobs/containerd.py b/turbinia/jobs/containerd.py
new file mode 100644
index 0000000..7d5be84
--- /dev/null
+++ b/turbinia/jobs/containerd.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Job to enumerate and mount containerd containers."""
+
+from __future__ import unicode_literals
+
+from turbinia.evidence import DiskPartition
+from turbinia.evidence import ContainerdContainer
+from turbinia.jobs import interface
+from turbinia.jobs import manager
+from turbinia.workers.containerd import ContainerdEnumerationTask
+
+
+class ContainerdEnumerationJob(interface.TurbiniaJob):
+  """Containerd enumeration job."""
+
+  # Types of evidence that this job will process
+  evidence_input = [DiskPartition]
+  evidence_output = [ContainerdContainer]
+
+  NAME = 'ContainerdEnumerationJob'
+
+  def create_tasks(self, evidence):
+    """Create task.
+
+    Args:
+      evidence: List of evidence objects to process.
+
+    Returns:
+      A list of tasks to schedule.
+    """
+    tasks = [ContainerdEnumerationTask() for _ in evidence]
+    return tasks
+
+
+manager.JobsManager.RegisterJob(ContainerdEnumerationJob)
diff --git a/turbinia/jobs/http_access_logs.py b/turbinia/jobs/http_access_logs.py
index 03a4726..4795e74 100644
--- a/turbinia/jobs/http_access_logs.py
+++ b/turbinia/jobs/http_access_logs.py
@@ -16,6 +16,7 @@
 from __future__ import unicode_literals
 
 from turbinia.evidence import Directory
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import DockerContainer
 from turbinia.evidence import ExportedFileArtifact
 from turbinia.evidence import GoogleCloudDisk
@@ -37,8 +38,8 @@ class HTTPAccessLogExtractionJob(interface.TurbiniaJob):
   """HTTP Access log extraction job."""
 
   evidence_input = [
-      Directory, DockerContainer, GoogleCloudDisk, GoogleCloudDiskRawEmbedded,
-      RawDisk, EwfDisk
+      ContainerdContainer, Directory, DockerContainer, EwfDisk, GoogleCloudDisk,
+      GoogleCloudDiskRawEmbedded, RawDisk
   ]
   evidence_output = [ExportedFileArtifact]
 
diff --git a/turbinia/jobs/jenkins.py b/turbinia/jobs/jenkins.py
index f82c80d..dd34830 100644
--- a/turbinia/jobs/jenkins.py
+++ b/turbinia/jobs/jenkins.py
@@ -16,9 +16,10 @@
 
 from __future__ import unicode_literals
 
-from turbinia.evidence import EwfDisk
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import Directory
 from turbinia.evidence import DockerContainer
+from turbinia.evidence import EwfDisk
 from turbinia.evidence import GoogleCloudDisk
 from turbinia.evidence import GoogleCloudDiskRawEmbedded
 from turbinia.evidence import RawDisk
@@ -32,8 +33,8 @@ class JenkinsAnalysisJob(interface.TurbiniaJob):
   """Jenkins analysis job."""
 
   evidence_input = [
-      Directory, DockerContainer, GoogleCloudDisk, GoogleCloudDiskRawEmbedded,
-      RawDisk, EwfDisk
+      ContainerdContainer, Directory, DockerContainer, EwfDisk, GoogleCloudDisk,
+      GoogleCloudDiskRawEmbedded, RawDisk
   ]
   evidence_output = [ReportText]
 
diff --git a/turbinia/jobs/jupyter.py b/turbinia/jobs/jupyter.py
index 4a72bb3..541e42c 100644
--- a/turbinia/jobs/jupyter.py
+++ b/turbinia/jobs/jupyter.py
@@ -16,10 +16,11 @@
 
 from __future__ import unicode_literals
 
-from turbinia.evidence import EwfDisk
 from turbinia.workers import artifact
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import Directory
 from turbinia.evidence import DockerContainer
+from turbinia.evidence import EwfDisk
 from turbinia.evidence import GoogleCloudDisk
 from turbinia.evidence import GoogleCloudDiskRawEmbedded
 from turbinia.evidence import RawDisk
@@ -35,7 +36,7 @@ class JupyterExtractionJob(interface.TurbiniaJob):
 
   # The types of evidence that this Job will process
   evidence_input = [
-      EwfDisk, Directory, DockerContainer, GoogleCloudDisk,
+      ContainerdContainer, Directory, DockerContainer, EwfDisk, GoogleCloudDisk,
       GoogleCloudDiskRawEmbedded, RawDisk
   ]
   evidence_output = [ExportedFileArtifact]
diff --git a/turbinia/jobs/linux_acct.py b/turbinia/jobs/linux_acct.py
index b3805b7..9559311 100644
--- a/turbinia/jobs/linux_acct.py
+++ b/turbinia/jobs/linux_acct.py
@@ -14,10 +14,11 @@
 # limitations under the License.
 """Job to execute linux_acct analysis task."""
 
-from turbinia.evidence import EwfDisk
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import CompressedDirectory
 from turbinia.evidence import Directory
 from turbinia.evidence import DockerContainer
+from turbinia.evidence import EwfDisk
 from turbinia.evidence import GoogleCloudDisk
 from turbinia.evidence import GoogleCloudDiskRawEmbedded
 from turbinia.evidence import RawDisk
@@ -31,8 +32,8 @@ class LinuxAccountAnalysisJob(interface.TurbiniaJob):
   """Linux Account analysis job."""
 
   evidence_input = [
-      EwfDisk, CompressedDirectory, Directory, DockerContainer, GoogleCloudDisk,
-      GoogleCloudDiskRawEmbedded, RawDisk
+      CompressedDirectory, ContainerdContainer, Directory, DockerContainer,
+      EwfDisk, GoogleCloudDisk, GoogleCloudDiskRawEmbedded, RawDisk
   ]
   evidence_output = [ReportText]
 
diff --git a/turbinia/jobs/plaso.py b/turbinia/jobs/plaso.py
index a2d6088..c0bd5b5 100644
--- a/turbinia/jobs/plaso.py
+++ b/turbinia/jobs/plaso.py
@@ -20,6 +20,7 @@ import logging
 
 from turbinia.evidence import EwfDisk
 from turbinia.evidence import BodyFile
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import DockerContainer
 from turbinia.evidence import CompressedDirectory
 from turbinia.evidence import Directory
@@ -37,8 +38,9 @@ class PlasoJob(interface.TurbiniaJob):
   """Runs Plaso on some evidence to generate a Plaso file."""
   # The types of evidence that this Job will process
   evidence_input = [
-      BodyFile, Directory, EwfDisk, RawDisk, GoogleCloudDisk,
-      GoogleCloudDiskRawEmbedded, CompressedDirectory, DockerContainer
+      BodyFile, ContainerdContainer, Directory, EwfDisk, RawDisk,
+      GoogleCloudDisk, GoogleCloudDiskRawEmbedded, CompressedDirectory,
+      DockerContainer
   ]
   evidence_output = [PlasoFile]
 
diff --git a/turbinia/jobs/postgres_acct.py b/turbinia/jobs/postgres_acct.py
index d89af53..57425dc 100644
--- a/turbinia/jobs/postgres_acct.py
+++ b/turbinia/jobs/postgres_acct.py
@@ -15,6 +15,7 @@
 """Job to execute postgresql_acct analysis task."""
 
 from turbinia.evidence import CompressedDirectory
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import Directory
 from turbinia.evidence import DiskPartition
 from turbinia.evidence import DockerContainer
@@ -28,7 +29,8 @@ class PostgresAcctAnalysisJob(interface.TurbiniaJob):
   """PostgreSQL Account analysis job."""
 
   evidence_input = [
-      Directory, DiskPartition, CompressedDirectory, DockerContainer
+      Directory, DiskPartition, CompressedDirectory, DockerContainer,
+      ContainerdContainer
   ]
   evidence_output = [ReportText]
 
diff --git a/turbinia/jobs/redis.py b/turbinia/jobs/redis.py
index 520f6c6..02f2f9c 100644
--- a/turbinia/jobs/redis.py
+++ b/turbinia/jobs/redis.py
@@ -18,6 +18,7 @@ from __future__ import unicode_literals
 
 from turbinia.workers import artifact
 from turbinia.workers import redis
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import Directory
 from turbinia.evidence import DockerContainer
 from turbinia.evidence import GoogleCloudDisk
@@ -35,8 +36,8 @@ class RedisExtractionJob(interface.TurbiniaJob):
 
   # The types of evidence that this Job will process
   evidence_input = [
-      Directory, DockerContainer, GoogleCloudDisk, GoogleCloudDiskRawEmbedded,
-      RawDisk, EwfDisk
+      ContainerdContainer, Directory, DockerContainer, GoogleCloudDisk,
+      GoogleCloudDiskRawEmbedded, RawDisk, EwfDisk
   ]
   evidence_output = [ExportedFileArtifact]
 
diff --git a/turbinia/jobs/sshd.py b/turbinia/jobs/sshd.py
index 1f08ced..0301257 100644
--- a/turbinia/jobs/sshd.py
+++ b/turbinia/jobs/sshd.py
@@ -18,6 +18,7 @@ from __future__ import unicode_literals
 
 from turbinia.workers import artifact
 from turbinia.workers import sshd
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import Directory
 from turbinia.evidence import DockerContainer
 from turbinia.evidence import GoogleCloudDisk
@@ -35,8 +36,8 @@ class SSHDExtractionJob(interface.TurbiniaJob):
 
   # The types of evidence that this Job will process
   evidence_input = [
-      Directory, DockerContainer, GoogleCloudDisk, GoogleCloudDiskRawEmbedded,
-      RawDisk, EwfDisk
+      ContainerdContainer, Directory, DockerContainer, GoogleCloudDisk,
+      GoogleCloudDiskRawEmbedded, RawDisk, EwfDisk
   ]
   evidence_output = [ExportedFileArtifact]
 
diff --git a/turbinia/jobs/tomcat.py b/turbinia/jobs/tomcat.py
index 4445cbf..df47aa2 100644
--- a/turbinia/jobs/tomcat.py
+++ b/turbinia/jobs/tomcat.py
@@ -16,6 +16,7 @@
 from __future__ import unicode_literals
 from turbinia.workers import artifact
 from turbinia.workers import tomcat
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import Directory
 from turbinia.evidence import DockerContainer
 from turbinia.evidence import GoogleCloudDisk
@@ -34,7 +35,7 @@ class TomcatExtractionJob(interface.TurbiniaJob):
   # The types of evidence that this Job will process
   evidence_input = [
       Directory, DockerContainer, GoogleCloudDisk, GoogleCloudDiskRawEmbedded,
-      RawDisk, EwfDisk
+      RawDisk, EwfDisk, ContainerdContainer
   ]
   evidence_output = [ExportedFileArtifact]
 
diff --git a/turbinia/jobs/windows_acct.py b/turbinia/jobs/windows_acct.py
index b1772bc..2112ed8 100644
--- a/turbinia/jobs/windows_acct.py
+++ b/turbinia/jobs/windows_acct.py
@@ -14,6 +14,7 @@
 # limitations under the License.
 """Job to execute windows_acct analysis task."""
 
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import CompressedDirectory
 from turbinia.evidence import Directory
 from turbinia.evidence import DockerContainer
@@ -31,8 +32,8 @@ class WindowsAccountAnalysisJob(interface.TurbiniaJob):
   """Windows Account analysis job."""
 
   evidence_input = [
-      Directory, RawDisk, GoogleCloudDisk, GoogleCloudDiskRawEmbedded,
-      CompressedDirectory, DockerContainer, EwfDisk
+      ContainerdContainer, Directory, RawDisk, GoogleCloudDisk,
+      GoogleCloudDiskRawEmbedded, CompressedDirectory, DockerContainer, EwfDisk
   ]
   evidence_output = [ReportText]
 
diff --git a/turbinia/jobs/wordpress_creds.py b/turbinia/jobs/wordpress_creds.py
index 93fb338..0aedc87 100644
--- a/turbinia/jobs/wordpress_creds.py
+++ b/turbinia/jobs/wordpress_creds.py
@@ -14,6 +14,7 @@
 # limitations under the License.
 """Job to execute wordpress_creds analysis task."""
 
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import CompressedDirectory
 from turbinia.evidence import Directory
 from turbinia.evidence import DockerContainer
@@ -31,8 +32,8 @@ class WordpressCredsAnalysisJob(interface.TurbiniaJob):
   """Wordpress Account analysis job."""
 
   evidence_input = [
-      Directory, RawDisk, GoogleCloudDisk, GoogleCloudDiskRawEmbedded,
-      CompressedDirectory, DockerContainer, EwfDisk
+      ContainerdContainer, Directory, RawDisk, GoogleCloudDisk,
+      GoogleCloudDiskRawEmbedded, CompressedDirectory, DockerContainer, EwfDisk
   ]
   evidence_output = [ReportText]
 
diff --git a/turbinia/jobs/yara.py b/turbinia/jobs/yara.py
index 71e9585..fe93333 100644
--- a/turbinia/jobs/yara.py
+++ b/turbinia/jobs/yara.py
@@ -15,6 +15,7 @@
 """Job to execute Yara analysis tasks."""
 
 from turbinia.evidence import CompressedDirectory
+from turbinia.evidence import ContainerdContainer
 from turbinia.evidence import Directory
 from turbinia.evidence import DiskPartition
 from turbinia.evidence import DockerContainer
@@ -28,7 +29,8 @@ class YaraAnalysisJob(interface.TurbiniaJob):
   """Yara analysis job."""
 
   evidence_input = [
-      Directory, DiskPartition, CompressedDirectory, DockerContainer
+      CompressedDirectory, ContainerdContainer, Directory, DiskPartition,
+      DockerContainer
   ]
   evidence_output = [ReportText]
 
diff --git a/turbinia/processors/containerd.py b/turbinia/processors/containerd.py
new file mode 100644
index 0000000..b47bef9
--- /dev/null
+++ b/turbinia/processors/containerd.py
@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Evidence processor to mount local containerd containers."""
+
+from __future__ import unicode_literals
+
+import logging
+import os
+import subprocess
+import tempfile
+
+from turbinia import config
+from turbinia import TurbiniaException
+
+log = logging.getLogger('turbinia')
+
+
+def PreprocessMountContainerdFS(image_path, namespace, container_id):
+  """Mounts a containerd container filesystem locally.
+
+  Args:
+    image_path (str): Path where evidence disk is mounted.
+    namespace (str): Namespace of the container to be mounted.
+    container_id (str): ID of the container to be mounted.
+
+  Returns:
+    str: Path where container is mounted.
+  """
+  config.LoadConfig()
+  mount_prefix = config.MOUNT_DIR_PREFIX
+
+  containerd_dir = get_containerd_dir(image_path)
+  if not os.path.isdir(containerd_dir):
+    raise TurbiniaException(f'containerd path {containerd_dir} is not valid.')
+
+  if os.path.exists(mount_prefix) and not os.path.isdir(mount_prefix):
+    raise TurbiniaException(
+        f'Mount directory {mount_prefix} is not a directory')
+
+  if not os.path.exists(mount_prefix):
+    log.info(f'Creating local mount parent directory {mount_prefix}')
+    try:
+      os.makedirs(mount_prefix)
+    except OSError as e:
+      raise TurbiniaException(
+          f'Could not create mount directory {mount_prefix}: {e}') from e
+
+  # Generate predectible containerd mount path
+  containerd_mount_path = tempfile.mkdtemp(
+      prefix=f'{namespace}_{container_id}_', dir=mount_prefix)
+
+  ce_binary = '/opt/container-explorer/bin/ce'
+  ce_support = '/opt/container-explorer/etc/supportcontainer.yaml'
+  mount_cmd = [
+      'sudo', ce_binary, '--support-container-data', ce_support, '-i',
+      image_path, '-n', namespace, 'mount', container_id, containerd_mount_path
+  ]
+
+  log.info(f'Running: {mount_cmd}')
+  try:
+    subprocess.check_call(mount_cmd)
+  except subprocess.CalledProcessError as e:
+    raise TurbiniaException(
+        f'Could not mount {namespace}:{container_id}: {e}') from e
+
+  return containerd_mount_path
+
+
+def get_containerd_dir(image_path):
+  """Return containerd directory in mounted disk image.
+
+  Args:
+    image_path (str): Path where evidence disk is mounted.
+
+  Returns:
+    str: Path of containerd directory.
+  """
+  # Assuming containerd is installed on the default location
+  # i.e. /var/lib/containerd
+  containerd_dir = os.path.join(image_path, 'var', 'lib', 'containerd')
+
+  # TODO(rmaskey): Handle if containerd is not installed on a default path.
+  return containerd_dir
diff --git a/turbinia/task_utils.py b/turbinia/task_utils.py
index b46b518..cd8f9ac 100644
--- a/turbinia/task_utils.py
+++ b/turbinia/task_utils.py
@@ -40,6 +40,7 @@ class TaskLoader():
       'AbortTask',
       'BinaryExtractorTask',
       'BulkExtractorTask',
+      'ContainerdEnumerationTask',
       'DfdeweyTask',
       'DockerContainersEnumerationTask',
       'FileArtifactExtractionTask',
@@ -109,6 +110,7 @@ class TaskLoader():
     from turbinia.workers.artifact import FileArtifactExtractionTask
     from turbinia.workers.binary_extractor import BinaryExtractorTask
     from turbinia.workers.bulk_extractor import BulkExtractorTask
+    from turbinia.workers.containerd import ContainerdEnumerationTask
     from turbinia.workers.dfdewey import DfdeweyTask
     from turbinia.workers.docker import DockerContainersEnumerationTask
     from turbinia.workers.file_system_timeline import FileSystemTimelineTask
diff --git a/turbinia/workers/analysis/yara.py b/turbinia/workers/analysis/yara.py
index 1a82aed..6cf336a 100644
--- a/turbinia/workers/analysis/yara.py
+++ b/turbinia/workers/analysis/yara.py
@@ -104,9 +104,11 @@ class YaraAnalysisTask(TurbiniaTask):
         cmd, result, stderr_file=stderr_file, stdout_file=stdout_file)
 
     if ret != 0:
-      error = "Unknown"
-      with open(stderr_file, 'r') as f:
-        error = f.readlines()
+      if os.path.exists(stderr_file):
+        with open(stderr_file, 'r') as f:
+          error = f.readlines()
+      else:
+        error = "Unknown (no stderr)"
       raise TurbiniaException(
           'Return code: {0:d}. Error: {1!s}'.format(ret, error))
 
@@ -122,7 +124,8 @@ class YaraAnalysisTask(TurbiniaTask):
       with open(stdout_file, 'r') as fraken_report:
         try:
           fraken_output = json.load(fraken_report)
-        except (TypeError, ValueError, json.JSONDecodeError) as exception:
+        except (TypeError, ValueError, FileNotFoundError,
+                json.JSONDecodeError) as exception:
           raise TurbiniaException(
               'Error decoding JSON output from fraken: {0!s}'.format(exception))
         for row in fraken_output:
diff --git a/turbinia/workers/containerd.py b/turbinia/workers/containerd.py
new file mode 100644
index 0000000..0190d54
--- /dev/null
+++ b/turbinia/workers/containerd.py
@@ -0,0 +1,166 @@
+# -*- coding: utf-8 -*-
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Task for analyzing containerd containers."""
+
+from __future__ import unicode_literals
+
+import json
+import logging
+import subprocess
+import tempfile
+
+from turbinia import config
+from turbinia import TurbiniaException
+from turbinia.evidence import ContainerdContainer
+from turbinia.evidence import EvidenceState as state
+from turbinia.workers import Priority
+from turbinia.workers import TurbiniaTask
+
+log = logging.getLogger('turbinia')
+
+CE_BINARY = '/opt/container-explorer/bin/ce'
+CE_SUPPORT_FILE = '/opt/container-explorer/etc/supportcontainer.yaml'
+
+
+class ContainerdEnumerationTask(TurbiniaTask):
+  """Enumerate containerd containers on Linux."""
+
+  REQUIRED_STATES = [state.ATTACHED, state.MOUNTED]
+
+  def list_containers(self, evidence, _, detailed_output=False):
+    """List containerd containers in the evidence.
+
+    Args:
+      evidence (Evidence): Input evidence to be processed.
+      result (TurbiniaTaskResult): Object to store logs.
+
+    Returns:
+      list(dict): Containers information
+    """
+    config.LoadConfig()
+    containers = None
+    image_path = evidence.mount_path
+
+    outputfile = tempfile.mkstemp()[1]
+    list_cmd = [
+        'sudo', CE_BINARY, '--support-container-data', CE_SUPPORT_FILE,
+        '--output', 'json', '--output-file', outputfile, '--image-root',
+        image_path, 'list', 'containers'
+    ]
+    log.info(f'Running {list_cmd}')
+
+    try:
+      #json_data = subprocess.check_output(list_cmd).decode('utf-8')
+      subprocess.check_call(list_cmd)
+      with open(outputfile, 'r') as fh:
+        json_data = fh.read()
+        if json_data:
+          containers = json.loads(json_data)
+    except json.JSONDecodeError as e:
+      raise TurbiniaException(
+          f'Error decoding container-explorer output: {e}') from e
+    except subprocess.CalledProcessError as e:
+      raise TurbiniaException(f'container-explorer return error: {e}') from e
+    except FileNotFoundError as e:
+      raise TurbiniaException(f'output file {outputfile} does not exist') from e
+
+    return self._list_containers_result(containers, detailed_output)
+
+  def _list_containers_result(self, containers, detailed_output):
+    """ Determine and return containers information.
+
+    Args:
+      containers (list(dict)): Containers information
+      detailed_output (bool): Check if detailed output is required.
+
+    Returns:
+      list(dict): List containers basic or complete output.
+    """
+    if not containers:
+      return containers
+
+    if detailed_output:
+      return containers
+
+    basic_fields = [
+        'Namespace', 'Image', 'ContainerType', 'ID', 'Hostname', 'CreatedAt'
+        'Labels'
+    ]
+    basic_containers = []
+
+    for container in containers:
+      basic_container = {}
+      for key, value in container.items():
+        if key not in basic_fields:
+          continue
+        basic_container[key] = value
+      basic_containers.append(basic_container)
+
+    return basic_containers
+
+  def run(self, evidence, result):
+    """Run ContainerdEnumerationTask.
+
+    Args:
+      evidence (Evidence): Evidence to process.
+      result (TurbiniaTaskResult): The object to place task result.
+
+    Returns:
+      TurbiniaTaskResult object
+    """
+    summary = ''
+    success = False
+
+    image_path = evidence.mount_path
+    if not image_path:
+      summary = f'Evidence {evidence.name}:{evidence.source_path} is not mounted'
+      result.close(self, success=False, status=summary)
+      return result
+
+    try:
+      # 1. List containers
+      containers = self.list_containers(evidence, result)
+      if not containers:
+        result.close(self, success=True, status='Found 0 containers')
+        return result
+
+      container_ids = [x.get('ID') for x in containers]
+
+      # 2. Add containers as evidences
+      for container in containers:
+        namespace = container.get('Namespace')
+        container_id = container.get('ID')
+
+        if not namespace or not container_id:
+          result.log(
+              f'Value is empty. namespace={namespace}, container_id={container_id}'
+          )
+          continue
+
+        container_evidence = ContainerdContainer(
+            namespace=namespace, container_id=container_id)
+
+        result.add_evidence(container_evidence, evidence.config)
+      summary = (
+          f'Found {len(container_ids)} containers: {", ".join(container_ids)}')
+      success = True
+    except TurbiniaException as e:
+      summary = f'Error enumerating containerd containers: {e}'
+
+    # 3. Prepare result
+    result.report_priority = Priority.LOW
+    result.report_data = summary
+    result.close(self, success=success, status=summary)
+    return result
diff --git a/turbinia/workers/plaso.py b/turbinia/workers/plaso.py
index 7d63b86..f43cefa 100644
--- a/turbinia/workers/plaso.py
+++ b/turbinia/workers/plaso.py
@@ -34,6 +34,22 @@ class PlasoTask(TurbiniaTask):
       state.ATTACHED, state.DECOMPRESSED, state.CONTAINER_MOUNTED
   ]
 
+  def test_yara_rules(self, file_path, result):
+    """Test the given Yara rules for syntactical validity before processing.
+
+    Args:
+      file_path (str): Location on disk of the Yara rules to be tested.
+      result (TurbiniaTaskResult): The object to place task results into.
+    
+    Returns:
+      True if rules are good, else False
+    """
+    cmd = ['/opt/fraken/fraken', '-rules', file_path, '-testrules']
+    (ret, _) = self.execute(cmd, result)
+    if ret == 0:
+      return True
+    return False
+
   def build_plaso_command(self, base_command, conf):
     """Builds a typical plaso command, contains logic specific to log2timeline.
 
@@ -66,7 +82,9 @@ class PlasoTask(TurbiniaTask):
       elif k == 'yara_rules':
         file_path = file_helpers.write_str_to_temp_file(
             v, preferred_dir=self.tmp_dir)
-        cmd.extend(['--yara_rules', file_path])
+        rules_check = self.test_yara_rules(file_path, self.result)
+        if rules_check:
+          cmd.extend(['--yara_rules', file_path])
       elif isinstance(v, list):
         cmd.extend([prepend + k, ','.join(v)])
       elif isinstance(v, bool):
diff --git a/web/.gitignore b/web/.gitignore
index 43d9033..f19186b 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -2,7 +2,6 @@
 node_modules
 /dist
 
-
 # local env files
 .env.local
 .env.*.local
diff --git a/web/package-lock.json b/web/package-lock.json
index aca70c9..784b754 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -11,7 +11,7 @@
         "axios": "^0.27.2",
         "core-js": "^3.8.3",
         "vue": "^2.6.14",
-        "vuetify": "^2.6.0"
+        "vuetify": "^2.6.10"
       },
       "devDependencies": {
         "@babel/core": "^7.12.16",
@@ -11020,9 +11020,9 @@
       "dev": true
     },
     "node_modules/vuetify": {
-      "version": "2.6.4",
-      "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.6.4.tgz",
-      "integrity": "sha512-2wEzU/Gz39gQCxK93xoiWPKCHQUnyUKWd81wB7Q7hfYJWu5QOWQXYlF0X/BgUZzf8IOyHWKiSNEAfEe9OE3b4w==",
+      "version": "2.6.10",
+      "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.6.10.tgz",
+      "integrity": "sha512-fgUeRDDCwYkwu6WGEEKGe7IdfzOsXJCZGrqkn1pcS2ycuoDL8mR2/dejH5iFNnBY6MnsT365PAGn0J+9otjfQg==",
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/johnleider"
@@ -19950,9 +19950,9 @@
       "dev": true
     },
     "vuetify": {
-      "version": "2.6.4",
-      "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.6.4.tgz",
-      "integrity": "sha512-2wEzU/Gz39gQCxK93xoiWPKCHQUnyUKWd81wB7Q7hfYJWu5QOWQXYlF0X/BgUZzf8IOyHWKiSNEAfEe9OE3b4w==",
+      "version": "2.6.10",
+      "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.6.10.tgz",
+      "integrity": "sha512-fgUeRDDCwYkwu6WGEEKGe7IdfzOsXJCZGrqkn1pcS2ycuoDL8mR2/dejH5iFNnBY6MnsT365PAGn0J+9otjfQg==",
       "requires": {}
     },
     "vuetify-loader": {
diff --git a/web/package.json b/web/package.json
index 388984b..dcb0d26 100644
--- a/web/package.json
+++ b/web/package.json
@@ -11,7 +11,7 @@
     "axios": "^0.27.2",
     "core-js": "^3.8.3",
     "vue": "^2.6.14",
-    "vuetify": "^2.6.0"
+    "vuetify": "^2.6.10"
   },
   "devDependencies": {
     "@babel/core": "^7.12.16",


Test patch:
diff --git a/k8s/tools/load-test.sh b/k8s/tools/load-test.sh
new file mode 100644
index 0000000..c32ae40
--- /dev/null
+++ b/k8s/tools/load-test.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+# Turbinia load test script
+# This script can be used to run load tests against a Turbinia GKE instance.
+# Please review the config file and make any necessary changes prior to executing
+# this script.
+
+set -o posix
+set -e
+
+OUTPUT_LOG=$1
+if [ -z $1 ]; then
+    echo "No output path found, please specify a path for the load test logs."
+    exit 0
+fi
+
+# Please ensure the disks you are testing have been created prior to running
+# this script as it does not handle test disk creation. Once that is done, update
+# the values below with the correct test disks.
+MAX_DISKS=5
+DISK_NAME='test-disk-25gb'
+DISK_ZONE='us-central1-f'
+
+for i in $(seq 1 $MAX_DISKS};
+do
+    echo -n "Start" > $OUTPUT_LOG/$DISK_NAME-$i.log
+    date >> $OUTPUT_LOG/$DISK_NAME-$i.log
+    { time turbiniactl -w googleclouddisk -d $DISK_NAME-$i -z $DISK_ZONE ; } >> $OUTPUT_LOG/$DISK_NAME-$i.log 2>&1 & 
+    sleep 2
+done
\ No newline at end of file
diff --git a/turbinia/workers/analysis/yara_test.py b/turbinia/workers/analysis/yara_test.py
index c7569d3..9cf09b2 100644
--- a/turbinia/workers/analysis/yara_test.py
+++ b/turbinia/workers/analysis/yara_test.py
@@ -16,10 +16,12 @@
 
 import logging
 import os
+import mock
 import sys
 import tempfile
 import unittest
 
+from turbinia import TurbiniaException
 from turbinia.workers.analysis import yara
 from turbinia.workers.workers_test import TestTurbiniaTaskBase
 
@@ -64,6 +66,16 @@ class YaraAnalysisTaskTest(TestTurbiniaTaskBase):
     self.assertIn('Hadoop', report)
     self.assertIn('Gitlab', report)
 
+  def test_yara_no_stderr(self):
+    """Tests the runFraken method errors with no stderr output."""
+    self.task.execute = mock.MagicMock()
+    # Mocking execute means the stderr file will never get created.
+    self.task.execute.return_value = (1, mock.MagicMock())
+
+    self.assertRaisesRegex(
+        TurbiniaException, '.*Unknown \(no stderr\).*', self.task.runFraken,
+        self.result, self.evidence)
+
 
 if __name__ == '__main__':
   unittest.main()
diff --git a/turbinia/workers/containerd_test.py b/turbinia/workers/containerd_test.py
new file mode 100644
index 0000000..ef576b7
--- /dev/null
+++ b/turbinia/workers/containerd_test.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for ContainerdEnumerationTask."""
+
+from __future__ import unicode_literals
+
+import unittest
+import mock
+
+from turbinia.evidence import ContainerdContainer
+from turbinia.workers.containerd import ContainerdEnumerationTask
+from turbinia.workers.workers_test import TestTurbiniaTaskBase
+from turbinia.workers import TurbiniaTaskResult
+
+
+class ContainerdEnumerationTaskTest(TestTurbiniaTaskBase):
+  """Tests for ContainerdEnumerationTask."""
+
+  def setUp(self):
+    super(ContainerdEnumerationTaskTest, self).setUp(
+        task_class=ContainerdEnumerationTask,
+        evidence_class=ContainerdContainer)
+    self.setResults(mock_run=False)
+    self.task.output_dir = self.task.base_output_dir
+    self.evidence.mount_path = 'non_existent'
+
+  @mock.patch('turbinia.state_manager.get_state_manager')
+  @mock.patch(
+      'turbinia.workers.containerd.ContainerdEnumerationTask.list_containers')
+  def testContainerdEnumerationTaskRun(self, list_containers_mock, _):
+    """Test ContainerdEnumerationTask run."""
+    self.result.setup(self.task)
+
+    list_containers_mock.return_value = [
+        {
+            'Namespace': 'default',
+            'ID': 'nginx01',
+        },
+        {
+            'Namespace': 'default',
+            'ID': 'apache01',
+        },
+    ]
+    result = self.task.run(self.evidence, self.result)
+
+    # Ensure run method returns a TurbiniaTaskResult instance.
+    self.assertIsInstance(result, TurbiniaTaskResult)
+    self.assertEqual(result.task_name, 'ContainerdEnumerationTask')
+    self.assertEqual(len(result.evidence), 2)
+    self.assertEqual(
+        result.report_data, 'Found 2 containers: nginx01, apache01')
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/turbinia/workers/plaso_test.py b/turbinia/workers/plaso_test.py
new file mode 100644
index 0000000..c0584bc
--- /dev/null
+++ b/turbinia/workers/plaso_test.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# Copyright 2022 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for the Plaso worker task."""
+
+import logging
+import os
+import sys
+
+from turbinia.workers import plaso
+from turbinia.workers.workers_test import TestTurbiniaTaskBase
+
+
+class PlasoTaskTest(TestTurbiniaTaskBase):
+  """Tests for PlasoTask."""
+
+  _BASE_COMMAND = "log2timeline.py"
+  _YARA_RULE = """
+rule rulename {
+  strings:
+    $one = "one"
+  condition:
+    all of them
+}
+  """
+
+  def setUp(self):
+    super(PlasoTaskTest, self).setUp()
+    logging.basicConfig(stream=sys.stderr)
+    self.setResults(mock_run=True)
+    self.plaso_task.result = self.result
+    self.plaso_task.job_name = 'PlasoJob'
+
+  def test_build_command_no_cli_arg(self):
+    command = self.plaso_task.build_plaso_command(self._BASE_COMMAND, {})
+    self.assertEqual(command, [self._BASE_COMMAND])
+
+  def test_build_command_bad_cli_arg(self):
+    command = self.plaso_task.build_plaso_command(
+        self._BASE_COMMAND, {'badArg': 'test'})
+    self.assertEqual(command, [self._BASE_COMMAND])
+
+  def test_build_command_good_cli_arg(self):
+    command = self.plaso_task.build_plaso_command(
+        self._BASE_COMMAND, {'status_view': 'none'})
+    self.assertEqual(command, [self._BASE_COMMAND, "--status_view", "none"])
+    return
+
+  def test_build_command_good_yara(self):
+    if not os.path.isfile(os.path.expanduser('/opt/fraken/fraken')):
+      logging.getLogger('turbinia').error('Fraken not installed')
+      return
+    command = self.plaso_task.build_plaso_command(
+        self._BASE_COMMAND, {'yara_rules': self._YARA_RULE})
+    self.assertIn("--yara_rules", command)
+
+  def test_build_command_bad_yara(self):
+    if not os.path.isfile(os.path.expanduser('/opt/fraken/fraken')):
+      logging.getLogger('turbinia').error('Fraken not installed')
+      return
+    command = self.plaso_task.build_plaso_command(
+        self._BASE_COMMAND, {'yara_rules': self._YARA_RULE + self._YARA_RULE})
+    self.assertNotIn("--yara_rules", command)
\ No newline at end of file