SageMaker training infrastructure for the image classification sample: data and model buckets, the SageMaker pipeline (validation, preprocessing, three trainers, evaluation, ensemble, fairness check, clinical quality gate, registration), the Model Registry, a Model Card and cost controls.
- Creates a KMS key with rotation for the stack's buckets, logs and SageMaker jobs
- Creates 6 S3 buckets (raw data, processed data, scripts, model artifacts, inference results, monitoring), plus an SBOM bucket when
enable_sbom_bucket = true(default) and a CloudTrail bucket whenenable_cloudtrail = true(defaultfalse).force_destroyisfalseon all of them - Creates the SageMaker execution role (carrying the workload permissions boundary when
permissions_boundary_arnis set) - Creates the Model Package Group and, with
enable_model_card = true(default), a Model Card - Creates the SageMaker pipeline from
pipeline.json.tftpl: 9 top-level steps (ValidateDataset, PreprocessData, three training steps, EvaluateAllModels, CreateEnsembleModel, BiasCheck, and the Condition step), whose branches are RegisterEnsembleModel or Fail - Creates an EventBridge rule that starts the pipeline with
RetrainingReason=data_uploadwhen the.batch_completemarker is written to the raw data bucket - With
enable_upload_quarantine = true(default), creates the upload quarantine Lambda (see Upload quarantine) - Creates CloudWatch log groups, metric filters and a training dashboard, and an AWS Budgets budget (
monthly_budget_usd, default 200) - Optionally creates a managed MLflow tracking server (
enable_mlflow, defaultfalse) and a multi-region CloudTrail trail (enable_cloudtrail, defaultfalse)
An EventBridge rule on the raw-data bucket's Object Created events under training_data_path invokes a Lambda once per uploaded object. EventBridge is the same event source the pipeline trigger uses: S3 allows one notification configuration per bucket, and the bucket's sends everything to EventBridge. The Lambda:
- keeps an object that has an allowed extension (
.jpg,.jpeg,.png, the extensions validation and preprocessing read), opens with Pillow as the format that extension names (verified and fully decoded, so truncated files fail), and is at leastquarantine_min_image_size_px(112, the validation step's hard minimum) on both sides; - otherwise copies it to
<quarantine_prefix><original key>(defaultquarantine/), writes<quarantine_prefix><original key>.reason.jsonwith the reason, ETag, size and time, deletes the original, and adds 1 to theQuarantinedImagesmetric (namespace<project_name>/DataQuality, dimensionBucket); - ignores the
.batch_completemarker, keys outsidetraining_data_pathand anything already underquarantine_prefix.
quarantine_prefix must sit outside training_data_path (a variable validation checks it), so neither the pipeline trigger nor the validation and preprocessing inputs read quarantined files. The bucket is versioned, so the deleted original stays recoverable as a noncurrent version.
The function uses the terraform-aws-lambda module: KMS-encrypted environment and log group (log_retention_days), active X-Ray tracing, an SQS dead-letter queue encrypted with the project key plus a queue-depth alarm, reserved concurrency (upload_quarantine_reserved_concurrency, 10) and the workload permissions boundary. Its role can read and delete only under training_data_path, write only under quarantine_prefix, and publish metrics only to its namespace. Pillow comes from a layer built from the same zip and script as the inference Lambda's layer (ops-scripts/build_lambda_layer.sh, Pillow 12.3.0); make layer builds it, and the apply builds it when missing.
The checks run as files arrive, while the marker starts the pipeline as soon as the upload script writes it, so a large upload can start the pipeline before the Lambda has reached every file. The validation step checks every image again and fails the run on a bad one, so nothing unchecked reaches training. For a clean first run, wait for the Lambda to catch up (its CloudWatch Invocations metric flattens) before writing the marker, or rerun the pipeline.
- Patient-grouped split. Preprocessing resizes images to
preprocessing_target_size(512) and splits train, validation and test by BreakHis patient id, so one patient's images never cross splits. - Thresholds tuned on validation. Evaluation and the ensemble tune their decision thresholds on the validation split; the clinical gate is scored on the test split at that threshold.
- Fairness gate on the ensemble. BiasCheck runs Fairlearn over the ensemble's own test predictions at its tuned threshold, per
fairness_gate.sensitive_feature(magnification). Fewer than two subgroups is "not evaluable" and fails the gate unlessallow_not_evaluable = true(default false; meant for small demo datasets). - Gate. The Condition step requires
clinical_quality_gate(accuracy 0.85, recall 0.95, precision 0.80, AUC 0.90) andfairness_gate.max_disparity(0.10). A pass registers the ensemble asPendingManualApproval; a miss ends in the Fail step. - Per-execution artifacts. Evaluation, ensemble and bias outputs go to
<prefix>/<pipeline-execution-id>/in the model artifacts bucket, and the registered package'sModelDataUrlpoints at that execution'smodel.tar.gz. - Lineage.
RetrainingReason,DatasetVersionandCodeCommitShaare pipeline parameters recorded on the model package. The Terraform variables only set their defaults; the upload rule passesdata_uploadand the drift rule instack-inferencepassesdrift_detected. - Network isolation.
enable_network_isolation = true(default) runs the training jobs without outbound network access; they read ImageNet weights froms3://<scripts-bucket>/pretrained-weights/. Processing jobs are not isolated because they install packages at start-up.vpc_configoptionally places the jobs in your subnets. - Instance types. The shipped
terraform.tfvarstrains onml.c5.2xlarge(CPU) with 2 epochs, sized for a smoke test. Instance types startingml.gorml.puse the GPU training image; they need a SageMaker training quota.
stack-backend-setupapplied andbackend.hclwritten (make backend-config)- Terraform
~> 1.15and AWS CLI v2 - After apply: the scripts uploaded (
make upload-scripts), the ImageNet weights uploaded when network isolation is on, and training data uploaded withscripts/data_uploader.sh training_data_pathset to the prefix the uploader writes to (medical_image_data/, the shipped value), andauto_trigger_marker_keyto the marker it writes (medical_image_data/.batch_complete)
From the repository root:
make deploy-training # terraform init -backend-config=backend.hcl, plan -out, apply
make upload-scripts # pipeline scripts to the scripts bucket
# Network isolation (default): upload ImageNet weights once (no-op when isolation is off)
make weights
# Upload a dataset (breast_benign/ and breast_malignant/ folders); the marker starts the pipeline
./scripts/data_uploader.sh data/breakhismake deploy-training passes state_bucket_name, state_bucket_region and permissions_boundary_arn from the bootstrap outputs as TF_VAR_*.
| Name | Version |
|---|---|
| terraform | ~> 1.15 |
| archive | ~> 2.7 |
| aws | ~> 6.0 |
| random | ~> 3.8 |
| Name | Version |
|---|---|
| aws | ~> 6.0 |
| random | ~> 3.8 |
| terraform | n/a |
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
| aws_region | AWS region | string |
n/a | yes |
| environment | Environment name | string |
n/a | yes |
| models | Configuration for all training models | map(object({ |
n/a | yes |
| pipeline_steps | Configuration for pipeline processing steps | map(object({ |
n/a | yes |
| project_name | Name of the project, used as the prefix of every resource name. Lowercase letters, digits and hyphens. Together with environment it is capped so the longest derived name (the inference-results bucket) stays within the 63-character S3 limit. | string |
n/a | yes |
| allow_not_evaluable | Let the fairness gate pass when the test split has fewer than two subgroups of fairness_gate.sensitive_feature, so disparity cannot be measured (status not_evaluable). Default false fails the pipeline instead; set true only for small demo datasets. | bool |
false |
no |
| auto_trigger_marker_key | Object key prefix in the raw-data bucket whose creation starts the pipeline. scripts/data_uploader.sh writes this marker after a batch upload completes. | string |
"medical_image_data/.batch_complete" |
no |
| bucket_defaults | Default configuration for S3 buckets | object({ |
{ |
no |
| budget_alert_emails | Email addresses that receive AWS Budgets alerts at 80% actual and 100% forecasted thresholds. | list(string) |
[] |
no |
| budget_start_date | Start of the AWS Budgets period, in the format YYYY-MM-DD_HH:MM (UTC). | string |
"2026-01-01_00:00" |
no |
| clinical_quality_gate | Clinical quality gate the ensemble must clear before it can be registered. Recall is highest because a missed malignant case (false negative) is the costly error. The evaluation and ensemble steps receive the same values (CLINICAL_QUALITY_GATE), so the threshold search targets this recall. | object({ |
{ |
no |
| cloudtrail_retention_days | Days to retain CloudTrail logs in S3 before lifecycle deletion. HIPAA requires 6 years of audit retention - typically achieved by shipping to a SIEM, not keeping everything in S3. | number |
400 |
no |
| code_commit_sha | Default of the CodeCommitSha pipeline parameter: the Git commit that produced the model, recorded on the model package for audit lineage. Override per execution from CI. | string |
"local" |
no |
| dashboard_config | Dashboard configuration JSON | string |
"" |
no |
| dashboard_name | CloudWatch dashboard name | string |
null |
no |
| data_config | Data processing configuration | object({ |
{ |
no |
| dataset_version | Default of the DatasetVersion pipeline parameter (for example a DVC hash such as dataset-v2.3-sha256:...), recorded on the registered model package for audit lineage. Override per execution from CI. | string |
"unversioned" |
no |
| debugger_rule_image | Region-specific SageMaker Debugger rule-evaluator image. us-east-1 default; see https://docs.aws.amazon.com/sagemaker/latest/dg/debugger-docker-images-rules.html | string |
"503895931360.dkr.ecr.us-east-1.amazonaws.com/sagemaker-debugger-rules:latest" |
no |
| enable_auto_trigger | Enable auto-trigger of pipeline on new data upload | bool |
true |
no |
| enable_cloudtrail | Create a multi-region CloudTrail trail with log-file integrity validation for this account. Leave false when an organization trail already records this account's API activity. | bool |
false |
no |
| enable_cloudtrail_sns | Attach an SNS delivery-notification topic to the CloudTrail. Off by default. The topic is encrypted with the project CMK, whose key policy then grants the CloudTrail service principal (CloudTrail cannot publish to a topic on the AWS-managed alias/aws/sns key). The trail and S3/CloudWatch delivery work without it. | bool |
false |
no |
| enable_debugger | Attach SageMaker Debugger built-in rules (Overfit, LossNotDecreasing) to the training steps (Part 2). Off by default; the CloudWatch training metrics (and MLflow when enable_mlflow = true) cover the same need. | bool |
false |
no |
| enable_experiments | Associate each training step with a SageMaker Experiment trial component via ExperimentConfig (Part 2). | bool |
true |
no |
| enable_kms_key_rotation | Enable KMS key rotation | bool |
true |
no |
| enable_managed_spot_training | Use EC2 Spot for all training steps. Requires checkpoint support in trainers. | bool |
true |
no |
| enable_mlflow | Create a managed SageMaker MLflow tracking server (Small) for experiment tracking. Off by default because it is billed for every hour it runs. | bool |
false |
no |
| enable_model_card | Create a SageMaker Model Card documenting intended use, risk rating, and clinical context for the model package group (Part 2/4). | bool |
true |
no |
| enable_network_isolation | Run the training jobs with network isolation (no outbound network access from the training container). The trainers then read ImageNet weights from s3:///pretrained-weights/, so run scripts/download_pretrained_weights.py once before the first pipeline execution. Processing jobs are not isolated because they install Python dependencies at start-up. | bool |
true |
no |
| enable_sbom_bucket | Create a dedicated S3 bucket to receive Syft-generated CycloneDX SBOMs from the patched-image CodeBuild. Low monthly cost; safe to leave on even if you don't wire the patched image yet. | bool |
true |
no |
| enable_training_monitoring | Enable training monitoring | bool |
true |
no |
| enable_upload_quarantine | Check every object uploaded under training_data_path in the raw-data bucket with a Lambda (allowed extension, opens with Pillow as that format, minimum size) and move failing files to quarantine_prefix with a .reason.json next to each. The pipeline validation step still checks every image. | bool |
true |
no |
| fairness_gate | Fairness gate the ensemble must clear before registration (Part 4). max_disparity bounds the larger of demographic-parity difference and equalized-odds difference, computed by scripts/bias/compute_bias.py with Fairlearn. Must mirror DEFAULT_THRESHOLD in that script. sensitive_feature is reported in the bias report; the public datasets used here carry no demographic metadata, so magnification is an honest subgroup proxy - supply a real attribute for clinical use. | object({ |
{ |
no |
| kms_deletion_window_days | KMS key deletion window (days) | number |
7 |
no |
| log_group_names | Log group names for metric filters | map(string) |
{} |
no |
| log_groups | Map of log groups to create | map(object({ |
{} |
no |
| log_retention_days | CloudWatch log retention days | number |
30 |
no |
| metric_namespaces | Metric namespaces for CloudWatch metrics | map(string) |
{} |
no |
| min_images_per_class | Minimum images required per class (benign/malignant) for the validation step to pass. Production default is 100; lower it for small test datasets. | number |
100 |
no |
| model_card_risk_rating | Risk rating recorded on the Model Card. High for clinical decision-support models. | string |
"High" |
no |
| monthly_budget_usd | Monthly spending budget in USD (project-scoped via tag filter). Set to 0 to disable the AWS Budgets resource entirely. | number |
200 |
no |
| permissions_boundary_arn | ARN of the permissions boundary attached to every IAM role this stack creates (stack-backend-setup output workload_boundary_arn). Required when CI/CD CodeBuild applies the stack; null leaves the roles unbounded. | string |
null |
no |
| pipeline_max_parallel_steps | Max concurrent steps a pipeline execution can run. Lets the three model trainers run in parallel. | number |
4 |
no |
| preprocessing_target_size | Target square resolution (pixels) the preprocessing job resizes every image to before training. 512 preserves fine diagnostic features like microcalcifications; the trainers downsample to their own input_size from there. | number |
512 |
no |
| quarantine_min_image_size_px | Uploads whose width or height is below this many pixels are quarantined. Matches the validation step's MIN_RESOLUTION default (112). | number |
112 |
no |
| quarantine_prefix | Key prefix in the raw-data bucket that failing uploads are moved to. Must end with / and sit outside training_data_path so neither the pipeline trigger nor the preprocessing input reads it. | string |
"quarantine/" |
no |
| retraining_reason | Default of the RetrainingReason pipeline parameter (manual, data_upload, drift_detected), recorded on the registered model package. The upload trigger passes data_upload and the drift alarm passes drift_detected per execution. | string |
"manual" |
no |
| sagemaker_images | SageMaker container image configurations | object({ |
{ |
no |
| sbom_retention_days | How long SBOM JSON files are kept before S3 expires them. | number |
730 |
no |
| spot_max_wait_buffer_seconds | Extra wait time (seconds) SageMaker holds for Spot capacity on top of MaxRuntime | number |
1800 |
no |
| training_data_path | Training data S3 path | string |
"medical_image_data/" |
no |
| training_input_mode | SageMaker training input mode | string |
"FastFile" |
no |
| training_keep_alive_seconds | KeepAlivePeriodInSeconds for warm pools. Set to 0 to disable. | number |
1800 |
no |
| upload_quarantine_reserved_concurrency | Reserved concurrency for the upload quarantine Lambda. A full dataset upload sends one event per image; the cap keeps it from using the account's whole Lambda concurrency, and throttled events are retried by Lambda's asynchronous queue. | number |
10 |
no |
| vpc_config | Optional VPC for the training and processing jobs. Null runs them in the SageMaker service network. The subnets need a route to Amazon S3 (gateway endpoint), SageMaker API, CloudWatch Logs and ECR (interface endpoints or NAT); processing jobs also pip install packages, which needs a route to PyPI or a mirror. | object({ |
null |
no |
| Name | Description |
|---|---|
| auto_trigger_eventbridge_rule | Name of the EventBridge rule for auto-triggering pipeline |
| endpoint_name | Name of the SageMaker endpoint |
| inference_results_bucket | Name of the inference results S3 bucket |
| kms_key_arn | ARN of the KMS key |
| kms_key_id | ID of the KMS key |
| mlflow_tracking_server_arn | ARN of the MLflow tracking server. Null when enable_mlflow = false. |
| mlflow_tracking_server_url | URL of the MLflow tracking server. Null when enable_mlflow = false. |
| model_artifacts_bucket | Name of the model artifacts S3 bucket |
| model_package_group_name | Name of the model package group |
| monitoring_bucket | Name of the monitoring S3 bucket |
| network_isolation_enabled | Whether the training jobs run with network isolation. When true, the ImageNet weights must be in s3:///pretrained-weights/ (make weights). |
| pipeline_log_groups | Pipeline step log group names |
| processed_data_bucket | Name of the processed data S3 bucket |
| raw_data_bucket | Name of the raw data S3 bucket |
| sagemaker_execution_role_arn | ARN of the SageMaker execution role |
| sagemaker_image_uris | SageMaker Docker image URIs fetched dynamically from AWS |
| sagemaker_pipeline_arn | ARN of the SageMaker pipeline |
| sagemaker_pipeline_name | Name of the SageMaker pipeline |
| sbom_bucket | Name of the SBOM S3 bucket. Null when enable_sbom_bucket = false. |
| sbom_bucket_arn | ARN of the SBOM S3 bucket. Null when enable_sbom_bucket = false. |
| scripts_bucket | Name of the scripts S3 bucket |
| training_dashboard_url | Training monitoring dashboard URL |
| training_log_groups | Training metrics log group names |
| upload_quarantine_function_name | Lambda that checks each uploaded training image (null when enable_upload_quarantine = false) |
| upload_quarantine_location | Where failing uploads are moved, with a .reason.json next to each (null when enable_upload_quarantine = false) |
stack-training/
|-- auto_trigger.tf # EventBridge rule, target and role that start the pipeline on upload
|-- backend.tf # Partial S3 backend (values come from backend.hcl)
|-- backend.hcl.example # Template for backend.hcl
|-- cloudtrail_budgets.tf # Optional CloudTrail trail and bucket, AWS Budgets budget
|-- data.tf # Caller identity and SageMaker prebuilt image lookups
|-- lambda/upload_quarantine/ # Upload quarantine Lambda source
|-- main.tf # KMS, S3 buckets, execution role, registry, Model Card, MLflow, pipeline
|-- monitor.tf # CloudWatch log groups, metric filters and training dashboard
|-- outputs.tf # Buckets, pipeline, registry, IAM, KMS, MLflow, SBOM outputs
|-- pipeline.json.tftpl # SageMaker pipeline definition (rendered by templatefile)
|-- providers.tf # AWS provider with default_tags
|-- quarantine.tf # Upload quarantine Lambda, rule, role, layer and DLQ
|-- sbom.tf # SBOM bucket for the patched inference image builds
|-- terraform.tfvars # Values for this environment
|-- terraform.tfvars.example # Annotated example values
|-- variables.tf # Input variables
`-- versions.tf # Terraform and provider version constraints