Topic page · SQL Server on Linux and Docker

Tuning SQL Server on Linux: What Microsoft recommends and what actually helps

PREEMPTIVE_OS_FLUSHFILEBUFFERS is at the top of your wait statistics. On Windows that would be a rare sight. On Linux it is usually normal operation: after writing, SQL Server explicitly asks the operating system to put the data on the disk, and waits for it. In our lab this happened after every single commit.

Updated Nov 12, 2026 · approx. 15 min read

Part 4 · PerformanceSQL query 4Lab SQL Server 2017–2025
One commit on LinuxSQL Server 2025 · labWrite logfdatasyncdonewait type PREEMPTIVE_OS_FLUSHFILEBUFFERSDefault: 7.1 ms per commit, 6.4 ms of it flushingNo flush (trace flag 3979, measurement only): 3.4 ms each0 ms10 ms20 ms

This page goes through what determines performance and stable operation of SQL Server on Linux: the kernel and system settings Microsoft recommends, flushing on writes, the limits for memory and CPU, where the database files live, and monitoring. What we could measure, we measured on SQL Server 2017, 2019, 2022 and 2025 in Linux containers. What we could not measure is stated as a Microsoft recommendation, with its source.

In 60 seconds

  • Microsoft recommends a set of kernel settings for SQL Server hosts, bundled in the TuneD profile mssql: Transparent Huge Pages on, swappiness 1, NUMA balancing off, large read-ahead, noatime, XFS. Many standard installations differ from it, and so does our lab server. The host check on this page shows your deviations.
  • Writing costs one flush per commit on Linux. That is the safe default. It only gets faster with storage that supports FUA and the matching settings. writethrough = 0 can cost you data.
  • Limit memory in three levels: container or host limit, memory.memorylimitmb for the whole SQL Server process, max server memory for the buffer pool. Leave 10 to 20 percent headroom in between. SQL Server 2017 and 2019 without a setting were killed by the kernel in our 4 GB container, without a single line in the errorlog.
  • SQL Server does not see a CPU quota. PROCESS AFFINITY on as many CPUs as the quota allows was 16 to 20 percent faster in our lab and almost removed the throttling. Trace flag 8002 added nothing measurable.
  • Container images only configure themselves in part: From 2019 they create tempdb files by CPU count (eight at most), but MAXDOP stays 0 and the cost threshold stays 5. Both need adjusting after the first start.
  • Where the files live in the container did not matter for performance: write layer, bind mount and volume were on par. The write layer is still ruled out, for operational reasons.

1 · Microsoft's list for the host

Microsoft describes the recommended settings on the page "Performance best practices and configuration guidelines for SQL Server on Linux" and bundles them in the TuneD profile mssql (Red Hat ships it, on Ubuntu you create it by hand). All of these values belong to the host: in a container you can read them but not change them.

SettingRecommendation (Microsoft)Linux default (Ubuntu 24.04, our lab)Purpose
Transparent Huge Pagesalways (several instances: madvise)madviselarge memory pages for the buffer pool
vm.swappiness160do not swap out the buffer pool
vm.dirty_ratio / dirty_background_ratio80 / 320 / 10the kernel's write buffer
vm.max_map_count1,600,000 (at least 262,144)1,048,576many memory regions of the process
kernel.numa_balancing0 with several NUMA nodes1 (2 nodes)SQL Server manages NUMA itself
TuneD profilemssqlnonebundles the values above
Read-ahead4096 sectors per device128 KBlarger reads
Mount optionnoatimerelatimeno access-time writes
File systemXFS (ext4 supported)ext4
Swappresent, as a safety netnoneprotection from the OOM killer

We did not measure how much each deviation costs: our lab server is shared with other measurements, and changes to the kernel would have affected them. The recommendations apply to servers where SQL Server is the main load; for a host with several applications or instances, Microsoft itself names exceptions (for example madvise instead of always).

There are differences even between Microsoft's page and the profile Red Hat ships: the upstream profile sets the dirty values in bytes and keeps CPU scheduler values active that Microsoft leaves out for newer kernels. If you rebuild TuneD on Ubuntu, pick one version and document it.

Host check

The following script reads the values and compares them with the recommendation. It changes nothing.

#!/usr/bin/env bash
# PSG Stack · SQL Server on Linux and Docker · Host check
# Compares the Linux settings of a SQL Server host with Microsoft's recommendations
# (Learn: "Performance best practices: Storage, kernel, CPU, and network", as of 05/2026).
#
# !!! WARNING — please read before running !!!
# - Read-only. The script changes no setting and writes no file.
# - Usage: bash host-check-en.sh [data directory] [log directory]
#   Default: /var/opt/mssql/data for both. Without root, single values may be missing (shown as "?").
# - Kernel values apply to the whole host. Inside a container the script shows the host's values;
#   they can only be changed on the host, not in the container.
# - "check" means: different from Microsoft's recommendation. Whether the difference costs you anything
#   is not something the script can tell; the recommendations apply to dedicated SQL Server hosts.
# - Tested in the PSG lab on Ubuntu 24.04 (host) and in the containers of SQL Server 2017 to 2025
#   (13 Nov 2026); not tested on Red Hat or SUSE.
set -u
DATA=${1:-/var/opt/mssql/data}
LOG=${2:-$DATA}
rd() { cat "$1" 2>/dev/null | tr -d '\n' || true; }
row() { printf '%-34s %-28s %-30s %s\n' "$1" "$2" "$3" "$4"; }
cmp() { if [ "$1" = "$2" ]; then echo ok; elif [ -z "$1" ] || [ "$1" = "?" ]; then echo "?"; else echo check; fi; }

echo "Host check SQL Server on Linux · $(date '+%Y-%m-%d %H:%M') · $(hostname)"
. /etc/os-release 2>/dev/null; echo "System: ${PRETTY_NAME:-?} · kernel $(uname -r)"
if [ -f /.dockerenv ] || grep -qa docker /proc/1/cgroup 2>/dev/null; then echo "Note: running in a container; kernel values are the host's."; fi
echo
row "Setting" "Current" "Recommended (Microsoft)" "Rating"
row "-------" "-------" "-----------------------" "------"

thp=$(rd /sys/kernel/mm/transparent_hugepage/enabled | sed -n 's/.*\[\(.*\)\].*/\1/p'); thp=${thp:-?}
row "Transparent Huge Pages" "$thp" "always (several instances: madvise)" "$(cmp "$thp" always)"
sw=$(rd /proc/sys/vm/swappiness); sw=${sw:-?}
row "vm.swappiness" "$sw" "1" "$(cmp "$sw" 1)"
mm=$(rd /proc/sys/vm/max_map_count); mm=${mm:-?}
if [ "$mm" != "?" ] && [ "$mm" -ge 262144 ] 2>/dev/null; then b=ok; [ "$mm" -lt 1600000 ] && b="ok (profile: 1600000)"; else b=$(cmp "$mm" 1600000); fi
row "vm.max_map_count" "$mm" "1600000 (at least 262144)" "$b"
dr=$(rd /proc/sys/vm/dirty_ratio); dbr=$(rd /proc/sys/vm/dirty_background_ratio)
row "vm.dirty_ratio / background" "${dr:-?} / ${dbr:-?}" "80 / 3 (TuneD profile mssql)" "$( [ "${dr:-}" = 80 ] && [ "${dbr:-}" = 3 ] && echo ok || echo check )"
numa=$(ls -d /sys/devices/system/node/node* 2>/dev/null | wc -l)
nb=$(rd /proc/sys/kernel/numa_balancing); nb=${nb:-?}
if [ "$numa" -gt 1 ]; then b=$(cmp "$nb" 0); else b="ok (1 NUMA node)"; fi
row "kernel.numa_balancing" "$nb ($numa NUMA nodes)" "0 with several NUMA nodes" "$b"
if command -v tuned-adm >/dev/null 2>&1; then t=$(tuned-adm active 2>/dev/null | sed -n 's/.*: //p'); else t="no TuneD"; fi
row "TuneD profile" "${t:-?}" "mssql" "$( [ "${t:-}" = mssql ] && echo ok || echo check )"
gov=$(rd /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor); gov=${gov:-"n/a (VM?)"}
row "CPU governor" "$gov" "performance" "$( [ "$gov" = performance ] && echo ok || { [ "${gov#n/a}" != "$gov" ] && echo info || echo check; } )"
sw_on=$(awk 'NR>1' /proc/swaps 2>/dev/null | wc -l)
row "Swap configured" "$( [ "$sw_on" -gt 0 ] && echo yes || echo no )" "yes (safety net, not capacity)" "$( [ "$sw_on" -gt 0 ] && echo ok || echo check )"

for dir in $(printf "%s\n%s\n" "$DATA" "$LOG" | awk '!s[$0]++'); do
  [ -d "$dir" ] || { row "Directory $dir" "missing" "" "?"; continue; }
  read -r dev fs opts < <(awk -v p="$(df -P "$dir" 2>/dev/null | awk 'NR==2{print $6}')" '$2==p{print $1, $3, $4}' /proc/mounts | tail -1)
  if [ "${fs:-}" = overlay ]; then
    row "File system $dir" "overlay" "volume or bind mount" "check (container writable layer)"; continue; fi
  row "File system $dir" "${fs:-?}" "xfs (ext4 supported)" "$( [ "${fs:-}" = xfs ] && echo ok || { [ "${fs:-}" = ext4 ] && echo ok || echo check; } )"
  row "  Mount option atime" "$(echo "${opts:-?}" | grep -o 'noatime\|relatime\|strictatime' | head -1)" "noatime" "$(echo "${opts:-}" | grep -q noatime && echo ok || echo check)"
  blk=$(basename "$(readlink -f "${dev:-x}" 2>/dev/null)" | sed 's/[0-9]*$//; s/p$//')
  if [ -n "$blk" ] && [ -d "/sys/block/$blk/queue" ]; then
    ra=$(rd /sys/block/$blk/queue/read_ahead_kb); sch=$(rd /sys/block/$blk/queue/scheduler | sed -n 's/.*\[\(.*\)\].*/\1/p')
    fua=$(rd /sys/block/$blk/queue/fua); wc=$(rd /sys/block/$blk/queue/write_cache)
    row "  Read-ahead ($blk)" "${ra:-?} KB" "4096 (blockdev --setra, sectors)" "info"
    row "  I/O scheduler ($blk)" "${sch:-?}" "no value given (multiqueue)" "info"
    row "  FUA / write cache ($blk)" "${fua:-?} / ${wc:-?}" "only with FUA=1 trace flag 3979" "info"
  else
    row "  Block device" "not determinable" "" "?"
  fi
done

CONF=/var/opt/mssql/mssql.conf
echo
if [ -r "$CONF" ]; then
  echo "mssql.conf ($CONF), entries relevant for performance:"
  grep -E '^\s*(writethrough|alternatewritethrough|memorylimitmb|traceflag[0-9]*)\s*=' "$CONF" | sed 's/^/  /' || echo "  (none; defaults apply)"
else
  echo "mssql.conf not readable or not present (defaults apply; in a container possibly MSSQL_* variables)."
fi
echo "Linux default: forced flush (trace flag 3982), control.writethrough = 1, control.alternatewritethrough = 1."
echo "Never set writethrough = 0 unless it is proven that the storage is O_DIRECT safe."

Sample output

From the lab: our host with Ubuntu 24.04, data and log on separate virtual disks. Almost every kernel setting differs from Microsoft's list; that is the normal case for a standard installation.

Sample output
Host check SQL Server on Linux · 2026-11-13 10:33 · psg-sql-lab01
System: Ubuntu 24.04.5 LTS · kernel 6.8.0-147-generic

Setting                            Current                      Recommended (Microsoft)        Rating
-------                            -------                      -----------------------        ------
Transparent Huge Pages             madvise                      always (several instances: madvise) check
vm.swappiness                      60                           1                              check
vm.max_map_count                   1048576                      1600000 (at least 262144)      ok (profile: 1600000)
vm.dirty_ratio / background        20 / 10                      80 / 3 (TuneD profile mssql)   check
kernel.numa_balancing              1 (2 NUMA nodes)             0 with several NUMA nodes      check
TuneD profile                      no TuneD                     mssql                          check
CPU governor                       n/a (VM?)                    performance                    info
Swap configured                    no                           yes (safety net, not capacity) check
File system /srv/sql/data          ext4                         xfs (ext4 supported)           ok
  Mount option atime               relatime                     noatime                        check
  Read-ahead (sdc)                 128 KB                       4096 (blockdev --setra, sectors) info
  I/O scheduler (sdc)              none                         no value given (multiqueue)    info
  FUA / write cache (sdc)          0 / write back               only with FUA=1 trace flag 3979 info
File system /srv/sql/log           ext4                         xfs (ext4 supported)           ok
  Mount option atime               relatime                     noatime                        check
  Read-ahead (sdd)                 128 KB                       4096 (blockdev --setra, sectors) info
  I/O scheduler (sdd)              none                         no value given (multiqueue)    info
  FUA / write cache (sdd)          0 / write back               only with FUA=1 trace flag 3979 info

mssql.conf not readable or not present (defaults apply; in a container possibly MSSQL_* variables).
Linux default: forced flush (trace flag 3982), control.writethrough = 1, control.alternatewritethrough = 1.
Never set writethrough = 0 unless it is proven that the storage is O_DIRECT safe.

2 · Writing and flushing

A commit is only complete when the log record is safely on the disk. On Windows, SQL Server requests this with a flag on each write. On Linux it depends on whether the disk supports "Forced Unit Access" (FUA), that is, whether it can make individual writes durable, bypassing the cache.

VariantSettingsWhen
Forced flush (default)trace flag 3982 (internal), control.writethrough = 1, control.alternatewritethrough = 1always, as long as the storage is not proven to support FUA
FUA writestrace flag 3979, writethrough = 1, alternatewritethrough = 0only with FUA-capable storage (XFS from kernel 4.18, ext4 from 5.6), not on overlayfs
unsafewritethrough = 0never, unless it is proven that the storage is O_DIRECT-safe

By default, SQL Server calls fdatasync after writing; that is the wait type PREEMPTIVE_OS_FLUSHFILEBUFFERS. In our lab (data and log on separate virtual disks, ext4, disks without FUA) this is what many small transactions looked like:

Commits/s (one session / four sessions)2017201920222025
Default (flush per commit, 6.4 ms on average)141 / 542141 / 463140 / 427140 / 380
Trace flag 3979, alternatewritethrough = 0 (no flush)293 / 777293 / 931291 / 662290 / 894

All four versions behave the same: 20,000 commits, about 20,000 calls of fdatasync. On SQL Server 2025, a commit took about 7.1 ms by default, of which 6.4 ms was flush; without flush about 3.4 ms.

Default (forced flush) · 140 commits/s · 6.4 ms flush per commitTrace flag 3979, alternatewritethrough = 0 · 290 commits/s · no flush (measurement only)0 ms20 ms40 ms60 ms80 ms100 msBlue: writing the log record until acknowledged. Orange: waiting for fdatasync(PREEMPTIVE_OS_FLUSHFILEBUFFERS). One session, 20,000 single commits, averages.
Default (forced flush) · 140 commits/s · 6.4 msflush per commitTrace flag 3979, alternatewritethrough = 0 · 290commits/s · no flush (measurement only)0 ms50 ms100 msBlue: writing the log record until acknowledged.Orange: waiting for fdatasync(PREEMPTIVE_OS_FLUSHFILEBUFFERS). One session,20,000 single commits, averages.
The first 100 ms of a session with many small transactions, SQL Server 2025 in the lab (disks without FUA). Trace flag 3979 is not a recommendation here; it shows what the flush costs.

The second row is not a recommendation: our disks report no FUA, and Microsoft names trace flag 3979 only for storage that supports it. We did not check whether the kernel really flushes the disk cache for every write in this case. The measurement shows what the flush per commit costs, not that you should switch it off.

Two observations for operations:

  • DBCC TRACESTATUS does not show the default: trace flag 3982 appears with status 0 even though forced flush is active. If 3979 is set, it shows with status 1 and in the errorlog as the startup parameter -T 3979.
  • SQL Server does not see control.writethrough and control.alternatewritethrough; they are in /var/opt/mssql/mssql.conf in the [control] section, set with mssql-conf set control.alternatewritethrough 0. If the section is missing, the defaults apply.

High WRITELOG times have the same causes on Linux as on Windows: a slow log disk or very many very small transactions. On Linux, every commit you save by grouping rows into one transaction also saves a flush.

3 · Memory in three levels

LevelLimitsSet with
Host or containereverythingVM size or docker run --memory
memory.memorylimitmbthe whole SQL Server process (buffer pool, SQLPAL, Agent, full-text)mssql-conf set memory.memorylimitmb, in the container MSSQL_MEMORY_LIMIT_MB
max server memorythe buffer pool onlysp_configure

Microsoft recommends 10 to 20 percent headroom between the container limit and memory.memorylimitmb, and max server memory below that. Without a setting, SQL Server takes 80 percent of the smaller of host memory and container limit, but only from 2022 CU20 also under cgroup v2; 2017 and 2019 do not see the limit of a container there (Part 3).

We reproduced what happens with a 4 GB container (--memory=4g, no swap) and a load that reads and groups a 4.7 GB table several times:

Versionno settingmemory.memorylimitmb 20% below the limit (3,277 MB)
2017sees 64 GB, target 60 GB: killed by the kernelload finished, peak 2.8 GB
2019sees 64 GB, target 61 GB: killed by the kernelload finished, peak 3.2 GB
2022sees 3.2 GB (80% of the limit): load finished, peak 3.3 GBload finished, peak 3.3 GB
2025sees 3.2 GB (80% of the limit): load finished, peak 3.3 GBload finished, peak 3.4 GB
SQL Server 2017no setting · sees 64 GBkilled by the kernelmemory.memorylimitmb 20% belowpeak 2.8 GBSQL Server 2019no setting · sees 64 GBkilled by the kernelmemory.memorylimitmb 20% belowpeak 3.2 GBSQL Server 2022no setting · sees 3.2 GBpeak 3.3 GBmemory.memorylimitmb 20% belowpeak 3.3 GBSQL Server 2025no setting · sees 3.2 GBpeak 3.3 GBmemory.memorylimitmb 20% belowpeak 3.4 GBcontainer limit 4 GB0 GB1 GB2 GB3 GB4 GBBar: highest memory use of the container (cgroup) under load. Orange with a cross: SQL Server planned withhost memory and was killed at the limit, without a line in the errorlog.
SQL Server 2017no setting · sees 64 GBkilled by the kernelmemory.memorylimitmb 20% belowpeak 2.8 GBSQL Server 2019no setting · sees 64 GBkilled by the kernelmemory.memorylimitmb 20% belowpeak 3.2 GBSQL Server 2022no setting · sees 3.2 GBpeak 3.3 GBmemory.memorylimitmb 20% belowpeak 3.3 GBSQL Server 2025no setting · sees 3.2 GBpeak 3.3 GBmemory.memorylimitmb 20% belowpeak 3.4 GBcontainer limit 4 GB0 GB1 GB2 GB3 GB4 GBBar: highest memory use of the container(cgroup) under load. Orange with a cross: SQLServer planned with host memory and was killedat the limit, without a line in the errorlog.
Without a setting, SQL Server 2017 and 2019 plan with 80 percent of host memory and are killed in the 4 GB container. With memory.memorylimitmb 20 percent below the limit, the load completed on all versions tested.

SQL Server 2022 and 2025 even survived memory.memorylimitmb equal to the container limit with this load (peak 3.87 and 3.76 of 4 GB). That does not mean it is safe without headroom: we did not stress Agent, CLR or large memory grants for queries.

An OOM kill leaves no line in the SQL Server errorlog. In our lab the errorlog ended with the normal startup messages, then nothing. The evidence is in two other places: in the kernel log (dmesg or journalctl -k: oom-kill:constraint=CONSTRAINT_MEMCG … oom_memcg=/system.slice/docker-<id>.scope) and, with Docker, in the container state (docker inspect: OOMKilled: true, exit code 137). If you only monitor the errorlog, you see a restart without a reason.

A bug where the process used more memory than memory.memorylimitmb allows is fixed in SQL Server 2019 CU27 and 2022 CU14 (KB5042369).

4 · CPU and NUMA

SQL Server does not see a CPU quota (docker run --cpus, Kubernetes CPU limits): it creates a scheduler for every CPU of the host and plans parallelism accordingly (Part 3). As a countermeasure, Microsoft names ALTER SERVER CONFIGURATION SET PROCESS AFFINITY on as many CPUs as the quota allows, under cgroup v2 together with trace flag 8002 (soft affinity: the schedulers may switch CPUs). We measured this with a parallel aggregation in a container with --cpus=4 on a host with 24 CPUs:

Runtime (median of 5) and time throttled by the kernel201920222025
Default: 24 schedulers, parallelism 24775 ms, 51 s873 ms, 60 s991 ms, 58 s
PROCESS AFFINITY CPU = 0 TO 3: 4 schedulers648 ms, 0 s697 ms, 0 s791 ms, 0 s
Affinity and trace flag 8002637 ms, 0 s707 ms, 0 s776 ms, 0 s
trace flag 8002 only790 ms, 49 s874 ms, 61 s984 ms, 62 s
SQL Server 2019Default · 24 schedulers775 ms · throttled 51 sPROCESS AFFINITY 0–3648 msAffinity + trace flag 8002637 msSQL Server 2022Default · 24 schedulers873 ms · throttled 60 sPROCESS AFFINITY 0–3697 msAffinity + trace flag 8002707 msSQL Server 2025Default · 24 schedulers991 ms · throttled 58 sPROCESS AFFINITY 0–3791 msAffinity + trace flag 8002776 ms0 ms500 ms1,000 msContainer with --cpus=4 on 24 CPUs, parallel aggregation over 4 million rows, median of five runs. Orange:SQL Server plans with 24 schedulers and is throttled by the kernel.
SQL Server 2019Default · 24 schedulers775 msthrottled by the kernel: 51 s in 5 runsPROCESS AFFINITY 0–3648 msAffinity + trace flag 8002637 msSQL Server 2022Default · 24 schedulers873 msthrottled by the kernel: 60 s in 5 runsPROCESS AFFINITY 0–3697 msAffinity + trace flag 8002707 msSQL Server 2025Default · 24 schedulers991 msthrottled by the kernel: 58 s in 5 runsPROCESS AFFINITY 0–3791 msAffinity + trace flag 8002776 ms0 ms500 ms1,000 msContainer with --cpus=4 on 24 CPUs, parallelaggregation over 4 million rows, median of fiveruns. Orange: SQL Server plans with 24schedulers and is throttled by the kernel.
PROCESS AFFINITY matched to the quota removes the throttling and makes the query 16 to 20 percent faster. Trace flag 8002 changes little.

Without affinity, SQL Server plans 24 parallel threads on four permitted CPUs; the kernel then keeps stalling the container for the rest of its time slice. With affinity on four CPUs, parallelism matches the quota, the throttling disappears, and the query gets 16 to 20 percent faster. Trace flag 8002 (soft affinity) changed the median by 2 percent at most, with strong outliers in both directions on 2022 and 2025. In a smaller preliminary run with 800,000 rows, affinity alone was slower; that was due to the small amount of data.

Our recommendation for containers with a CPU quota: set PROCESS AFFINITY to match the quota; use trace flag 8002 only if Microsoft's note applies to your environment and you measure it. Pinned CPUs (--cpuset-cpus) are simpler; SQL Server 2022 and 2025 recognize them, while 2017 and 2019 still see all CPUs of the host.

NUMA: On hosts with several NUMA nodes, Microsoft recommends turning off the kernel's automatic NUMA balancing (kernel.numa_balancing = 0), because SQL Server controls its own memory placement. We did not measure this.

Defaults in the container: This is how the images configure themselves at first start on our host with 24 CPUs:

2017201920222025
tempdb data files, no limit or with --cpus=61888
tempdb data files, with 6 pinned CPUs (--cpuset-cpus)1866
MAXDOP / cost threshold0 / 50 / 50 / 50 / 5

So from 2019 on, the images create tempdb like a package installation does: one file per CPU, eight at most. What counts is what SQL Server sees as a CPU; it does not see a quota, and pinned CPUs only from 2022. MAXDOP and cost threshold, on the other hand, are at the old default in all versions. Set both after the first start, as described in our tempdb guide and as on Windows.

A trap for scripts: in the container, SQL Server reports itself ready ("SQL Server is now ready for client connections") before tempdb is finished. The errorlog first shows “The tempdb database has 1 data file(s).” and one to two seconds later the final number. If you check or start load right after the first login, you see an intermediate state or get error 904 (“cannot be autostarted during server shutdown or start”). In start scripts, wait for “Recovery is complete” in the errorlog, not only for a successful login.

5 · Where the database files live

In a container, database files can live in the write layer of the container (overlayfs), on a bind mount of a host directory, or in a named Docker volume. Docker recommends volumes for write-heavy loads; Microsoft explicitly requires a mount, not overlayfs, for the FUA path. We measured the same load in four variants:

SQL Server 2025Commits/s (1 / 4 sessions)Bulk insert 1 million rowsBackup
Container write layer (overlayfs)141 / 4223.21 s3.23 s
Bind mount, same disk141 / 4423.21 s3.28 s
Named volume141 / 4243.23 s3.35 s
Bind mounts, data and log on separate disks141 / 4723.16 s3.43 s

SQL Server 2022 showed the same picture (141 commits/s in all variants, bulk 2.99 to 3.17 s); the values with four sessions scattered between 334 and 482 without a pattern. On our host, the location therefore did not matter for performance. Still, database files do not belong in the write layer: they disappear with the container, and updates require recreating the container (Part 3). Separate disks for data and log remain sensible if the disks themselves are separate; with virtual disks on the same SSD as here, it brings little.

File system: Microsoft recommends XFS for data and log, ext4 is supported; plus noatime. We did not measure XFS against ext4, and we did not find a published measurement.

6 · Monitoring on Linux

Inside the database, you monitor on Linux as you do on Windows: Query Store, DMVs, Extended Events, the Performance Dashboard in SSMS. The view of the operating system is different:

  • No Performance Monitor. The SQL Server counters are available through sys.dm_os_performance_counters; you have to compute rates and percentages from the raw values yourself.
  • Operating system tools: top/htop, vmstat, iostat, sar (package sysstat). Red Hat ships a plug-in for SQL Server with Performance Co-Pilot (pmdamssql), which collects DMV values as metrics.
  • Wait types that look different on Linux: PREEMPTIVE_OS_FLUSHFILEBUFFERS is often near the top and is not a finding on its own (section 2).
  • The Management Data Warehouse (Data Collector) in SSMS is not supported against Linux instances.

Operations check: How is my instance doing?

The following query rates the points on this page for one instance, one row per check. What the instance sees of its platform and container limits is shown by Query 2 in Part 3; Query 1 in Part 1 shows which features it uses that are missing on Linux.

Query: Operations check

Query 4: Operations check · read-only · tested in the PSG lab, versions in the header
/* PSG Stack · SQL Server on Linux and Docker · Query 4 "Operations check"   Checks settings and waits that matter on Linux for performance and stable operation:   flush trace flags, memory limits and memory pressure, CPUs and schedulers, MAXDOP, tempdb,   waits when writing the transaction log. One row per check with a rating.    !!! WARNING — please read before running !!!   - Read-only. The query changes no data and no setting.   - Permissions: VIEW SERVER STATE (from 2022, VIEW SERVER PERFORMANCE STATE covers most checks);     without VIEW ANY DEFINITION the tempdb check reports "not checked".   - MAXDOP 1: every part runs serially and uses no additional cores.   - Waits count since the last start or since the statistics were last cleared.   - Rating "check" means: look closer, do not change automatically. Guidelines follow Microsoft     Learn (Linux performance pages, as of 05/2026) and the PSG lab; they do not replace an analysis.   - SQL Server 2017 and later. Tested in the PSG lab on 2017, 2019, 2022 and 2025 in Linux containers,     as sa and without sysadmin (13 Nov 2026); not tested on Windows (Linux checks show n/a there).*/SET NOCOUNT ON; DECLARE @r TABLE (nr int, area nvarchar(20), [check] nvarchar(80), value nvarchar(200),                  guideline nvarchar(200), rating nvarchar(10), note nvarchar(400));DECLARE @linux bit = 0, @cpu int, @sched int, @mem_mb bigint, @target_mb bigint, @model nvarchar(60),        @maxmem bigint, @maxdop int, @ctp int, @container nvarchar(60) = N'n/a'; IF OBJECT_ID(N'sys.dm_os_host_info') IS NOT NULL    SELECT @linux = CASE WHEN host_platform = N'Linux' THEN 1 ELSE 0 END FROM sys.dm_os_host_info OPTION (MAXDOP 1); SELECT @cpu = cpu_count, @mem_mb = physical_memory_kb / 1024, @target_mb = committed_target_kb / 1024,       @model = sql_memory_model_descFROM sys.dm_os_sys_info OPTION (MAXDOP 1);IF EXISTS (SELECT 1 FROM sys.all_columns WHERE object_id = OBJECT_ID(N'sys.dm_os_sys_info') AND name = N'container_type_desc')    EXEC sys.sp_executesql N'SELECT @c = container_type_desc FROM sys.dm_os_sys_info OPTION (MAXDOP 1);', N'@c nvarchar(60) OUTPUT', @c = @container OUTPUT;SELECT @sched = COUNT(*) FROM sys.dm_os_schedulers WHERE status = N'VISIBLE ONLINE' OPTION (MAXDOP 1);SELECT @maxmem = CONVERT(bigint, value_in_use) FROM sys.configurations WHERE name = N'max server memory (MB)' OPTION (MAXDOP 1);SELECT @maxdop = CONVERT(int, value_in_use) FROM sys.configurations WHERE name = N'max degree of parallelism' OPTION (MAXDOP 1);SELECT @ctp = CONVERT(int, value_in_use) FROM sys.configurations WHERE name = N'cost threshold for parallelism' OPTION (MAXDOP 1); /* 1 · Writing and flushing (Linux only) */DECLARE @tf TABLE (TraceFlag int, Status int, Global int, Session int);BEGIN TRY    INSERT @tf EXEC (N'DBCC TRACESTATUS(3979, 3982, 8002, -1) WITH NO_INFOMSGS');END TRYBEGIN CATCHEND CATCH;INSERT @r SELECT 1, N'Flush', N'Flush mode (trace flag 3979)',    CASE WHEN @linux = 0 THEN N'n/a (Windows)' WHEN NOT EXISTS (SELECT 1 FROM @tf) THEN N'not readable'         WHEN EXISTS (SELECT 1 FROM @tf WHERE TraceFlag = 3979 AND Global = 1) THEN N'FUA writes (3979 on)'         ELSE N'forced flush (default, 3979 off)' END,    N'forced flush, unless the storage provably supports FUA', N'info',    N'Forced flush is the Linux default, even though DBCC TRACESTATUS does not show 3982 as active (PSG lab). 3979 only with FUA-capable storage and alternatewritethrough = 0.';INSERT @r SELECT 2, N'Flush', N'writethrough / alternatewritethrough', N'in mssql.conf (SQL Server does not show them)',    N'1 / 1 (default) or 1 / 0 with trace flag 3979', N'info',    N'Check with cat /var/opt/mssql/mssql.conf, section [control]; if it is missing, the defaults apply. writethrough = 0 can lose data.'; /* 2 · Waits when writing (since start) */DECLARE @total bigint, @flush bigint, @wl_ms bigint, @wl_n bigint;SELECT @total = SUM(wait_time_ms) FROM sys.dm_os_wait_statsWHERE wait_type NOT LIKE N'SLEEP%' AND wait_type NOT LIKE N'BROKER%' AND wait_type NOT LIKE N'XE%'  AND wait_type NOT LIKE N'%QUEUE%' AND wait_type NOT LIKE N'HADR%' AND wait_type NOT LIKE N'SQLTRACE%'  AND wait_type NOT LIKE N'CLR%' AND wait_type NOT LIKE N'FT_%' AND wait_type NOT LIKE N'%IDLE%'  AND wait_type NOT IN (N'WAITFOR', N'CHECKPOINT_QUEUE', N'DIRTY_PAGE_POLL', N'DISPATCHER_QUEUE_SEMAPHORE',      N'LOGMGR_QUEUE', N'REQUEST_FOR_DEADLOCK_SEARCH', N'SP_SERVER_DIAGNOSTICS_SLEEP', N'QDS_ASYNC_QUEUE',      N'QDS_PERSIST_TASK_MAIN_LOOP_SLEEP', N'QDS_CLEANUP_STALE_QUERIES_TASK_MAIN_LOOP_SLEEP', N'PWAIT_EXTENSIBILITY_CLEANUP_TASK',      N'SOS_WORK_DISPATCHER', N'PVS_PREALLOCATE', N'PREEMPTIVE_XE_DISPATCHER', N'PREEMPTIVE_OS_FLUSHFILEBUFFERS_IDLE')OPTION (MAXDOP 1);SELECT @flush = SUM(CASE WHEN wait_type = N'PREEMPTIVE_OS_FLUSHFILEBUFFERS' THEN wait_time_ms ELSE 0 END),       @wl_ms = SUM(CASE WHEN wait_type = N'WRITELOG' THEN wait_time_ms ELSE 0 END),       @wl_n  = SUM(CASE WHEN wait_type = N'WRITELOG' THEN waiting_tasks_count ELSE 0 END)FROM sys.dm_os_wait_stats OPTION (MAXDOP 1);INSERT @r SELECT 3, N'Flush', N'Share of PREEMPTIVE_OS_FLUSHFILEBUFFERS in waits',    CASE WHEN ISNULL(@total, 0) = 0 THEN N'no waits'         ELSE CONVERT(nvarchar(20), CONVERT(decimal(5, 1), 100.0 * @flush / @total)) + N' %' END,    N'often near the top on Linux, not a finding on its own', N'info',    N'Corresponds to the fdatasync call SQL Server makes on Linux (Paul Randal, Bob Dorr). Only an issue together with high WRITELOG times.';INSERT @r SELECT 4, N'Flush', N'WRITELOG, average wait per commit wait',    CASE WHEN ISNULL(@wl_n, 0) = 0 THEN N'none' ELSE CONVERT(nvarchar(20), CONVERT(decimal(9, 2), 1.0 * @wl_ms / @wl_n)) + N' ms' END,    N'a few milliseconds', CASE WHEN @wl_n > 0 AND 1.0 * @wl_ms / @wl_n > 5 THEN N'check' ELSE N'ok' END,    N'High values: slow log storage, log in the container writable layer (overlayfs) or very many small transactions.'; /* 3 · Memory */INSERT @r SELECT 5, N'Memory', N'max server memory vs. visible memory',    CONVERT(nvarchar(20), @maxmem) + N' MB of ' + CONVERT(nvarchar(20), @mem_mb) + N' MB',    N'set, below visible memory, with headroom',    CASE WHEN @maxmem >= 2147483647 OR @maxmem > @mem_mb THEN N'check' ELSE N'ok' END,    N'On Linux, memory.memorylimitmb (or MSSQL_MEMORY_LIMIT_MB) limits the whole process, max server memory only the buffer pool. In a container, leave 10–20 % headroom to the container limit.';INSERT @r SELECT 6, N'Memory', N'Memory target (committed_target)',    CONVERT(nvarchar(20), @target_mb) + N' MB', N'below the container or host limit', N'info',    N'2017 and 2019 do not see the cgroup v2 limit of a container and plan with host memory (PSG lab, part 3).';INSERT @r SELECT 7, N'Memory', N'Memory model', @model, N'CONVENTIONAL is normal on Linux', N'info',    N'There is no documented equivalent of "Lock Pages in Memory" on Linux.';DECLARE @low int, @proc int;SELECT @low = CONVERT(int, system_low_memory_signal_state) FROM sys.dm_os_sys_memory OPTION (MAXDOP 1);SELECT @proc = CONVERT(int, process_physical_memory_low) FROM sys.dm_os_process_memory OPTION (MAXDOP 1);INSERT @r SELECT 8, N'Memory', N'Memory pressure now (system / process)',    CONVERT(nvarchar(5), @low) + N' / ' + CONVERT(nvarchar(5), @proc), N'0 / 0',    CASE WHEN @low = 1 OR @proc = 1 THEN N'check' ELSE N'ok' END,    N'1 means: SQL Server currently reports too little memory. If it stays 1, check limits and memory-hungry queries.'; /* 4 · CPU, schedulers, parallelism */INSERT @r SELECT 9, N'CPU', N'CPUs and visible schedulers',    CONVERT(nvarchar(10), @cpu) + N' CPUs, ' + CONVERT(nvarchar(10), @sched) + N' schedulers'      + CASE WHEN @container <> N'n/a' AND @container <> N'NONE' THEN N' · ' + @container ELSE N'' END,    N'as many schedulers as CPUs are actually available', N'info',    N'SQL Server does not see a CPU quota (docker --cpus). Remedy according to Microsoft: PROCESS AFFINITY and, under cgroup v2, trace flag 8002.';INSERT @r SELECT 10, N'CPU', N'Trace flag 8002 (soft processor affinity)',    CASE WHEN @linux = 0 THEN N'n/a (Windows)' WHEN NOT EXISTS (SELECT 1 FROM @tf) THEN N'not readable'         WHEN EXISTS (SELECT 1 FROM @tf WHERE TraceFlag = 8002 AND Global = 1) THEN N'on' ELSE N'off' END,    N'Microsoft: with PROCESS AFFINITY under cgroup v2; measure first', N'info',    N'Without 8002, PROCESS AFFINITY binds the schedulers firmly to individual CPUs. In the PSG lab (2019–2025, quota of 4 CPUs) 8002 added no measurable gain over affinity alone.';INSERT @r SELECT 11, N'CPU', N'max degree of parallelism', CONVERT(nvarchar(10), @maxdop),    N'at most 8 per NUMA node, not 0 with more than 8 schedulers',    CASE WHEN @maxdop = 0 AND @sched > 8 THEN N'check' ELSE N'ok' END,    N'MAXDOP 0 uses as many threads as schedulers are visible, even if the container gets less CPU.';INSERT @r SELECT 12, N'CPU', N'cost threshold for parallelism', CONVERT(nvarchar(10), @ctp),    N'not the default of 5', CASE WHEN @ctp = 5 THEN N'check' ELSE N'ok' END,    N'The default of 5 lets even very small queries run in parallel.'; /* 5 · tempdb */IF HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW ANY DEFINITION') = 1BEGIN    DECLARE @n int, @equal bit, @percent int;    SELECT @n = COUNT(*), @equal = CASE WHEN MIN(size) = MAX(size) AND MIN(growth) = MAX(growth) THEN 1 ELSE 0 END,           @percent = MAX(CONVERT(int, is_percent_growth))    FROM sys.master_files WHERE database_id = 2 AND type = 0 OPTION (MAXDOP 1);    INSERT @r SELECT 13, N'tempdb', N'Data files',        CONVERT(nvarchar(10), @n) + N' files, ' + CASE WHEN @equal = 1 THEN N'same size' ELSE N'different sizes' END          + CASE WHEN @percent = 1 THEN N', percent growth' ELSE N'' END,        N'as many as schedulers, at most 8, same size, fixed growth',        CASE WHEN @n < CASE WHEN @sched > 8 THEN 8 ELSE @sched END OR @equal = 0 OR @percent = 1 THEN N'check' ELSE N'ok' END,        N'From 2019 a Linux installation creates up to eight files (Microsoft); the container images create one per CPU on first start, up to eight (PSG lab; 2017: one). Not on an in-place upgrade.';ENDELSE    INSERT @r SELECT 13, N'tempdb', N'Data files', N'not checked', N'', N'info', N'VIEW ANY DEFINITION is missing (sys.master_files).'; SELECT nr, area, [check], value, guideline, rating, note FROM @r ORDER BY nr OPTION (MAXDOP 1);

Sample output

From the lab: SQL Server 2025 CU10 in a container with 6 pinned CPUs and a 6 GB limit, as sa, after a short write load (2,000 single inserts).

Sample output
nr|area|check|value|guideline|rating|note
--|----|-----|-----|---------|------|----
1|Flush|Flush mode (trace flag 3979)|forced flush (default, 3979 off)|forced flush, unless the storage provably supports FUA|info|Forced flush is the Linux default, even though DBCC TRACESTATUS does not show 3982 as active (PSG lab). 3979 only with FUA-capable storage and alternatewritethrough = 0.
2|Flush|writethrough / alternatewritethrough|in mssql.conf (SQL Server does not show them)|1 / 1 (default) or 1 / 0 with trace flag 3979|info|Check with cat /var/opt/mssql/mssql.conf, section [control]; if it is missing, the defaults apply. writethrough = 0 can lose data.
3|Flush|Share of PREEMPTIVE_OS_FLUSHFILEBUFFERS in waits|22.5 %|often near the top on Linux, not a finding on its own|info|Corresponds to the fdatasync call SQL Server makes on Linux (Paul Randal, Bob Dorr). Only an issue together with high WRITELOG times.
4|Flush|WRITELOG, average wait per commit wait|0.56 ms|a few milliseconds|ok|High values: slow log storage, log in the container writable layer (overlayfs) or very many small transactions.
5|Memory|max server memory vs. visible memory|2147483647 MB of 4916 MB|set, below visible memory, with headroom|check|On Linux, memory.memorylimitmb (or MSSQL_MEMORY_LIMIT_MB) limits the whole process, max server memory only the buffer pool. In a container, leave 10–20 % headroom to the container limit.
6|Memory|Memory target (committed_target)|4267 MB|below the container or host limit|info|2017 and 2019 do not see the cgroup v2 limit of a container and plan with host memory (PSG lab, part 3).
7|Memory|Memory model|CONVENTIONAL|CONVENTIONAL is normal on Linux|info|There is no documented equivalent of "Lock Pages in Memory" on Linux.
8|Memory|Memory pressure now (system / process)|0 / 0|0 / 0|ok|1 means: SQL Server currently reports too little memory. If it stays 1, check limits and memory-hungry queries.
9|CPU|CPUs and visible schedulers|6 CPUs, 6 schedulers · LINUX CONTAINER|as many schedulers as CPUs are actually available|info|SQL Server does not see a CPU quota (docker --cpus). Remedy according to Microsoft: PROCESS AFFINITY and, under cgroup v2, trace flag 8002.
10|CPU|Trace flag 8002 (soft processor affinity)|off|Microsoft: with PROCESS AFFINITY under cgroup v2; measure first|info|Without 8002, PROCESS AFFINITY binds the schedulers firmly to individual CPUs. In the PSG lab (2019–2025, quota of 4 CPUs) 8002 added no measurable gain over affinity alone.
11|CPU|max degree of parallelism|0|at most 8 per NUMA node, not 0 with more than 8 schedulers|ok|MAXDOP 0 uses as many threads as schedulers are visible, even if the container gets less CPU.
12|CPU|cost threshold for parallelism|5|not the default of 5|check|The default of 5 lets even very small queries run in parallel.
13|tempdb|Data files|6 files, same size|as many as schedulers, at most 8, same size, fixed growth|ok|From 2019 a Linux installation creates up to eight files (Microsoft); the container images create one per CPU on first start, up to eight (PSG lab; 2017: one). Not on an in-place upgrade.

Method and limits

SQL Server 2017 CU31, 2019 CU32, 2022 CU27 and 2025 CU10 (Docker images from Microsoft), main run on Nov 13, 2026; a fresh container for each variant. Measured in Linux containers on an Ubuntu 24.04 VM under Hyper-V (24 vCPUs, 2 NUMA nodes, ext4 on virtual disks of a SATA SSD, disks without FUA, no swap, Docker with overlayfs and cgroup v2). Limits:

  • No change to the host: The effect of THP, swappiness, NUMA balancing, read-ahead, TuneD, noatime and XFS is not measured; Microsoft's recommendations apply for these.
  • Container instead of installation: We did not check the defaults of a package installation (mssql-conf setup).
  • Virtual disks without FUA: The FUA path should only be read as a measured value here.
  • Synthetic loads, a few runs per case; times show orders of magnitude.

Further reading

Running SQL Server on Linux?

Three things first: run the host check and decide deliberately which deviations you keep. Use Query 4 to check memory limits, schedulers and tempdb. And before you turn any knobs on flushing, find out whether your storage supports FUA at all.

More parts of the series