Topic page · SQL Server on Linux and Docker

SQL Server in Docker: What goes wrong in the container

You stop the container with docker stop. Nothing happens for ten seconds, then it is gone, exit code 137. The next start runs a recovery, as if the power had failed. In our lab this happened on every single stop with SQL Server 2022 and 2025.

Updated Nov 12, 2026 · approx. 12 min read

Part 3 · ContainersSQL query 2Lab SQL Server 2017–2025
SQL Server 2022 and 2025 in a containerdocker stopSIGTERM never arrivesSIGKILL10.3 s · Exit 137 · recovery at next startkill -TERM to sqlservr1.1 s · Exit 0 · clean shutdown0 s5 s10 sgrace period

SQL Server runs in a container with the same engine as anywhere else. What changes is everything around it: who receives signals, which CPUs and how much memory the engine believes it sees, who owns the files. Microsoft's quickstarts show how to start the container. This page shows six places where things go wrong afterwards, measured on SQL Server 2017, 2019, 2022 and 2025.

In 60 seconds

  • docker stop does not shut down SQL Server 2022 and 2025 cleanly. The start script in the image does not pass the stop signal on. Docker waits ten seconds and then kills the container. --init makes it worse. SQL Server only ends cleanly if the signal goes directly to the sqlservr process.
  • SQL Server does not see a CPU quota (--cpus). It plans with all CPUs of the host. Only SQL Server 2022 and 2025 respect pinned CPUs (--cpuset-cpus).
  • SQL Server 2017 and 2019 do not see the memory limit of the container (on hosts with cgroup v2). In a 4 GB container they planned with 60 GB. Always limit memory yourself and leave headroom.
  • Volumes: From 2019, SQL Server does not run as root in the container. From 2022, the data directory must belong to the user with ID 10001, otherwise SQL Server does not start.
  • Images: latest moves with every update. For reproducible environments, the CU level belongs in the tag. On 2025, MSSQL_PID=Developer gives you Enterprise Developer Edition.
  • SQL Server is ready when it answers a query, not when the container is running. In the lab that took 7 to 14 seconds.

1 · Shutdown: docker stop is not a shutdown

docker stop sends the signal SIGTERM to the process with number 1 in the container, waits for a grace period (ten seconds by default) and then ends the container with SIGKILL. That only works if PID 1 understands the signal or passes it on.

In the images of SQL Server 2022 and 2025, PID 1 is not SQL Server but a start script (/opt/mssql/bin/launch_sqlservr.sh) that neither handles SIGTERM nor forwards it to sqlservr. Microsoft knows this as a bug (mssql-docker #968, affecting the images since 2022 CU18 and 2025-latest); a fix has been announced for upcoming CUs, without a number. It was not yet included in 2022 CU27 and 2025 CU10 in the lab.

SQL Server 2022 and 2025 · PID 1 is the start scriptdocker stop10.3 s · Exit 137 · recoverydocker stop -t 3030.3 s · Exit 137 · recoverywith --init0.2–0.3 s · Exit 143 · recoverykill -TERM to sqlservr0.7–1.1 s · Exit 0 · cleanSQL Server 2017 and 2019 · PID 1 is sqlservrdocker stop10.3–10.4 s · Exit 137 · grace period too shortdocker stop -t 3010.3–10.4 s · Exit 0 · clean10 s grace period30 s grace period0 s10 s20 s30 saborted, recovery at next startaborted, no recoveryclean shutdown
SQL Server 2022 and 2025 · PID 1 is the startscriptdocker stop10.3 s · Exit 137 · recoverydocker stop -t 3030.3 s · Exit 137 · recoverywith --init0.2–0.3 s · Exit 143 · recoverykill -TERM to sqlservr0.7–1.1 s · Exit 0 · cleanSQL Server 2017 and 2019 · PID 1 is sqlservrdocker stop10.3–10.4 s · Exit 137 · grace period too shortdocker stop -t 3010.3–10.4 s · Exit 0 · clean10 s grace period30 s grace period0 s10 s20 s30 saborted, recovery at next startaborted, no recoveryclean shutdown
How long the container took to end and how it ended, one run per case; small database without an open transaction. The exact values per version are in the table.
VersionPID 1docker stop (grace period 10 s)docker stop -t 30--initSIGTERM directly to sqlservr
2017, 2019sqlservr10.3 s, exit 13710.4 s, exit 010.3 s, exit 13710.3 s, exit 0
2022 CU27start script10.3 s, exit 137, recovery30.3 s, exit 137, recovery0.3 s, exit 143, recovery1.1 s, exit 0, clean
2025 CU9 and CU10start script10.3 s, exit 137, recovery30.3 s, exit 137, recovery0.2–0.3 s, exit 143, recovery0.7 s, exit 0, clean

"Recovery" means: the errorlog of the terminated run lacks SQL Server is terminating in response to a 'stop' request, and at the next start there are lines like … transactions rolled forward in database …. Committed transactions are not lost; SQL Server recovers them from the log. But every stop is an abort, and with large open transactions the next start takes correspondingly longer.

Why --init does not help: With --init, Docker puts a small init process in as PID 1, which forwards signals. We measured: the container is gone after 0.2 to 0.3 seconds, without a stop message, and the next start runs a recovery. Our assumption, not tested: the init process passes the signal to the start script, not to SQL Server; the script ends immediately, and SQL Server ends with it without an orderly shutdown.

What helps (2022 and 2025): Send SIGTERM directly to the SQL Server process and wait until the container ends:

# Show the process IDs of sqlservr in the container (there are two)
docker exec mein-sql sh -c '
  for p in /proc/[0-9]*; do
    [ "$(cat $p/comm 2>/dev/null)" = sqlservr ] && echo ${p#/proc/}
  done'

# One of the two is enough (9 here as an example); in the lab both variants ended cleanly
docker exec mein-sql kill -TERM 9
docker wait mein-sql

In the lab, SQL Server then ended in 0.7 to 1.1 seconds, with a stop message and without a recovery at the next start. The same principle applies to orchestrators: a preStop step that sends the signal to sqlservr replaces the missing forwarding. We did not measure this under Kubernetes.

SQL Server 2017 and 2019 do not have this problem: there, sqlservr itself is PID 1 and shuts down in an orderly way on SIGTERM. But it needs a good ten seconds for that, which is exactly Docker's default grace period. So plain docker stop still ended with exit 137 in the lab. Give it more time: docker stop -t 30, or --stop-timeout 30 at start.

2 · CPU: SQL Server does not see the quota

Docker knows two ways to limit CPUs: a quota (--cpus=2, the container gets CPU time worth two CPUs, spread across all of them) and pinned CPUs (--cpuset-cpus=22-23, the container runs only on these two). We measured what SQL Server sees in each case, on a host with 24 logical CPUs:

Versionno limit--cpus=2--cpuset-cpus2017, 2019sees 24, has 24sees 24, gets 2sees 24, runs on 22022, 2025sees 24, has 24sees 24, gets 2sees 2, has 2One dot per logical CPU of the host (24). Filled: schedulers SQL Server creates. Orange: more than thecontainer gets in CPU.
2017, 20192022, 2025no limitsees 24, has 24sees 24, has 24--cpus=2sees 24, gets 2sees 24, gets 2--cpuset-cpussees 24, runs on 2sees 2, has 2One dot per logical CPU of the host (24).Filled: schedulers SQL Server creates. Orange:more than the container gets in CPU.
What SQL Server sees of the CPUs, measured on a host with 24 logical CPUs, container with two CPUs as a quota (--cpus) or as pinned CPUs (--cpuset-cpus).
Versionno limit--cpus=2--cpuset-cpus (2 CPUs)
2017, 201924 CPUs, 24 schedulers24 CPUs, 24 schedulers24 CPUs, 24 schedulers
2022, 202524 CPUs, 24 schedulers24 CPUs, 24 schedulers2 CPUs, 2 schedulers
  • No version sees the quota. Microsoft describes it this way; confirmed in the lab. SQL Server creates one scheduler for each host CPU and plans parallelism accordingly, although the container only gets CPU time for two CPUs.
  • Only 2022 and 2025 respect pinned CPUs. We found no source for this; 2017 and 2019 see all 24 despite pinning.
  • The consequence is measurable: A table lock with TABLOCKX took 24 lock partitions with 24 visible CPUs and one with two visible CPUs. And with the default max degree of parallelism = 0, a query may use as many threads as SQL Server sees CPUs. We did not measure how much this degrades runtime under a quota.

What helps: On 2022 and 2025, use pinned CPUs instead of a quota if the host allows it. On all versions, set MAXDOP to match the CPUs actually available. For quotas, Microsoft additionally names ALTER SERVER CONFIGURATION SET PROCESS AFFINITY together with trace flag 8002; we did not test this. CPU limits under Kubernetes are usually quotas in this sense; we did not measure under Kubernetes.

3 · Memory: Three limits you have to tell apart

  • --memory limits the whole container. If it is exceeded, the kernel kills the process (OOM kill).
  • MSSQL_MEMORY_LIMIT_MB tells SQL Server how much memory it should regard as available in total.
  • max server memory limits the memory of the engine within that.

Without a setting, SQL Server takes 80 percent of the smaller of host memory and container limit, according to Microsoft. What we measured on a host with cgroup v2 (the default of current Linux distributions):

Only --memory=4g2017target 60,480 MB · sees 64,417 MB2019target 61,037 MB · sees 64,417 MB2022target 2,617 MB · sees 3,277 MB2025target 2,721 MB · sees 3,277 MBcontainer limit 4 GB0 GB16 GB32 GB48 GB64 GB--memory=4g and MSSQL_MEMORY_LIMIT_MB=60002017target 4,935 MB · sees 6,000 MB2019target 5,112 MB · sees 6,000 MB2022target 3,433 MB · sees 4,096 MB2025target 3,618 MB · sees 4,096 MBcontainer limit 4 GB0 GB2 GB4 GB6 GB8 GBBar: SQL Server memory target. Line: memory SQL Server reports as available. Orange: target above thecontainer limit.
Only --memory=4g2017 · target 60,480 MB · sees 64,417 MB2019 · target 61,037 MB · sees 64,417 MB2022 · target 2,617 MB · sees 3,277 MB2025 · target 2,721 MB · sees 3,277 MBcontainer limit 4 GB0 GB16 GB32 GB48 GB64 GB--memory=4g and MSSQL_MEMORY_LIMIT_MB=60002017 · target 4,935 MB · sees 6,000 MB2019 · target 5,112 MB · sees 6,000 MB2022 · target 3,433 MB · sees 4,096 MB2025 · target 3,618 MB · sees 4,096 MBcontainer limit 4 GB0 GB2 GB4 GB6 GB8 GBBar: SQL Server memory target. Line: memory SQLServer reports as available. Orange: targetabove the container limit.
Memory target (committed_target_kb) and reported memory (physical_memory_kb) in a container with 4 GB, measured on SQL Server 2017 to 2025. 64,417 MB is 80 percent of the host memory.
Version--memory=4g4g and MSSQL_MEMORY_LIMIT_MB=32004g and MSSQL_MEMORY_LIMIT_MB=6000
2017sees 64,417 MB, target 60,480 MB3,200 MB, target 2,297 MB6,000 MB, target 4,935 MB
2019sees 64,417 MB, target 61,037 MB3,200 MB, target 2,506 MB6,000 MB, target 5,112 MB
20223,277 MB, target 2,617 MB3,200 MB, target 2,573 MB4,096 MB, target 3,433 MB
20253,277 MB, target 2,721 MB3,200 MB, target 2,641 MB4,096 MB, target 3,618 MB

"Sees" is the memory SQL Server reports as available (physical_memory_kb), "target" is the memory target (committed_target_kb). 64,417 MB is 80 percent of the host memory.

  • 2017 and 2019 do not detect the container limit under cgroup v2. In a container with 4 GB, they plan with 60 GB. As soon as the buffer pool grows, this ends in an OOM kill. Microsoft names support for cgroup v2 from SQL Server 2022 CU20.
  • A MSSQL_MEMORY_LIMIT_MB that is too high is dangerous. 2017 and 2019 accept it unchecked. 2022 and 2025 cap it at the container limit, but then leave no headroom for everything outside the engine that needs memory.

What helps: Always set both, container limit and MSSQL_MEMORY_LIMIT_MB, and leave 10 to 20 percent headroom between them, as Microsoft recommends. On 2017 and 2019 this is mandatory. We did not provoke the OOM kill itself.

4 · Volumes and permissions: Who owns the data directory?

From SQL Server 2019, SQL Server runs in the container as user mssql with ID 10001, not as root. If you mount a host directory as the data directory, you have to make it accessible to this user. We tried four variants:

Owner of the directory on the host2017201920222025
root, mode 755startsfailsfailsfails
ID 10001, group 0, mode 770startsstartsstartsstarts
other user (4000), mode 700startsfailsfailsfails
root, group 0 writable (770)startsstartsfailsfails

SQL Server 2017 runs as root and starts with any directory. The "group 0 writable" variant, which Microsoft names for platforms with random user IDs, only worked on 2019; there, mssql runs in group 0, while on 2022 and 2025 it runs in its own group 10001. When it fails, the container ends immediately, and docker logs shows:

ERROR: Setup FAILED copying system data file 'C:\templatedata\master.mdf' to '/var/opt/mssql/data/master.mdf':  5(Access is denied.)
ERROR: BootstrapSystemDataDirectories() failure (HRESULT 0x80070005)

The Windows path and the Windows error code 5 are not a typo: on Linux, SQL Server runs on an abstraction layer that shows it Windows paths.

What helps:

sudo install -d -o 10001 -g 0 -m 770 /srv/sql/data/mein-sql
docker run … -v /srv/sql/data/mein-sql:/var/opt/mssql/data …

5 · Images, tags and editions

  • latest moves. For us, 2025-latest still pointed to CU9 when CU10 was already available as its own tag; after the next docker pull it points somewhere else. For tests that should give the same result tomorrow, the CU level belongs in the tag (for example 2025-CU10-ubuntu-24.04), or the image is pinned by its digest.
  • An update is a new image. The databases live on the volume; the container is recreated with the new tag.
  • Editions on 2025: MSSQL_PID=Developer gave us Enterprise Developer Edition in the lab, although the table in the documentation no longer names this value. If you want to test realistically against Standard, set MSSQL_PID=StandardDeveloper (reports Standard Developer Edition).
  • x86-64 only: According to Microsoft, the images run only on x86-64 hosts; emulation through Rosetta or QEMU, for example on Apple silicon, is not supported.

6 · Development and CI

  • Start time: Until SQL Server answered a query, 7 to 8 seconds passed in the lab (2017, 2019) and 9 to 14 seconds (2022, 2025). A running container does not mean that SQL Server is ready; tests should wait for a successful SELECT 1.
  • The command-line tool is in a different place per version: in the 2022 and 2025 images under /opt/mssql-tools18/bin/sqlcmd (with -C, because the certificate is self-signed), in 2017 and 2019 under /opt/mssql-tools/bin/sqlcmd.
  • Sporadic start crashes: Microsoft has confirmed a rare crash shortly after start, which was noticed mainly in CI environments with many simultaneous starts (mssql-docker #974); fixed according to Microsoft in 2022 CU29 and 2025 CU12. We did not observe it.
  • The errorlog is at /var/opt/mssql/log/errorlog and was plain text in all images we checked (the documentation says UTF-16).

Overview: What does my instance see?

The following query shows what SQL Server makes of its platform: operating system, edition, CPUs and schedulers, memory, important settings. Compare the result with the limits you gave the container.

Query: Container overview

Query 2: Container overview · read-only · tested in the PSG lab, versions in the header
/* PSG Stack · SQL Server on Linux and Docker · Query 2   What does this instance see of its platform? Operating system, edition, CPUs and schedulers,   memory target, key settings, paths and whether SQL Server Agent is running.    !!! WARNING — please read before running !!!   - Read-only. The query changes no data and no setting.   - Permissions: VIEW SERVER STATE (on SQL Server 2022 and later, VIEW SERVER PERFORMANCE STATE is enough).   - MAXDOP 1: every part of the query runs serially and uses no additional cores.   - SQL Server 2017 and later. Columns a version does not know show as n/a; the query checks     whether the column exists. Tested in the PSG lab on 2017, 2019, 2022 and 2025 in Linux     containers, as sa and without sysadmin (Nov 11, 2026); not tested on Windows.   - container_type_desc reports a Linux container if the version knows the column.     Compare cpu_count and physical_memory_mb with the limits you gave the container: no     version sees a CPU quota (--cpus), only SQL Server 2022 and 2025 see CPU pinning     (--cpuset-cpus), and 2017 and 2019 do not see the memory limit under cgroup v2.*/SET NOCOUNT ON; DECLARE @sql nvarchar(max), @columns nvarchar(max) = N''; /* 1 · Platform */SET @sql = N'SELECT ' +  CASE WHEN OBJECT_ID(N'sys.dm_os_host_info') IS NULL THEN N'N''n/a'' AS host_platform, N''n/a'' AS host_distribution, N''n/a'' AS host_release'       ELSE N'h.host_platform, h.host_distribution, h.host_release' END +  N', CONVERT(nvarchar(20), SERVERPROPERTY(''ProductVersion'')) AS version,     CONVERT(nvarchar(30), SERVERPROPERTY(''ProductUpdateLevel'')) AS update_level,     CONVERT(nvarchar(60), SERVERPROPERTY(''Edition'')) AS edition   FROM ' + CASE WHEN OBJECT_ID(N'sys.dm_os_host_info') IS NULL THEN N'(SELECT 1 AS x) AS h' ELSE N'sys.dm_os_host_info AS h' END +  N' OPTION (MAXDOP 1);';EXEC sys.sp_executesql @sql; /* 2 · CPU and memory as SQL Server sees them */SELECT @columns = @columns + N', ' + CASE WHEN EXISTS (SELECT 1 FROM sys.all_columns                                                        WHERE object_id = OBJECT_ID(N'sys.dm_os_sys_info') AND name = v.n)                                         THEN N'CONVERT(nvarchar(60), i.' + v.n + N')' ELSE N'N''n/a''' END + N' AS ' + v.nFROM (VALUES (1, N'socket_count'), (2, N'cores_per_socket'), (3, N'numa_node_count'),             (4, N'sql_memory_model_desc'), (5, N'container_type_desc')) AS v(k, n)ORDER BY v.k;SET @sql = N'SELECT i.cpu_count, i.scheduler_count, i.hyperthread_ratio, i.affinity_type_desc' + @columns + N',       i.physical_memory_kb / 1024 AS physical_memory_mb,       i.committed_target_kb / 1024 AS committed_target_mbFROM sys.dm_os_sys_info AS i OPTION (MAXDOP 1);';EXEC sys.sp_executesql @sql; SELECT COUNT(*) AS schedulers_visible_onlineFROM sys.dm_os_schedulersWHERE status = N'VISIBLE ONLINE'OPTION (MAXDOP 1); /* 3 · Settings, tempdb, Agent, paths */SELECT    (SELECT CONVERT(int, value_in_use) FROM sys.configurations WHERE name = N'max server memory (MB)') AS max_server_memory_mb,    (SELECT CONVERT(int, value_in_use) FROM sys.configurations WHERE name = N'max degree of parallelism') AS maxdop,    (SELECT CONVERT(int, value_in_use) FROM sys.configurations WHERE name = N'cost threshold for parallelism') AS cost_threshold,    (SELECT COUNT(*) FROM tempdb.sys.database_files WHERE type = 0) AS tempdb_data_files,    CASE WHEN EXISTS (SELECT 1 FROM sys.dm_exec_sessions WHERE program_name LIKE N'SQLAgent%') THEN N'running' ELSE N'not running or not visible' END AS sql_server_agent,    CONVERT(nvarchar(260), SERVERPROPERTY('InstanceDefaultDataPath')) AS default_data_path,    CONVERT(nvarchar(260), SERVERPROPERTY('InstanceDefaultLogPath')) AS default_log_path,    CONVERT(nvarchar(260), SERVERPROPERTY('ErrorLogFileName')) AS errorlogOPTION (MAXDOP 1);

Sample output

From the lab: SQL Server 2022 CU27 in a container with two pinned CPUs (--cpuset-cpus) and a 4 GB memory limit, Agent enabled:

Sample output
host_platform  host_distribution  host_release  version     update_level  edition
-------------  -----------------  ------------  ----------  ------------  --------------------------
Linux          Ubuntu             22.04         16.0.4295.3 CU27          Developer Edition (64-bit)

cpu_count  scheduler_count  affinity_type_desc  container_type_desc  physical_memory_mb  committed_target_mb
---------  ---------------  ------------------  -------------------  ------------------  -------------------
2          2                AUTO                LINUX CONTAINER      3277                2570

max_server_memory_mb  maxdop  tempdb_data_files  sql_server_agent  default_data_path     errorlog
--------------------  ------  -----------------  ----------------  --------------------  ---------------------------
2147483647            0       2                  running           /var/opt/mssql/data/  /var/opt/mssql/log/errorlog

Columns shortened. Here is how to read it: Two CPUs and 3,277 MB match the container limits (80 percent of 4 GB); max server memory and MAXDOP are still at their defaults. If cpu_count shows the CPU count of the host even though the container is limited, or physical_memory_mb shows more than the container limit, then section 2 or 3 applies to you.

Method and limits

Measured on Nov 11, 2026 on a Linux VM (Ubuntu, cgroup v2, 24 logical CPUs, 78 GB memory, Docker 29.8) with the Microsoft images SQL Server 2017 CU31, 2019 CU32, 2022 CU27, 2025 CU9 and CU10 (Ubuntu variants), Developer Edition; a fresh container without network for each case, one run per case, small databases. We had already measured shutdown on 2022 and 2025 once on Oct 21, 2026; the results agree. What we did not measure ourselves comes from Microsoft Learn and the GitHub repository of the images. Limits:

  • We did not check CU images newer than 2022 CU27 and 2025 CU10; whether the announced fix of the start script is included there is open.
  • Recovery duration after a hard stop, the runtime effect of too many schedulers under a quota, and the OOM kill are not measured.
  • Ubuntu images only, Docker only, on a host with cgroup v2. We did not check RHEL images, Podman, Kubernetes or hosts with cgroup v1; under cgroup v1, the memory detection of 2017 and 2019 may turn out differently.
  • Times are single measurements and show orders of magnitude.

Further reading

  • Microsoft Learn: quickstart "Run SQL Server Linux container images with Docker"; "Configure SQL Server container images on Docker"; "Performance best practices and configuration guidelines for SQL Server on Linux".
  • GitHub microsoft/mssql-docker: issues #968 (start script does not pass on SIGTERM), #974 (crash shortly after start).
  • Docker documentation: docker stop and docker run --init.
  • In this series: Overview and Part 1: Linux, containers or Windows? with the check query "What is missing on Linux?".
  • At PSG: A restart only helps briefly (what a restart really resets in SQL Server), Understanding SQL Server locking.

Running SQL Server in containers?

First check three things: whether your containers end with exit code 0 or 137, whether cpu_count and physical_memory_mb from Query 2 match the limits you set, and who owns the data directory. If any of these is off, take a second look at the container definition before the next update or the next stop.

More parts of the series