DEV Community

Cover image for Keeping the Data Where It Is, Modernizing Only the App From .NET Framework to Lambda — What Changes and What Does Not (A 1-Environment Test)

Keeping the Data Where It Is, Modernizing Only the App From .NET Framework to Lambda — What Changes and What Does Not (A 1-Environment Test)

Keeping the data you already have, modernizing only the app — what changes across SMB, NFS, and S3 Access Points

Business apps written for .NET Framework are still running in many Windows environments. .NET Framework runs only on Windows, and its feature set is effectively frozen. Current .NET (modern .NET) is cross-platform: it runs on Linux, in containers, and on serverless such as AWS Lambda. Moving an app to modern .NET frees it from the OS dependency and the support-window constraint, and lets it run without an always-on server, paying only for what it uses. Microsoft itself frames the move from .NET Framework to modern .NET as a good opportunity to containerize and go cloud-native (Microsoft Learn: Upgrade .NET apps overview).

But work usually stalls on the same question: the app can move, but what about the data the app has been reading and writing? Do I rebuild that data or relocate it, and what happens to its permissions and protocols?

No new announcement from AWS or any other vendor prompted this work. I have been working through modernization on AWS as a series of themes — migration, multiprotocol access, performance, S3 Access Points, a serverless file portal, a data lake, observability — and the step I had not yet taken was migrating the application code itself. Then AWS's AI-Driven Modernization published its AI Modernization Flow (a workflow that has an AI agent carry out a disciplined migration), and I used it to take exactly that step.

This post is a measurement record of what changed and what did not when I moved only the app, from .NET Framework 4.8 to current .NET and AWS Lambda in four stages, with the data left on a single volume. Across the stages I added paths for opening that same data: SMB from a Windows Amazon EC2 instance, NFS and cifs (a way to use SMB from Linux) from a Linux Amazon EC2 instance, and S3 Access Points (a way to open the volume through the S3 API) from AWS Lambda. The data lives on a single volume of Amazon FSx for NetApp ONTAP (FSx for ONTAP from here on; NetApp's ONTAP offered as a managed AWS storage service), and I did not change that volume from start to finish.

First, the caveats. I measured one app in one environment (ONTAP 9.19.1P3, Single-AZ, ap-northeast-1), and each measured value in this post comes from one to a few runs. A value I saw only once is labeled "one observation". No value is a guarantee that production will behave the same way. Costs are the values AWS Cost Explorer returned before billing is final (Estimated), not invoice amounts. When a later chapter says "as stated up front", it refers to this paragraph: one app, one environment, one to a few runs, and Estimated costs.

The one app I measured is DocIntake, a sample app I wrote for this verification. It is not a customer app and not a production app.

  • Role: the Worker (the app proper, one of the two console apps) reads the documents in an inbox folder on the volume (seed/inbox/), processes them, and writes a report of the results (reports) and an index, index.json. It is a batch-style app
  • Structure: two console apps (the Worker, and the Probe that measures the five behaviors). It has no screen (UI), no Web layer, no database, and no authentication
  • How it reaches storage: all file reads and writes go through IFileStore, which has 5 methods. In the .NET Framework source, a single Store key in appSettings selects the storage implementation (SMB in stage 0, the first of the four stages). As the stages advanced, I added NFS and S3 Access Points implementations
  • Why it was built this way, and its size: so that the difference between paths can be observed behavior by behavior, I placed one habit that does not show up on Windows + SMB but can show up on another path in the source code for each of the five behaviors (the five behaviors are listed in the Probe row under "Terms used in this post"). It is .NET Framework 4.8, C#, 0 NuGet dependencies, 10 .cs files, and 935 lines

Because this is the result of a single self-written console app, it does not necessarily apply to real business apps, or to apps that have a screen.

You are probably wondering about these three points:

  • Can I move only the app to Lambda without moving the data?
  • When I change the path that opens the data from SMB to NFS to S3 Access Points, what changes in how the same files behave?
  • Which should I use to convert the code: atx (AWS's code analysis and conversion tool) or AIMF (the AI Modernization Flow mentioned above)?

Here are the three findings, answering the three questions above in order:

  • Question 1 (can I move only the app to Lambda without moving the data?): yes. I converted the code of DocIntake and ran it on Lambda, while the data stayed in one FSx for ONTAP volume, appdata. In this post, "move" means converting the code and changing where the code runs and which path it uses to open the volume. It does not include copying or moving the data.
    • What did not change (where the data lives): the data stayed in the volume appdata from stage 0 through stage 3. I did not copy, move, or recreate that volume. The UUID of appdata, its security style (the method it uses to decide permissions; ntfs to the end, which decides permissions with NTFS ACLs), and the sha256 of the seed (the 6 files I placed at the start of the verification) did not change; I recorded and compared them at each stage boundary (these records are from the second of the two builds of the verification environment). Those 3 items are what I confirmed did not change. The output that the app writes did accumulate on the volume, and in stage 1 I changed the export policy and name mapping settings for NFS
    • What did change (the code, and how it runs): three things changed: the app's code, where the code runs, and the path used to open the volume. I converted the code from .NET Framework 4.8 to .NET 10 (with atx, a code analysis and conversion tool, and AIMF, a kiro-cli migration workflow; the third finding covers how they differ). I changed where the code runs from a Windows EC2 instance to a Linux EC2 instance to Lambda. For the path, I added NFS and cifs to SMB, and finally added S3 Access Points
    • What I checked: in stage 3, the reports and index.json that the app itself (the Worker) wrote on Lambda through S3 Access Points were visible from the Linux client that opens the same volume over NFS (one observation)
    • The code that ran at each stage: the converted code ran in stage 2 and stage 3. Stage 0 is the starting point with the unconverted code, and stage 1 adds a path without changing the code, so neither has converted code. The table below shows each stage
Stage Code that ran Where it ran Path to the volume Evidence that it ran
0 The unconverted Probe (.NET Framework 4.8). The counterpart on the Linux side is a Python version of the Probe (not DocIntake's code, but a separate script that measures the same 5 behaviors). The unconverted Worker ran once, later, as the stage 0 baseline A Windows EC2 instance and a Linux EC2 instance SMB from Windows; cifs from Linux The Probe records show every side and every pair as Success. The Worker exited with 0 and wrote reports and index.json
1 The same Probe as in stage 0 (no record of a code change). A Python version of the Probe on the Linux side. No record that the Worker ran A Windows EC2 instance and a Linux EC2 instance SMB from Windows; cifs and NFS from Linux The Probe records show every side and every pair as Success
2 The converted Worker and Probe (.NET 10). The counterpart on the Windows side is the unconverted Probe A Linux EC2 instance NFS for the Worker; NFS and cifs for the Probe The Worker exited with 0 and wrote reports and index.json. Every Probe record is measured
3 The converted Worker and Probe (.NET 10; two modes of one Lambda function) Lambda (inside the VPC) S3 Access Points The Worker exited with 0, and the output it wrote was visible from the NFS side (names and sizes only; I did not read the contents; one observation)
  • Question 2 (when I change the path that opens the data, what changes in how the same files behave?): some of how the same files behave changed with the path that opens them.
    • Case sensitivity: for each path, I measured whether a reference that points at an existing file with different letter case resolves to that file on the same volume, appdata. Both SMB paths (Windows, and a Linux cifs mount, which uses SMB from Linux) were case-insensitive (the reference resolved), while NFS and S3 Access Points were case-sensitive (it did not)
    • Write visibility (the time until a file written by one client becomes visible to the other client): I measured both directions between Windows and Linux. When Windows wrote and Linux read, it took under 100 ms (35 ms when Linux read over NFS and 66 ms over cifs; the reading client tries every 100 ms to read the content the writer wrote, so these two values cannot be told apart). In the opposite direction, when Linux wrote and Windows read, it took about 8 seconds (7,968 to 8,342 ms: 7,968 ms when cifs wrote and 8,342 ms when NFS wrote). The asymmetry, fast when Windows writes and slow when Linux writes, had the same shape in three measurements (record identifiers s0, s1 and s2m). The values 35, 66, 7,968 and 8,342 ms above come from one of them, the single s2m measurement
    • Other behaviors: the handling of names that contain \, whether an exclusive lock is possible (a way of opening a file so that no one else can open it), and the writability check (whether writability is judged by a pre-evaluation of the ACL or by actually writing) also differ by path (see "Behavior by access path")
  • Question 3 (which should I use, atx or AIMF?): atx and AIMF covered different scopes. I do not rank them.
    • What I compared: atx and AIMF are two different tools. I set side by side what each produced from processing the same app, DocIntake, once each
    • atx (AWS Transform custom, AWS's code analysis and conversion tool): analyzed the code and converted it to .NET 10, and the net10.0 build of the converted code (on macOS) succeeded. But the five legacy-code habits that I had placed in the sample app, one for each of its five behaviors, and that assume Windows and SMB, remained in the converted code. The NFS and S3 Access Points implementations were not in atx's output
    • AIMF (a workflow run with kiro-cli): took the atx analysis as input, produced ADRs (architecture decision records) and a migration ledger (a list of where each legacy-code habit was handled in the new code), and went on to implement NFS and S3 Access Points
    • Limits of the comparison: one app, processed once with each tool, and the cost units differ (agent minutes for atx, Kiro credits for AIMF), so I did not compare which took less time or cost less

What you should take back to your own environment: each time you add a path that opens the data (SMB, NFS, S3 Access Points, and so on), measure the five behaviors again on the added path, together with how root is treated. The five behaviors are case sensitivity, handling of names that contain \, exclusive locking, the writability check, and write visibility (listed in the Probe row under "Terms used in this post"). For root, an example is that on NFS the root user could not enter /mnt/appdata (the cause is an inference from the export policy setting; see the stages 0 and 1 chapter and the stage 3 chapter).

On cost: the total I could identify for the AWS resources used in this verification is $35.91. The breakdown is $27.49 that I could narrow down in Cost Explorer by tag; $4.80 for AWS Directory Service (Active Directory) in the second of the two builds of the verification environment (the first on 2026-10-07, the second on 2026-10-11 to 2026-10-12), which I could not narrow down and estimated by subtracting the steady usage of days without the verification from the usage of the verification days; and $3.62 for AWS Transform (atx) usage. All three are Estimated, not invoice amounts. The AWS Transform usage carries a Credit of the same amount, −$3.62, and the $35.91 does not deduct that Credit.

When this post fits and when it does not

This post fits SAs, application developers, storage administrators, and Partner/SI engineers who are considering moving a .NET Framework app, built around a Windows shared folder (SMB), to Linux or Lambda without moving its data. It reports one such app, opened over SMB, NFS, and S3 Access Points against one FSx for ONTAP volume (ntfs, Single-AZ), so it is useful for judging cases with the same conditions (an SMB-based .NET Framework app, an ntfs volume, Single-AZ).

This post does not fit if you need any of the following, because I either did not measure it or measured it under different conditions:

  • Performance (this verification put it out of scope, so I did not measure it; "What this post covers and what it does not" points to results that other projects measured)
  • Behavior on a volume whose security style is unix or mixed (I measured only ntfs)
  • Migrating the screens or Web layer of an app that has a UI (the app I measured is a console app with no screen and no Web layer)
  • Behavior on Multi-AZ (I built only Single-AZ)
  • A guarantee for production (every value is from one to a few runs in one environment)

Reading by purpose

  • You only want the conclusions: the three findings in this chapter are enough.
  • You want to know whether changing the path causes trouble: "Behavior by access path" and "Summary and choosing an access path"
  • You are choosing between atx and AIMF: "Choosing between atx and AIMF"
  • You want to know how to hand out a development environment without copying the data: the FlexClone subsection of "Staged design and the single-volume constraint" (it reports only what I read in documentation; I did not create a FlexClone in this verification)
  • You want to know the cost: "Cost estimates and observations"
  • You want the conditions and limits of the measurement, or want to reproduce it: "Test environment and method" and "What has not been measured yet"
  • You want to read in order: the staged design → stages 0 and 1 → stage 2 → stage 3 → behavior by access path

What this post covers and what it does not

This post reports five behaviors of one app, measured in one environment. It is not an exhaustive comparison.

What this post covers:

  • It covers what changed and what stayed the same on the volume across four stages (SMB, adding NFS, new .NET, Lambda). The data did not move and only the path changed, so differences can be traced to the path
  • It covers how I checked the measurements, and failures written up with numbers. Measuring without a positive control (a control that looks for something that must be found) invalidated the case-sensitivity values in 6 measurement runs. The same kind of error could occur in other measurements
  • It covers how to choose between atx and AIMF. I have one pair of results where both processed the same app, so the difference in scope can be compared concretely

What this post does not cover:

  • It does not cover volumes whose security style is unix or mixed. I decided to keep the security style at ntfs, and I did not create volumes with the other styles
  • It does not cover the container migration branch. The documents in this verification's repository hold only an explanation of the branch point and a link to another repository, and I did not go down the branch. The other repository that covers the branch (Container-Datastore Patterns, Japanese) is also, so far, at literature review and static validation (cfn-lint), and lists deployment to a real environment as the next stage
  • It does not cover apps that have a UI (a Web layer such as ASP.NET, or a desktop app), or tests that operate a screen. DocIntake, the sample app in this verification, is a console app with no screen and no Web layer, and I ran no screen-operation tests
  • It does not cover creating a FlexClone in a real environment, or the five behaviors on the clones. I did not create a single FlexClone in this verification, and the creation script went only as far as a dry run. The "Staged design and the single-volume constraint" chapter reports what I read in the documentation about FlexClone (documented)
  • It does not cover other ONTAP versions or Multi-AZ. The only configuration I built was Single-AZ on ONTAP 9.19.1P3
  • It does not cover reconciliation with an invoice. I have not checked an invoice, and the costs in this post are only the Cost Explorer values before billing is final (Estimated)
  • It does not cover performance. This verification measured how behavior differs per path (the five behaviors). I did not run measurements with performance conditions decided in advance (large files, many files, long runs), and ADR-2, AIMF's record of design decisions, also put performance out of scope. Another project measured the performance of the S3 Access Points path in two cases. Both are carried-over (values measured in another verification of mine), and neither is a value measured in this post's environment. Neither document records the ONTAP version
    • From an Amazon EC2 instance (c5n.9xlarge) using Python, the median latency of a PUT to S3 Access Points at concurrency 1 was 363.7 ms for 1 MiB and 331.5 ms for 8 MiB. A fixed cost per request (about 330 ms) dominated over object size (2026-09-01, ap-northeast-1, Single-AZ, 128 MBps, NetworkOrigin VPC, UNIX root identity; measurement of the S3 Access Points path performance, Japanese)
    • From a Lambda function outside the VPC (1,769 MB), the median latency of a 1 MB GetObject against S3 Access Points at concurrency 10 was 73.2 ms (2026-05-25, ap-northeast-1, Single-AZ, 128 MBps, NetworkOrigin Internet; S3 Access Points benchmark results, Japanese). That benchmark results document also lists the path this post uses, from a Lambda function in the VPC to an S3 Access Point with VPC origin, as not yet measured

Terms used in this post

Term One-line meaning
ntfs / security style The security style is the method a volume uses to decide permissions, and ntfs decides with NTFS ACLs. This post uses ntfs to the end
SMB The protocol Windows uses for file sharing
NFS The protocol Linux uses for file sharing. This post uses NFS 4.1 with sec=sys (a simple authentication where the client claims its uid)
cifs mount A way to mount an SMB share from Linux. The path is SMB and the client is Linux
S3 Access Points An endpoint that reads and writes an FSx for ONTAP volume through the S3 API. An S3 API client such as Lambda reaches the same volume without copying the data
NetworkOrigin The setting for where an S3 Access Point can be reached from (a VPC in this post). You can specify it only when the access point is created, and changing it means recreating the access point
atx (AWS Transform custom) AWS's code analysis and conversion tool. I use two of its transformations: analysis (comprehensive-codebase-analysis) and conversion (dotnet-modernization)
AIMF Short for AI Modernization Flow. A workflow that runs through Phase 0a to 4 with kiro-cli and leaves ADRs and a ledger
ADR / migration ledger An ADR is a record of a design decision and its reason. The ledger (AP-1 to 14) lists where each legacy-code habit was handled in the new code
Probe A console app that measures five behaviors (case sensitivity, names containing \, exclusive locking, the writability check, write visibility) and records them as JSON. An exclusive lock is a way of opening a file so that no one else can open it
Positive control A control that looks for something that must be found, through the same path, to confirm the measurement works
write-visibility The label the Probe records use for write visibility: the time until a file written by one client becomes visible to the other
8.3 short name A short Windows-compatible alias given to a long file name (for example PROBES~1.TXT)
name mapping An ONTAP setting that maps a Windows user to a UNIX user
FlexClone An ONTAP feature that creates a writable point-in-time copy, as a separate volume, from a volume's Snapshot (its state at one moment). I did not create one in this post

The modernization work so far, and the new step this time

This post continues an ongoing body of work on modernizing on AWS. So far I have worked through block storage migration with AWS Transform, multiprotocol access on FSx for ONTAP, hands-on throughput and block-performance verification, using S3 Access Points, a serverless file portal, data lake integration, and observability — each published as a GitHub repository and a blog post. This time the new, concrete step was migrating the application code itself.

What prompted this work is AWS's "AI-Driven Modernization (AIDM)" and its AI Modernization Flow (AIMF). I ran this migration with AIMF. AIMF is a workflow that has an AI agent carry out a disciplined migration of an app on an older technology stack; designing and building the cloud environment is out of its scope. That boundary is why the two fit together: AIMF handles the app side, and my earlier FSx for ONTAP work covers the data and storage side.

Differences from the previous article

The previous article is "AWS Transform Now Supports Block Storage Migration to FSx for ONTAP — Benefits and Pitfalls from a Hands-On EC2 Test". This post differs in two ways:

  • Scope: the previous article covered getting data onto FSx for ONTAP (AWS Transform migrating block storage). This post leaves the data where it is and moves only the app's code
  • What was measured: the previous article measured a migration job. This post measures how five behaviors differ when the same volume is opened over SMB, NFS, and S3 Access Points

Staged design and the single-volume constraint

I fixed the data on one volume so that failures in the app migration and failures in the data migration would not get mixed together. With the volume fixed, an observed difference can be read as a difference between paths. That choice to fix the volume is a design decision, not a measurement result. Fixing the volume does not mean there can be no working copies. FlexClone, a way to add a working volume while keeping appdata fixed, is described in the second half of this section.

Stage Runtime Path Where the code comes from Volume
0 Windows Amazon EC2 instance, .NET Framework 4.8 (a Linux Amazon EC2 instance opens SMB over cifs as the Probe's second measuring client) SMB The original app appdata (ntfs)
1 The stage 0 setup; the Linux Amazon EC2 instance also opens NFS 4.1 NFS 4.1 added The original app, plus a Python version of the Probe on the Linux side appdata (still ntfs)
2 Linux Amazon EC2 instance, .NET 10 NFS 4.1 and cifs AIMF (main line), atx (comparison) appdata (still ntfs)
3 AWS Lambda (in the VPC), .NET 10 S3 Access Points From AIMF; from atx (first run; run id s3) appdata (still ntfs)

Source of this table: the stage table in docs/en/reproduce.md (commit-pinned; I reorganized the columns)

Stage flow diagram. Windows over SMB, Linux over NFS, Linux over NFS and cifs, and Lambda over S3 Access Points all connect to one volume, appdata.

Figure 1: The flow from stage 0 to stage 3. The volume in the bottom row stays the same. The container migration is a branch and was not measured. (dark theme version)

The point of the figure: the volume in the bottom row stays one volume as the stages advance.

AIMF is the main line of stage 2. AIMF's first step (Phase 0a) imported the atx analysis results. I ran the atx dotnet-modernization transformation separately, for comparison.

Handing out working volumes with FlexClone (not done in this verification)

For app development and testing, you often need a separate place to read and write production-like data (in this post, the contents of appdata) without modifying the data itself. ONTAP FlexClone is one way to add that place without copying all of the data. FlexClone can be used alongside the design that fixes appdata as a single volume.

A FlexClone is a feature that creates a writable point-in-time copy by referencing the metadata of a Snapshot (the state of a volume at one moment). I call the copy a clone, and I call the volume it is made from the source volume (the NetApp documentation calls it the parent volume; in this post the source volume is appdata). The clone shares data blocks with the source volume, and until you write to the clone it uses capacity only for metadata (ONTAP: FlexClone volumes, files, and LUNs concepts). The free space a newly created clone uses is described as about 0.5% of the clone's nominal size. New data written to either the source volume or the clone is not shared between the two volumes, and the clone's usage grows by the amount written to the clone (ONTAP: space used by a FlexClone volume). The NetApp documentation contrasts this with traditional copies, which can take minutes to hours, and says that because FlexClone shares blocks, a copy of a large dataset can be made in a short time (this post gives no creation-time number).

How this project used it

  • Designed and implemented: the verification script integration-clone.sh creates and deletes, over the ONTAP REST API, a FlexClone named appdata_it_<n> from a Snapshot of appdata. The script never creates, changes, or deletes appdata itself. Its purpose is to create and remove the clones that the AIMF Phase 3 integration test (the integration gate) uses
  • Dry run only: dryrun_shell_tests.sh checks the requests that were assembled and the name validation, without calling ONTAP. No clone is created
  • A check at teardown only: in steps 4 and 5 of the environment teardown, I confirmed that no FlexClone and no it_* Snapshot remained and that the recovery queue was empty (docs/en/environment.md). This confirms absence; it is not evidence that one was created

So I have confirmed the design and the command assembly, but I have not created a clone in a real environment or measured behavior on one.

Use cases the documentation describes

Use case What the documentation says (paraphrased) Source Tier
Copies for development and testing Described as temporary, writable copies for trying an app against a production dataset ONTAP concepts, ONTAP: clones for testing documented
Several copies, per user or per server Several copies of the same dataset (the documented example is virtual desktop deployment). An example that makes several clones from the same Snapshot for several development and test servers. A use that gives additional users access to the data without giving them access to production data. I found no document that says "one per developer" The two in the first row, AWS: SAP HANA replicate and clone documented (per-developer use not stated)
Rehearsal before a change An example of trying a database operation on a clone before running it against production. Checks before an application or Kubernetes cluster upgrade. Validation of a system upgrade or migration AWS: refreshing a development environment, AWS: applications on Amazon EKS, the SAP HANA post in the second row documented
Checking recovery and incident response A use for disaster recovery (DR) testing. Mounting a clone of a Snapshot taken before data corruption on another server to attempt a logical repair The SAP HANA post in the second row documented
Provisioning test data for CI The documents I could fetch contain no statement that names use in CI. This use is only my extension of "several copies" above None hypothesis
Analytics and reporting on a point-in-time copy The documents I could fetch contain no statement that names analytics or reporting None hypothesis

The examples in the sources are Oracle Database (Amazon EC2, NFS), MySQL (Amazon EKS, iSCSI), and SAP HANA. The documents I fetched have no example of a Windows app opening an SMB NTFS share.

Relationship to the single fixed volume

Through stages 0 to 3, appdata stays one volume with the same UUID. A FlexClone is a separate volume from appdata, and you can specify a separate junction path for it (the path that attaches a volume to the SVM namespace) (ONTAP CLI: volume clone create). So a clone does not contradict the fixed design. It adds a working volume, per developer or per stage, next to the fixed appdata. On the other hand, every clone adds a new junction path. The five behaviors this post measured (case sensitivity, names that contain \, exclusive locking, the writability check, and write visibility) and the treatment of root are properties of the access path. So you measure them again on each path that opens a clone, the same as the takeaway "measure again each time you add a path".

Aspect One fixed volume (this verification, verified) FlexClone working volume (documented, not done)
What you get You can attribute differences to the path. The UUID, the security style, and the seed sha256 did not change across stages 0 to 3 A write target per user or per purpose that does not modify appdata
Behavior per path The five behaviors are measured (one environment) Needs to be measured again on each path that opens the clone. Opening a clone of an NTFS volume over SMB, NFS, and S3 Access Points is not measured. I found no statement in the fetched documents on how NTFS ACLs or SMB shares carry over to a clone
Capacity No additional capacity, because no volume besides appdata is added Metadata right after creation, then growth by the amount written. After a split (detaching the clone from its source volume to make an independent, normal volume), both the source volume and the clone need the full capacity set by the volume's guarantee (ONTAP: split)
Dependency on the source volume None You cannot delete the source volume while the clone exists (ONTAP: creating a FlexClone). A split cannot be undone, and no new Snapshot of that clone can be created during the split
What remains after deletion Not applicable In a separate verification of mine (2026-08-26, ONTAP 9.18.1P3D1, ap-northeast-1, a 1 GiB volume with the UNIX security style, one observation), a clone deleted in ONTAP stayed in the recovery queue, the deletion of the source volume it was made from failed, and a purge cleared it (measurement of ONTAP features over S3 Access Points, Japanese, carried-over)
Number of volumes 1 The volume limit per FSx for ONTAP file system is 500 (first-generation, and second-generation with one HA pair) or 1,000 (second-generation with two or more HA pairs), and 491 and 975 respectively when you use S3 Access Points (975 is for two HA pairs and decreases as HA pairs are added) (AWS: quotas). That page does not say whether a clone counts toward the number
How it appears on the AWS side Not applicable A change made on the ONTAP side can take up to several minutes to appear in the AWS console, CLI, and API (AWS: managing with ONTAP applications). In the separate verification described in the "What remains after deletion" row, a clone took 1,177 seconds (observed every 20 seconds, one run) to appear in the AWS-side list, and in another run it had not appeared at 1,258 seconds (carried-over)
Where you create it Not applicable The ONTAP CLI and REST API (ONTAP REST: creating a volume). The AWS API's ONTAP volume-creation settings list no field for specifying a clone (AWS API: CreateOntapVolumeConfiguration)
Relationship to backups Not applicable In an AWS Transform VMware migration example (a different feature from the atx in this post), locked Snapshots that FSx for ONTAP backups create can block a split (AWS: VMware migration). A clone that is in a data protection relationship cannot be split

You can also clone a FlexClone (ONTAP: efficient copies). In the table above, I did not confirm the behavior of a clone of an NTFS volume or the relationship to backups.

How to choose

  • Where it fits: you need working copies, per user or per purpose, that you can write to while keeping the contents of appdata, and the time and capacity of making full copies is a burden
  • Where to consider other means: if you need only a few working copies for a short time, an ordinary copy is enough. If you keep a clone for a long time and writes grow, the clone's usage grows and the period during which the source volume cannot be deleted gets longer. Also consider other means when the volume count is near the limit, or when you cannot build the ONTAP CLI and REST API into your operations. Restoring from a backup (AWS: restoring backups) is also a way to create a separate volume. The documents I fetched do not say whether a restore shares blocks

What you can decide: whether to take the approach of adding working volumes with FlexClone while keeping appdata fixed as one volume, and, if you do, whether you can accept the operational costs in the table above (deleting the source volume, splitting, the volume count, and the delay before changes appear on the AWS side).

The value of S3 Access Points and its four prerequisites

The value of using S3 Access Points is that an S3 API client such as Lambda reaches the same volume without copying the data. In this environment, the 5 reports and index.json written by the Lambda Worker were visible from the NFS side (stage 3, one observation). There are four prerequisites:

# Prerequisite Content Evidence tier
1 Fixed NetworkOrigin Specified only at creation. To change it, delete the access point and create it again documented (AWS documentation; the source is in the stage 3 chapter)
2 The user (identity) that handles S3 Access Points requests on the volume In this environment, the WINDOWS user appsvc verified (stage 3)
3 Reachability to an AD domain controller (DC) In this environment I used the access point with the DC reachable. Whether every data operation on an AD-joined SVM requires DC reachability, I have not confirmed. A successful HeadBucket does not show that file operations work open (I could not find a record a reader can reproduce, or a public primary source; details are in "What has not been measured yet")
4 No exclusive locking and no event notifications file-locking is skipped in the Probe run through S3 Access Points; the trigger that starts the Worker is polling from EventBridge Scheduler The file-locking skip is verified. The unavailability of event notifications is documented (my reading of the AWS API support table, which lists GetBucketNotificationConfiguration as not supported; see the stage 3 chapter)

What you can decide: whether to adopt the approach of changing only the paths (SMB, then NFS, then S3 Access Points) without moving the data, and whether you can confirm the four prerequisites above in your own environment before you use S3 Access Points.

Test environment and method

I measured three paths (Windows, Linux, Lambda) against the same volume, appdata, in the same SVM. The setup is below (as in the caveats stated at the start).

Configuration

Unless noted otherwise, the tier is verified (versions are values read from ONTAP REST and similar).

Item Value
Region and configuration ap-northeast-1, SINGLE_AZ_1, 1 HA pair, 1,024 GiB SSD, 128 MBps
ONTAP 9.19.1P3 (the first build was 9.19.1P2; I did not look into why it was updated)
SVM and volume 1 SVM, 1 volume appdata, security style ntfs
Active Directory AWS Managed Microsoft AD (Standard, 2 DCs). The directory needs 2 AZs, so I added a second subnet to the VPC
Windows client Amazon EC2 t3.large. I did not record the Windows Server version
Linux client Amazon EC2 t3.medium, Amazon Linux 2023 x86_64. NFS 4.1 sec=sys (uid 10001), cifs vers=3.0. I did not record the kernel or cifs-utils versions
Around the VPC 6 interface endpoints, an S3 gateway endpoint, 4 Secrets Manager secrets, an Amazon S3 artifacts bucket
Tool versions AIMF v0.11.0 (pinned tag), kiro-cli 2.29.1, atx 3.18.0, .NET SDK 10.0.400 (runtime 10.0.11; self-contained on Linux), AWSSDK.S3 4.0.104.2 (pinned)
The app, DocIntake .NET Framework 4.8, C#, 0 NuGet dependencies, 10 .cs files, 935 lines (sum of wc -l; atx reported 15 files and 1,089 lines). Two console apps (a Worker that processes files and writes reports and index.json, and the Probe) and one class library. IFileStore has 5 methods

Environment diagram. A VPC contains FSx for ONTAP, AWS Managed Microsoft AD, Windows and Linux EC2 instances, and Lambda, which reach the volume over SMB, NFS 4.1, and the S3 API.

Figure 2: The test environment. Arrow labels name the path or purpose (SMB, NFS 4.1, S3 API, fetch credentials, domain join). (dark theme version)

The point of the figure: all three paths reach the same volume in the same SVM.

Method

  • Probe: a console app that measures five behaviors and records them as JSON
  • Positive control for case-sensitivity: in addition to a lookup that refers to an existing file with different letter case, a lookup that refers to it exactly as it exists, to confirm the measurement works
  • Standalone and pair: standalone runs the Probe on one machine; a pair links two machines by a shared sync ID (locking and visibility need two machines)
  • Stage boundaries b0 to b3 and check-invariant: records of the volume's state taken after each stage (b0 after stage 0, b1 to b3 after stages 1 to 3). Each record holds the volume UUID, security style, sha256 of the 6 seed files, export policy, snapshots, and snapshot locking. check-invariant compares a later record with b0 and checks three items: the UUID, the seed sha256, and the top-level paths

Evidence labels and run ids

The labels in the tier column are as follows. verified: a value I measured in a named environment (with count and date). field-observation: a value I saw once and did not reproduce. documented: a value stated in a document whose source I read in full. carried-over: a value measured in another verification of mine that I did not measure this time. hypothesis: a guess. open: an item for which I could not find a record a reader can reproduce or a public primary source. not measured: an item I did not measure.

The main runs are below (a run id identifies one measurement).

run id Date What it measured
s0-20261011T065443Z 2026-10-11 All 5 behaviors in stage 0
s1-20261011T075058Z 2026-10-11 All 5 behaviors in stage 1 (after adding NFS)
s4c-20261012T034403Z 2026-10-12 case-sensitivity (SMB, cifs, NFS)
s3b-20261012T022900Z 2026-10-12 case-sensitivity (S3 Access Points)
s0w-20261012T102314Z 2026-10-12 Output of the old Windows Worker
s2m-20261012T090934Z 2026-10-12 8 pairs (4 lock, 4 visibility) of the Probe converted to .NET 10 in stage 2 (Linux side; the Windows side was the earlier build)
s2n-20261012T104314Z 2026-10-12 A run that re-took the shape of the writability check (acl-evaluation) record
s3m-20261012T104800Z 2026-10-12 The Probe and Worker converted to .NET 10, on Lambda

The source of this table is the run id naming in docs/en/reproduce.md, the same document as the stage table (I reorganized the columns).

The runs whose case-sensitivity values I invalidated are s0, s1, s2, s2f, s2g, and the first stage 3 run, s3. They had no case-sensitivity positive control, so I use none of those case-sensitivity values in any conclusion. The values of the other behaviors in those runs are valid. Source: record validity in docs/en/reproduce.md

The measurement window is 2026-10-11 to 2026-10-12. Times in the tables mix UTC and JST, and each row says which. For external unit prices and availability, I give the date I read them.

What you can decide: if your environment differs from the configuration table (versions, Multi-AZ, security style), that is a range this post did not measure. Look up the matching item in "When this post fits and when it does not" and "What this post covers and what it does not", and treat it as an item to measure again in your own environment.

Stages 0 and 1: unchanged UUID and seed, changed root handling

Adding NFS to the SMB volume did not change the volume UUID, the security style, or the sha256 of the seed. What changed was how root is treated over NFS and how to read the mode bits in ls -l (2026-10-11, one observation each).

Unchanged Changed
The seed sha256, the volume UUID, and the security style (still ntfs before and after) As root over NFS, cd /mnt/appdata gave Permission denied, while uid 10001 could read and write
check-invariant passed for b0 against b1 (UUID, seed, and top-level paths matched) Over NFS, ls -la showed the top level as d--------- (owned by root), yet uid 10001 could read and write. Files created over SMB showed as -rwx------, owner nobody
  • Stage 0 (SMB only): I created the AD users appsvc and appreader and placed the 6 seed files over SMB. The sha256 values read back matched the originals, and the Probe finished with the 2 standalone runs (Windows and the Linux cifs mount) and all 4 pairs (2 lock, 2 visibility) at Success
  • Stage 1: I changed the export policy to the NFS 4 family, sec=sys, and superuser none (a setting that replaces NFS root with an anonymous user). I created the UNIX users appsvc (10001) and appreader (10002) and put 4 name mappings in place (2 win_unix, 2 unix_win). The Probe finished with the 3 standalone runs (Windows, the Linux cifs mount, and Linux NFS) and all 8 pairs (4 lock, 4 visibility) at Success
  • Reading ls -l: on ntfs, the mode bits are a mapping of the ACL and do not tell you whether a write is allowed (this reading is a hypothesis). ADR-3 decides the Linux writability check by whether a real I/O attempt succeeds, not by the mode bits (a design decision; see the stage 2 chapter)

Security note
I read the denial of NFS root as an effect of the export policy's superuser none, but that is an inference from the setting (hypothesis). I did not try NFS without name mappings.

This chapter gives no case-sensitivity numbers. The stage 0 and 1 records have no positive control, so they support no conclusion (the re-measurement is in "Behavior by access path").

Sources for this chapter: the stage 0 re-measurement, the two-client behaviors in stage 1

What you can decide: when you add NFS to an existing SMB setup, this environment needed no data migration. Put the procedures that use root, and the procedure of checking permissions with ls -l, on your review list.

Stage 2: atx up to net10.0, AIMF up to ADRs and a ledger

atx got as far as a net10.0 build, but five legacy-code habits (ways of writing that assume Windows and SMB) remained after conversion. AIMF produced ADR-1 to 5 and ledger AP-1 to 14 and went on to implement NFS and S3 Access Points. The scopes differ.

Step Run date and duration Unit and cost Output and result check Who made the fix
atx analysis (comprehensive-codebase-analysis) 2026-10-11 09:48 to 10:03 UTC (about 15 min), exit 0 92.46 / 120.00 agent minutes (atx's billing unit), about $3.24 (estimate) 28 ATXDocumentation files, no code changes. The gitleaks scan before upload found no leaks Analysis only
atx dotnet-modernization 2026-10-11 11:05 to 11:25 UTC (about 20 min), exit 0 139.00 / 300 agent minutes (atx's record). In the hourly Cost Explorer data, 11.025 minutes were billable (about $0.39, Estimated); the other 127.97 minutes do not appear as billable (breakdown below the table in "Costs that could not be attributed, and why") 11 files, +55/−162. net10.0, SDK-style csproj, settings moved to IConfiguration and appsettings.json. The parity gate (a check that the old and converted code agree on Config, Boot, Endpoints, and Auth) passed on all 4 items. Build succeeded on macOS with 0 errors and 7 warnings atx did the conversion. The Linux fixes were my separate work (not an atx feature)
AIMF (Phase 0a to 4) 2026-10-12 13:50 to 17:23 JST (3 h 33 min; see ※) 284.26 Kiro credits (AIMF's billing unit; limit 5,000, notification at 4,000). Not converted to dollars ADR-1 to 5, ledger AP-1 to 14. Build warnings 0, 8 tests passed (later 54), a self-contained Linux publish output of 196 files (81 MB); the sha256 of all 196 files matched after deployment AIMF did the Path.Combine change and the real-I/O writability check. AIMF ported my Probe fix that adds the positive control

※ The 3 h 33 min is the span from the first to the last commit in the records repository. It is not the session length. The log starts with an approval at 13:25:54, and the span includes waiting for my decisions.

Sources for this table: the atx and AIMF comparison in stage 2, cost estimates and observed costs in stage 2 (I reorganized the columns for this post)

The five legacy-code habits that remained after the atx conversion:

  • The backslash join in StorePaths.Combine
  • The Docs\Index.json reference
  • ACL: File.GetAccessControl was only replaced by FileInfo.GetAccessControl, which does not work on Linux. No NfsFileStore was added either
  • The FileShare.None exclusive lock
  • The assumption that a file can be read back right after it is written

When I ran the code that atx converted on Linux (2026-10-11, self-contained, one observation), over NFS, the path-separator, file-locking, and write-visibility behaviors failed with UnauthorizedAccessException, and acl-evaluation failed with PlatformNotSupportedException. On the Linux cifs mount side, Combine created names joined with \ directly under /mnt (a local xfs), outside the mount, and nothing was written to the share.

The AIMF ledger, verification, and decisions:

  • Ledger: of AP-1 to AP-14, 12 resolved, 1 replaced by an alternative implementation, 1 not needed, 0 open
  • Negative tests of the detectors for AP-1 to AP-13 (I injected a temporary violating file and checked that it was detected): 13 pass, 0 fail
  • Independent verification: one run by a separate agent with a separate context. It found no missing functionality and raised one scope point: OpenExclusive cannot be used on S3, and no one had approved that limitation. AP-14 and ADR-5 closed the point
  • ADRs: ADR-2 puts performance out of scope. ADR-3 makes the Linux writability check a real I/O attempt instead of a pre-check. ADR-4 makes index.json compact System.Text.Json output (ADR-4 was my decision)
  • FileShare.None: AIMF also kept this exclusive lock on purpose (AP-4)

Conditions of the comparison: the scopes differ (atx goes up to the .NET 10 conversion and the parity gate; AIMF goes up to implementing NFS and S3 Access Points), and running AIMF depends on the atx analysis. I measured one app, once per tool, and the cost units are not converted, as the table shows.

What you can decide: if you only need to raise the .NET version and check agreement with the old code, atx's scope is enough. If you also need a per-path implementation and a record of decisions, that is AIMF's scope (how to choose is in the later chapter "Choosing between atx and AIMF").

Stage 3: Lambda reaching the same volume through S3 Access Points

A Lambda Worker (.NET 10, in the VPC) wrote 5 reports and index.json through S3 Access Points, and they were visible from the NFS side (one observation). Three conditions held when the Worker reached the volume: NetworkOrigin set to the VPC, the WINDOWS user appsvc as the identity, and an AD domain controller (DC) that was reachable. I have not confirmed whether the DC must be reachable.

  • Attachment: AWS::FSx::S3AccessPointAttachment reached CREATE_COMPLETE, and Lifecycle was AVAILABLE
  • NetworkOrigin: VPC. Changing it means deleting and recreating the access point (Creating access points restricted to a virtual private cloud, Configuring network access for Amazon S3 access points)
  • Stack creation, invocation, and deletion: 3 times (s3, s3b, s3m). The wait for delete-stack took more than 20 minutes (one recorded case), and no attachment remained after deletion
  • Lambda settings: 512 MB, 120-second timeout. The EventBridge Scheduler rule was created as DISABLED

Results of the runs:

  • atx-derived code (first run): the Worker exited 0 and wrote 5 reports and index.json to out/s3/. The run took 2,505 ms (Init 361 ms) with peak memory of 115 MB. The Probe took 821 ms with peak memory of 120 MB
  • AIMF-derived code (s3m): index.json was 181 bytes (confirmed with ls; I did not read the contents). The Probe gave 4 measured and 1 skipped (file-locking). Lambda ran only as the writer, so there is no write-visibility value

A name mapping that appeared only while the access point was attached (the per-path behavior is in the table of the next chapter, "Behavior by access path"):

  • Name mapping s3_win (replacement set to the AD domain's appsvc): it appeared in ONTAP only while the access point was attached; ONTAP held 5 name mappings at b3 while attached and 4 after deletion and at b2. I have not confirmed in documentation whether the attach process creates s3_win (one observation)

I did not check this time whether the AD domain controller must be reachable (the first item in "What has not been measured yet"). Because S3 Access Points cannot use event notifications, I used polling from EventBridge Scheduler as the trigger. I read the table as meaning that event notifications are unavailable, because the AWS API support table lists GetBucketNotificationConfiguration as not supported (Access point compatibility (FSx for ONTAP User Guide), checked on 2026-10-21, documented). That table lists only some of the APIs, and it has no row that names event notifications as unsupported.

Request charges in Tokyo are $0.00108 per 1,000 PUT, COPY, POST, and LIST requests, and $0.000029 per 1,000 GET and other requests. I read them on 2026-10-12 in the "S3 Access Points for FSx" tab of the English Amazon FSx for NetApp ONTAP pricing page, and on 2026-10-21 I opened the same tab in a browser and saw the same values. On the same day I also read them again in the Price List API under the AmazonS3 usage types APN1-Requests-FSXONTAP-Tier1 and Tier2; the values were the same. 1,000,000 PUT or LIST requests come to $1.08, which is unit price × count arithmetic (the billed amount is unconfirmed).

What stayed unchanged at the stage boundaries: the UUID, security style, seed, snapshots, and snapshot locking did not change from b0 to b3. The export policy attached to appdata changed once, from default at b0 to appmod_nfs (set in stage 1) at b1, and did not change from b1 to b3. check-invariant raised one finding at b2 (s2m) and at b3 (s3m), and passed at b3 after the deletion (the details are in failure 5).

Sources for this chapter: the table of the five behaviors in stage 3, the effect of a \ key on the SMB side, and the name mapping that appears only while attached

What you can decide: whether Lambda can use the same volume without copying the data depends on whether you can meet the four prerequisites in "Staged design and the single-volume constraint" in your own environment.

Behavior by access path

Even on one volume, case sensitivity, the handling of names containing \, whether an exclusive lock was possible, and the writability check differed by path, and write visibility differed by direction. What stayed the same across the paths was the volume they reach, the seed sha256, and the Worker output as JSON values.

A caution on reading: the implementation changed together with the path. The Windows column is the old build (.NET Framework 4.8) and the others are the modernized build (.NET 10), so some differences cannot be attributed to the path alone. Unless noted otherwise, the tier of each cell is verified.

Behavior Windows (earlier .NET Framework 4.8 build, SMB) Linux cifs (.NET 10) Linux NFS 4.1 (.NET 10, uid 10001) Lambda (.NET 10, S3 Access Points) Source runs
Case sensitivity Case-insensitive (a reference with different letter case resolved: true) Case-insensitive (resolved: true) Case-sensitive (did not resolve: false) Case-sensitive (did not resolve: false) s4c, s3b. s2m, s2n, and s3m show the same values as s4c and s3b
Names containing \ Treats \ as a separator (1 run) May reject a name with \ (errno 22). Not re-observed with the post-AIMF Probe An ordinary character (Python version). The .NET Probe got "not found" for the same name (1 run each) The key probe/sep\check-s3.txt could be written and read back. The first design, a key made only of \, was created directly under the volume root and appeared in the SMB listing as PROBES~1.TXT (field-observation, 1 run) s1, s2f, s2g, s2m, s3m, s3
Exclusive locking With the holder (the side that opens first) and the contender (the side that opens afterwards) swapped between Windows and Linux, the contender was refused in both directions and succeeded after release (1 run per pair) Same result as the Windows column, in pairs with Windows Same result as the Windows column, in pairs with Windows skipped (no operation to open exclusively). not measured s2m (Python version: s0, s1)
Writability check Pre-evaluates the NTFS ACL: writable Pre-check skipped; real I/O, run as root, showed it writable Pre-check skipped; real I/O showed it writable Pre-check skipped; the PUT was allowed s2m, s2n, s3m
Write visibility Takes part as one half of the pair with Linux (the values are in the two Linux columns, by direction) 66 ms when Windows writes and cifs reads; 7,968 ms when cifs writes and Windows reads 35 ms when Windows writes and NFS reads; 8,342 ms when NFS writes and Windows reads not measured s2m (the same shape, fast when Windows writes and slow in the other direction, in s1 and s0)

Source of this table: the source material is spread across the per-stage documents. I combined it into one table for this post, and the public repository has no such table (commit-pinned).

  • Case sensitivity (SMB, cifs, NFS): the stage 1 table
  • Exclusive locking and write visibility: the stage 2 measurements of the modernized app (7,968 / 8,342 ms; Worker output 201 / 181 bytes)
  • The S3 Access Points column, names containing \, and name mapping: the links at the end of the stage 3 chapter (two tables in stage 3)
  • The original tables for exclusive locking and visibility with two clients: the links at the end of the stages 0 and 1 chapter (stage 1)

Two points are not in the table: identity (which user a request is treated as) and Worker output. For identity, the treatment of root is in the stages 0 and 1 chapter and the stage 3 chapter. For Worker output (index.json), the old Worker wrote 201 bytes and the modernized Worker (Linux) that AIMF built wrote 181 bytes. The two index.json files are equal as JSON values (JSON-equal), and the 20-byte difference is whitespace (one run each). The Lambda Worker exited 0 and wrote 181 bytes (confirmed with ls; I did not read the contents). The source runs are s0w, s2m, and s3m.

In the write-visibility measurement, the reading client tries every 100 ms (polling interval 100 ms) to read the content the writer wrote, so differences below 100 ms (35 and 66) cannot be told apart. The asymmetry, fast when Windows writes and slow when Linux writes, had the same shape in three runs (s0, s1, s2m). I did not isolate why only the direction where Linux writes and Windows reads takes about 8 seconds. The Linux writer was Python (with fsync) in s0 and s1 and .NET (without fsync) in s2m, so the implementation differed too. In a separate verification, the reader's NFS mount options alone changed the time until a deletion made with S3 DeleteObject became visible over NFS, from 7 ms (actimeo=0) to 2,171 ms (default mount) (2026-08-09, ap-northeast-1, NFSv3, UNIX security style, concurrency 1, ONTAP version not identified, carried-over; measurement of S3 and NFS visibility on the same volume, Japanese). I have not confirmed whether that is the same cause as the 8 seconds in this post. Details of the exclusive-locking pairs, such as the result of the read-only open, are in the original stage 1 table.

What you can decide: when you add a path, you can make these five behaviors an item to measure again for each added path. On case sensitivity, Windows and cifs were insensitive while NFS and S3 Access Points were sensitive, so do not apply the SMB result to NFS or S3 Access Points.

Eight failures and the checks to put in place before measuring

There were 8 failures. The largest was the case-sensitivity measurement with no positive control, which made the case-sensitivity result unusable as a conclusion in 6 runs. The other 7 concern record shapes, environment creation, and side effects of scripts.

The eight failures

  1. Case sensitivity with no positive control: invalid in 6 runs (s0, s1, s2, s2f, s2g, and the first stage 3 run, s3)
    • Fact: I looked for the reference Docs\Index.json at the volume root, but the actual file was seed/docs/index.json. Even on case-insensitive Windows SMB the lookup resolved to false, and taken at face value this would have meant "SMB is case-sensitive"
    • Fix: added the positive control and measured again as s4c and s3b
  2. A measured on the cifs mount side (SMB used from Linux; measured is the state in which the Probe recorded that it could measure the behavior) that was not written to the share: s2 left 3 files directly under the local /mnt
    • Fact: the cifs-mount-side measured did not mean a successful write to the share. It meant that 3 files had been created directly under the local /mnt
    • Fix: deleted the 3 files that had been created
  3. topology: cross-host, which does not show two machines coordinating: the Probe converted to .NET 10 emitted it even when run standalone
    • Fact: the record's topology held the fixed value cross-host, which is supposed to show that two machines coordinated. The value was not derived from a sync check, so it is not evidence
    • Fix: changed to a step that judges a pair only when the two records carry the same sync ID (probe_merge.py). In s2m, all 8 pairs had empty mismatch reasons and were judged as pairs
  4. A writability record that disagreed with ADR-3 (s2m), and the missing comparison baseline out/s0/ for the Worker output
    • Fact: the s2m record held only the pre-evaluation value, which did not match the shape in ADR-3 (pre-check skipped, decided by real I/O). The baseline out/s0/ did not exist either, so the s2m JSON-equality check was a comparison against reference values
    • Fix: fixed the Probe and measured again as s2n to get the ADR-3 shape. I ran the old Windows Worker once and made the baseline as s0w, and the reference values (the index.json written by the Worker that atx transformed) and the old Worker's index.json were identical down to the bytes (201 bytes)
  5. check-invariant failing because of PROBES~1.TXT: two boundaries, b2 and b3
    • Fact: the first S3 Probe design wrote a key made only of \ with no slash. My reading that the key made only of \ was mapped to the 8.3 short name PROBES~1.TXT is an inference (PROBES~1.TXT appeared before the NFS deletion and was gone after it, which supports the inference)
    • Fix: after I moved the key under probe/, the new key did not appear at the root. I deleted the file that had been created at the root with one NFS rm, with my approval. The first attempt at the deletion script was aborted before deleting anything, because the root user could not see the volume's top-level directory (nothing changed)
  6. AIMF's Phase 3 Step 1 (the step that creates the implementation tree) failing because the base branch (base-ref) master could not be resolved: once
    • Fact: the base-ref master could not be resolved in the implementation tree (a working clone), and the step failed
    • Fix: reran with origin/master and it succeeded
  7. Two kinds of failure while creating the base environment: the template size limit and a missing secret
    • Fact: base.yaml was 52,466 bytes and exceeded the 51,200-byte limit for --template-body (no resources were created). On another attempt, the Secrets Manager secrets did not exist, so the Directory resource reached CREATE_FAILED and the stack rolled back (no resources remained)
    • Fix: deploy.sh now puts the template in the deployment Amazon S3 bucket and passes it from there. I now create the secrets with create-secrets.sh first and then run the base creation again
  8. A script that ignored --help and ran: it created 4 Secrets Manager secrets (01:24 JST on 2026-10-13)
    • Fact: I called create-secrets.sh, a script that does not parse arguments, with --help to check its usage. It ignored --help, ran its main process, and created 4 Secrets Manager secrets. About 7 hours after they were created, I deleted those 4 secrets. The cost of those 4 secrets is an estimate: $0.40 per secret per month, prorated by hours. 4 secrets × $0.40 × 7 hours ÷ 730 hours (taking a month as 730 hours) comes to about $0.015 (1.5 cents)
    • Fix: added to the build a gate, check_help_is_safe, that calls every script with --help and fails if any script does anything

Five preventive checks

  • Put the positive control in place before measuring (failure 1). Without a file to find, even SMB returned false
  • Put the NFS mount in a script (the first Probe in stage 1 failed because NFS was not mounted; that failure is not counted among the 8 above)
  • Check that the secrets exist before building the base environment (failure 7)
  • Put S3 keys under probe/ (failure 5)
  • Make "--help does nothing" a build gate: the build checks that --help executes nothing (failure 8)

Source for this chapter: why a measurement without a positive control is invalid (record validity is in the link in "Test environment and method")

What you can decide: before your own measurements, you can put these five points on a checklist. The positive control is needed in every measurement whose result can end as "not found".

Choosing between atx and AIMF

atx and AIMF cover different scopes, so the table does not say which is better. It lets you choose by the scope you need (the result of processing the same app once with each).

Aspect atx AIMF
What it is A tool for code analysis and transformation A workflow that runs Phase 0a to 4 through kiro-cli
Input and output in this verification Input: the source. Output: 28 analysis documents and an 11-file transformation. The index.json (201 bytes) written by the Worker that atx transformed matched, byte for byte, the index.json written by the old Worker (.NET Framework 4.8) before the transformation The atx analysis in; ADRs, a ledger, an implementation, and tests out. The index.json (181 bytes) written by the modernized Worker (Linux) that AIMF built is JSON-equal to the old Worker's index.json; ADR-4 made the output compact, so the bytes are 20 whitespace bytes smaller
Duration About 15 min for analysis, about 20 min for the transformation (run time) 3 h 33 min from the first to the last commit (not the session length; includes waits for my decisions)
Cost unit agent minutes Kiro credits (not converted to dollars)
Human involvement I ran it with run-atx.sh (I had put in a guard that forbids running it directly from an agent) 6 decisions and 13 manual approvals from me; the AIMF run was held 35 times, waiting for my decision, because the approver script did not approve automatically (reasons: a command not on the allowlist of operations it approves automatically, a match with a forbidden pattern, a cd outside the working directory, and so on; the count is the STOP lines in the execution log)
Trade-offs The output contains no NFS or S3 implementation and no decision records such as ADRs or a ledger. Five legacy-code habits remained It needs setup (a records repository, hooks, running only with --agent migration, and install.sh writing .kiro/ and CLAUDE.md). Credits cannot be converted to dollars, and the shape of a Probe record did not match its ADR (failure 4)

Duration and cost are measured differently and in different units, so I do not compare atx and AIMF as shorter or longer, or cheaper or more expensive. Not measured for either tool: run-to-run variation, larger apps, other languages, and maintainability.

Source of this table: the same comparison table as in the stage 2 chapter (I reorganized the columns).

How to choose, in three points:

  • If moving to .NET 10 and checking agreement with the old code is enough: atx. Plan for the legacy-code habits that remain after conversion
  • If you need to add paths or keep a record of decisions: AIMF. Plan for the setup, the 3 h 33 min of this verification (the span between commits), and my 6 decisions
  • If you already have an atx analysis: import it in AIMF's Phase 0a. In every case, re-measuring each path comes next
flowchart TD
    Q1{"Adding access paths?"} -->|Yes| Q3{"Already have an atx analysis?"}
    Q1 -->|No| Q2{"Need decision records?"}
    Q2 -->|No| A["atx: move to .NET 10 and check parity"]
    Q2 -->|Yes| Q3
    Q3 -->|Yes| C["AIMF, importing the atx analysis"]
    Q3 -->|No| B["AIMF"]
    A --> R["Re-measure each path"]
    B --> R
    C --> R

The diagram above summarizes the table and the list.

What you can decide: two questions, whether you are adding paths and whether you need decision records, tell you whether to start with atx or with AIMF. The differences in time and cost are single observations, so do not use them as the basis for choosing.

Cost estimates and observations

Per row, the hourly-rate estimate and the value I attributed from Cost Explorer differed by −1% to −3% (largest −3.06%). This difference checks whether the hourly rates in the estimate match the observed values; it is not a reconciliation with an invoice. The cost identified for the whole verification is $35.91. That figure does not deduct the AWS Transform Credit line (−$3.62); if the Credit is deducted, the AWS Transform portion nets to $0.

The values in this chapter are either estimates (unit price times hours) or single observations from one environment. Cost Explorer values are before billing is final (Estimated: true). They are not invoice amounts, and they do not guarantee that production would cost the same. I read them on 2026-10-13 JST with UnblendedCost, Credit and Refund excluded, for 2026-10-11 and 2026-10-12 (UTC dates). I read them again on 2026-10-21 JST under the same conditions: the main rows were unchanged to three decimals, and the values were still marked Estimated: true. The account is shared with other work, so I did not use service-level totals. I used only the values filtered by the CloudFormation tag aws:cloudformation:stack-name (appmod-base, appmod-stage3) and the values by resource ID.

Item Estimate Estimated duration Actual uptime and observed value
Base environment (second build) $57.71 72 hours About 33.6 hours (stack created 2026-10-11T02:08Z; deletion of appmod-base finished about 11:44Z on 2026-10-12). The tag-attributed observed value is $21.18, excluding Directory Service
Base environment, Directory Service Hourly rate $0.146 × 33.6 hours ≈ $4.91 33.6 hours Not separable by resource. The difference method (estimating this verification's share by subtracting the steady usage of days without the verification from the usage of the verification days) gives about $4.80
Base environment (first build) $19.23 24 hours Tag-attributed $6.31 (2026-10-07). The uptime is unknown, so I did not compare it with the estimate
atx analysis and dotnet-modernization Analysis about $3.24 (92.46 min × $0.035; upper bound $4.20). dotnet $0 inside the free allowance, about $4.87 outside (upper bound $10.50) About 15 min for analysis, about 20 min for dotnet Cost Explorer shows AWS Transform on 2026-10-11 as Usage $3.6221 (103.49 minutes: 92.465 for analysis, $3.24, and 11.025 for dotnet-modernization, $0.39) and a Credit of −$3.6221 for the same amount, so the net is $0. 2026-10-12 is $0
Lambda (each stage 3 run) Upper bounds of $0.04 (s3), $0.01 (s3b), $0.01 (s3m) Not applicable $0 on both days when filtered by tag. I did not measure the actual request counts
AIMF 284.26 Kiro credits (limit 5,000) 3 h 33 min from the first to the last commit As in the estimate column. Not converted to dollars

Source of this table: the estimate and actual uptime in docs/en/reproduce.md (commit-pinned; I reorganized the columns)

The $57.71 estimate for 72 hours is not the cost of the roughly 33.6 hours the environment was up. The whole estimate prorated to 33.6 hours is about $26.9 (the breakdown is in the private estimate made at approval time).

Hourly-rate estimates against observed values

I prorated the estimate's hourly rates to the same 33.6 hours of uptime and compared them.

Item Estimated hourly rate × 33.6 hours (USD) Observed (USD) Difference
FSx for ONTAP (SSD 1,024 GiB + 128 MBps throughput) 12.41 12.03 −3%
EC2 (Windows + Linux) 6.41 6.36 −1%
VPC interface endpoints 2.82 2.77 −2%
AWS Managed Microsoft AD 4.91 4.80 (difference method, inference) −2%
Total (excluding AD; the observed value includes $0.02 of S3) 21.64 21.18 −2%

Source of this table: the tag-attribution table and the estimate-against-actual comparison in docs/en/environment.md (I renamed the column "Observed (tag)" to "Observed" and changed some row names)

The observed value 21.18 is the sum of FSx for ONTAP 12.03, EC2 6.36, VPC 2.77, and S3 0.02. Each value is the two-day sum of the unrounded figures Cost Explorer returned, rounded afterwards. If you round each daily row first and add them up, some days are off by 0.01 (for 2026-10-11, the unrounded total is 13.8632 and the sum of the rounded rows is 13.87). The AD figure of 4.80 is an inference, so it is not in this total.

Identified cost of the whole verification

Item Day (UTC) and basis Amount (USD)
Tag-attributed: first build 2026-10-07 6.31
Tag-attributed: second build 2026-10-11, 2026-10-12 21.18
Tag-attributed subtotal — 27.49
Directory Service, second build Difference-method inference 4.80
AWS Transform usage Cost Explorer Usage line (Credit line excluded) 3.62
Identified cost — 35.91

Source of this table: the AD difference method, the identified total, and the AWS Transform line in docs/en/environment.md (I merged two tables into one)

Not in the $35.91:

  • The first build's Directory Service: an estimate only, about $1.6
  • Secrets Manager, CloudWatch, and Lambda: not separable; estimated at $0.1 to $0.2 in total
  • The four secrets I created by accident (failure 8): expected to be about $0.015 (1.5 cents)
  • The AWS Transform Credit line: with it included, the net is $0

Costs that could not be attributed, and the teardown check

This section answers two questions. The first is which costs of this verification I could not attribute, and why: AWS Directory Service, which Cost Explorer cannot filter by resource ID or by tag, and AWS Transform, whose billed minutes do not match the minutes in the atx records. The second is what I checked to confirm that everything created for the verification was destroyed. The cost values are Cost Explorer values before billing is final (Estimated), or inferences from them, and they are not invoice amounts.

Costs that could not be attributed, and why

Item Amount (USD; Cost Explorer values are Estimated) Attributed? Why Source and tier
AWS Directory Service (AWS Managed Microsoft AD, Standard, 2 DCs), second build About 4.80 No. Inferred with the difference method Cost Explorer shows no resource ID (NoResourceId), and the resource carries no CloudFormation tag Inference by the difference method (assumes the baseline is constant). Calculation in the next table
AWS Directory Service, first build (2026-10-07) About 1.6 No. The difference method cannot be used The usage on the day before, 2026-10-06, was 48.52 DC-hours, half of the baseline, so the baseline cannot be called constant Estimate only. Not in the $35.91
AWS Transform (2026-10-11) Usage line 3.6221; Credit line of the same day −3.6221 The amount, yes (the service-level Usage line). The minutes, no match The Usage line shows 103.49 minutes. The atx records show 92.46 minutes for analysis and 139.00 for dotnet-modernization, 231.46 in total, so the gap is 127.97 minutes. All of the gap is in dotnet-modernization. A free allowance may be the cause, but I have not confirmed it (details below the table) Cost Explorer Usage and Credit lines (Estimated); atx records

The identified cost of the whole verification, $35.91, includes the roughly $4.80 in the first row and the AWS Transform Usage line of $3.62. It does not include the roughly $1.6 in the second row. It does not deduct the AWS Transform Credit line (−$3.62); if the Credit is deducted, the AWS Transform portion nets to $0.

I could explain part of the AWS Transform minute gap (2026-10-11). The match between the hourly Cost Explorer data (UTC) and the atx log (debug.log) is verified (once each). Whether a free allowance is the cause is a hypothesis.

  • How the minutes are counted: the atx figures of 92.46 and 139.00 minutes are sums over the main agent and the sub-agents, not wall-clock time (verified). Wall-clock time was about 15 minutes for the analysis and about 20 minutes for dotnet-modernization. The pricing page says that agent minutes exceed wall-clock time when several agents run in parallel (documented). The analysis alone has 78.95 minutes on the main agent, which is already more than its wall-clock time of about 15 minutes, so I have not confirmed that parallelism by itself explains the difference.
  • The analysis matches: the hourly Cost Explorer data (UTC) shows 63.318 minutes at 09:00 and 29.147 at 10:00, 92.465 in total. That equals the atx record for the same hours (63.318 and 29.147).
  • Where the gap is: the Usage for the 11:00 UTC hour, when dotnet-modernization ran, is 11.025 minutes. The other 127.97 of the 139.00 minutes in the atx record do not appear as billable.
  • Dates and unit: both atx runs (the analysis and dotnet-modernization) fall inside UTC 2026-10-11, so a date boundary is not the cause. The total from 1 September to 21 October is also 103.49 minutes. The unit is minutes (AgentMinute).
  • A possible free allowance: AWS announced that the AWS-managed .NET modernization transformation includes 50,000 free agent minutes per month (AWS: .NET modernization via CLI is generally available; the allowance's existence is documented). Most of the dotnet-modernization minutes may have fallen inside it, but Cost Explorer shows no line for the allowance and I could not see how much of it was used.
  • Still unexplained: why exactly 11.025 minutes were billed. The seven PhaseValidator sub-agent runs (validation steps) in the same execution total 11.027 minutes, which is close to 11.025 minutes, but they differ by 0.002 minutes and chance cannot be ruled out. I also did not confirm why CloudWatch reports 144.75 minutes for dotnet-modernization against 139.00 in the atx record (a 5.75-minute difference).

The difference method is an inference that subtracts the steady usage of days without the verification from the usage of the verification days, to find this verification's share. The calculation for the second build's Directory Service is as follows.

Item Value Calculation or basis
Baseline (mean of days without the verification) 95.72 DC-hours per day Mean of 2026-10-08 to 2026-10-10
Difference on 2026-10-11 41.40 DC-hours 137.12 − 95.72
Difference on 2026-10-12 24.36 DC-hours 120.08 − 95.72
Total difference 65.76 DC-hours 2.1% off the expected 67.2 DC-hours
Unit price 0.073 USD per DC-hour 7.012 USD ÷ 96.05 DC-hours on 2026-10-08
Estimated amount About 4.80 USD 65.76 × 0.073

The 0.073 USD per DC-hour matches the Price List unit price for AWSDirectoryService, APN1-Std-MicrosoftAD-DC-Usage (Tokyo, Standard); I read it on 2026-10-21.

I read the atx unit price of $0.035 per agent minute again on 2026-10-21 in the Price List (AWSTransform, APN1-AgentMinute, Tokyo). It had not changed.

Teardown check results

I tore the base environment down on 2026-10-12. I confirmed the teardown through API listings and the teardown steps. The results are as follows.

Target How it was checked Result Date
Stacks starting with appmod API listing 0 2026-10-13 JST
S3 buckets starting with appmod API listing 0 2026-10-13 JST
Secrets starting with appmod API listing 0 2026-10-13 JST
Lambda functions starting with appmod API listing 0 2026-10-13 JST
S3 Access Points attachments starting with appmod API listing 0 2026-10-13 JST
Scheduler schedules starting with appmod API listing 0 2026-10-13 JST
Log groups starting with appmod API listing 0 2026-10-13 JST
File system, directory, EC2 instances, VPC API listing NotFound or terminated 2026-10-13 JST
Leftover FlexClones Environment teardown steps 4 and 5 None 2026-10-07 (first build), 2026-10-12 (second build)
it_* Snapshots Environment teardown steps 4 and 5 None 2026-10-07 (first build), 2026-10-12 (second build)
Recovery queue Environment teardown steps 4 and 5 Empty 2026-10-07 (first build), 2026-10-12 (second build)
Daily cost for 2026-10-13 (filtered by the tags appmod-base and appmod-stage3) Check in Cost Explorer (daily, UnblendedCost) 0 (Estimated: true) 2026-10-21 JST

What you can decide: for a configuration where you know the uptime, you can use the unit-price-times-hours estimate as material for a budget draft, as a single environment's data point. Before you use it as the basis of a budget, check an invoice. The items to check are the three whose limits are written in this chapter:

  • I have not yet reconciled the figures with an invoice
  • For the first build's Directory Service I have only an inference, not an actual amount
  • The 127.97-minute gap in AWS Transform minutes is entirely in dotnet-modernization. A free allowance may be the cause, but I have not confirmed it

What has not been measured yet

This chapter lists only the items that you need when deciding whether to apply this approach to your own app migration, and that I found measured nowhere in the documents of my other repositories that I checked. There are three. For each item I write what I did not measure, what another verification measured, when not knowing it hurts, and how to measure it. The results of other verifications are labeled carried-over. They are not values measured in this post's environment.

These items are not in this chapter. Security styles unix and mixed, other ONTAP versions and Multi-AZ, reconciliation with an invoice, and performance are in "When this post fits and when it does not" and "What this post covers and what it does not". The limits of the cost figures are in "Cost estimates and observations". I left out NFS without name mapping, the case where the writability check finds a file not writable, NFS Kerberos and mount options, and pairs of the same OS. They are variants of this post's question, and I judged that they add nothing to your decision about migrating your own app.

  1. S3 Access Points data operations while the AD domain controller is unreachable
    • What I did not measure: the results of S3 Access Points data operations (ListObjectsV2, GetObject, PutObject) on an AD-joined SVM when the domain controller (DC) is unreachable. In this verification I used the access point with the DC reachable
    • Status of public information (open): whether every data operation on an AD-joined SVM requires DC reachability is something I could not confirm, either from a record a reader can reproduce or from a public primary source (What an endpoint needs to reach S3 Access Points, Japanese)
    • What another verification measured (carried-over): on an SVM that was joined to a domain but found no DC at all, HeadBucket, ListObjectsV2, GetObject, PutObject, and DeleteObject all succeeded when the identity resolved to a local SMB user on the SVM (2026-08-26, ap-northeast-1, ONTAP 9.18.1P3D1, 128 MBps, an NTFS volume; file portal identity and visibility measurements). With a domain account as the identity, that verification could not tell whether a DC is required, because its environment had no reachable DC. That document says a re-measurement in an environment with a reachable DC is needed. This post's identity is the AD domain account appsvc and its environment had a reachable DC, so the conditions are close to that re-measurement. But I did not cut off the DC in this post either
    • When not knowing it hurts: you use S3 Access Points on an AD-joined SVM and AD goes down
    • How to measure: cut the route to the DC with a positive control and take the result of each operation. Do not judge by a successful HeadBucket alone
  2. When a file written by Lambda through S3 Access Points overlaps with a file a Windows app handles over SMB
    • What I did not measure: the results of two things. A write from S3 Access Points to a file while SMB holds an exclusive lock on it. The time until Windows can read a file written through S3 Access Points, and the time until S3 Access Points can read a file Windows wrote over SMB. In this verification Lambda ran only as the writer and there was no reader. The S3 API has no operation to open exclusively, and file-locking was skipped
    • What another verification measured (carried-over): the median time until a 64 B object written through S3 Access Points could be read directly from the volume over NFS (NFSv3, actimeo=0) was 3 ms, and until it could be read from the FlexCache side (FlexCache is an ONTAP volume that caches the data of a source volume) over a Linux cifs mount (SMB 3.0, cache=none) it was 7 ms (concurrency 1, 30 runs, 2026-08-09 and 2026-08-10, ap-northeast-1, ONTAP 9.18.1P3D1, 128 MBps, UNIX security style, UNIX root identity; comparison of visibility in all directions, Japanese). These are not values read by a Windows client, and not values for an NTFS volume
    • When not knowing it hurts: a Lambda function and a Windows app handle the same file at the same time. Also when you must build into the design the wait until Windows can read a file Lambda wrote
    • How to measure: design a pair experiment that links Lambda and Windows by a sync ID, and measure both directions
  3. The five behaviors on a clone of an NTFS volume made with FlexClone
    • What I did not measure: the five behaviors of a clone of an NTFS volume made with FlexClone, opened over SMB, NFS, and S3 Access Points. I did not create a single FlexClone in this verification
    • What another verification measured (carried-over): when I attached S3 Access Points to a clone of a 1 GiB volume with the UNIX security style, LIST, GET, and PUT worked. The sha256 of a 256 MiB file matched the source volume, and an object PUT to the clone did not appear in the source volume (2026-08-26, ap-northeast-1, ONTAP 9.18.1P3D1, 128 MBps, UNIX root identity; measurement of ONTAP features over S3 Access Points, Japanese). That document has no measurement of a clone of an NTFS volume
    • When not knowing it hurts: you hand out a working volume per developer or per stage from a Snapshot of appdata and apply the results measured on appdata to the clone as they are
    • How to measure: create a clone of an NTFS volume and run the same Probe over each path that opens the clone

Governance note
This article describes design considerations. It is not a legal, compliance, or regulatory judgment. The ADR option summaries and the independent verification result are AI output and serve as supporting signals. I made the decisions, including the decision to keep the security style at ntfs.

What you can decide: look at which of the three items above your environment falls under, and decide whether to add that item's "How to measure" to your own measurement items.

Summary and choosing an access path

The results are the three findings at the start of this post (the converted app ran on Lambda without moving the data, what changed with the path, and the difference in scope between atx and AIMF). There were 8 failures, and the measurement with no positive control invalidated the case-sensitivity values in 6 runs, more than any other failure.

The three paths coexisted on the same volume up to stage 3. The criterion for choosing is which client you use.

Choice When Constraints (from this environment's measurements)
Stay on SMB You keep the existing Windows app unchanged and want to keep the case-insensitive assumption Windows and cifs resolved a reference with different letter case. NFS and S3 Access Points did not. Write visibility (the time until a file one client wrote becomes visible to the other) was 66 ms with Windows writing and cifs reading, and 7,968 ms in the reverse direction (s2m, one observation)
Add NFS You want a Linux app to run on the same data On NFS, a reference that differs only in letter case does not resolve. the NFS root user could not enter /mnt/appdata (the cause is an inference from the export policy setting), while the uid 10001 user could read and write. I did not measure the case with no name mapping
Serve through S3 Access Points An S3 API client such as Lambda needs to use the data On S3 Access Points, a reference that differs only in letter case does not resolve. The S3 API has no operation to open exclusively, and a key made only of \ was created directly under the volume root and appeared in the SMB listing as an 8.3 short name (one observation). The fixed NetworkOrigin and the AD domain controller reachability (open) are as in the table in the design chapter

The repository is here:
https://github.com/Yoshiki0705/FSx-for-ONTAP-App-Modernization-Patterns

The previous article is "AWS Transform Now Supports Block Storage Migration to FSx for ONTAP — Benefits and Pitfalls from a Hands-On EC2 Test".
Related articles (two):

I thank the people who publish AIMF and atx, and the people who support my day-to-day work.

I hope this is useful to anyone considering moving a .NET Framework app to a newer .NET without moving its data.

See you next time.
Bye now!!
Yoshiki Fujiwara

Resources

Top comments (0)