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.
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 stopdoes 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.--initmakes it worse. SQL Server only ends cleanly if the signal goes directly to thesqlservrprocess.- 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:
latestmoves with every update. For reproducible environments, the CU level belongs in the tag. On 2025,MSSQL_PID=Developergives 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.
| Version | PID 1 | docker stop (grace period 10 s) | docker stop -t 30 | --init | SIGTERM directly to sqlservr |
|---|---|---|---|---|---|
| 2017, 2019 | sqlservr | 10.3 s, exit 137 | 10.4 s, exit 0 | 10.3 s, exit 137 | 10.3 s, exit 0 |
| 2022 CU27 | start script | 10.3 s, exit 137, recovery | 30.3 s, exit 137, recovery | 0.3 s, exit 143, recovery | 1.1 s, exit 0, clean |
| 2025 CU9 and CU10 | start script | 10.3 s, exit 137, recovery | 30.3 s, exit 137, recovery | 0.2–0.3 s, exit 143, recovery | 0.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-sqlIn 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:
--cpus) or as pinned CPUs (--cpuset-cpus).| Version | no limit | --cpus=2 | --cpuset-cpus (2 CPUs) |
|---|---|---|---|
| 2017, 2019 | 24 CPUs, 24 schedulers | 24 CPUs, 24 schedulers | 24 CPUs, 24 schedulers |
| 2022, 2025 | 24 CPUs, 24 schedulers | 24 CPUs, 24 schedulers | 2 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
TABLOCKXtook 24 lock partitions with 24 visible CPUs and one with two visible CPUs. And with the defaultmax 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
--memorylimits the whole container. If it is exceeded, the kernel kills the process (OOM kill).MSSQL_MEMORY_LIMIT_MBtells SQL Server how much memory it should regard as available in total.max server memorylimits 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):
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=4g | 4g and MSSQL_MEMORY_LIMIT_MB=3200 | 4g and MSSQL_MEMORY_LIMIT_MB=6000 |
|---|---|---|---|
| 2017 | sees 64,417 MB, target 60,480 MB | 3,200 MB, target 2,297 MB | 6,000 MB, target 4,935 MB |
| 2019 | sees 64,417 MB, target 61,037 MB | 3,200 MB, target 2,506 MB | 6,000 MB, target 5,112 MB |
| 2022 | 3,277 MB, target 2,617 MB | 3,200 MB, target 2,573 MB | 4,096 MB, target 3,433 MB |
| 2025 | 3,277 MB, target 2,721 MB | 3,200 MB, target 2,641 MB | 4,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_MBthat 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 host | 2017 | 2019 | 2022 | 2025 |
|---|---|---|---|---|
| root, mode 755 | starts | fails | fails | fails |
| ID 10001, group 0, mode 770 | starts | starts | starts | starts |
| other user (4000), mode 700 | starts | fails | fails | fails |
| root, group 0 writable (770) | starts | starts | fails | fails |
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
latestmoves. For us,2025-lateststill pointed to CU9 when CU10 was already available as its own tag; after the nextdocker pullit points somewhere else. For tests that should give the same result tomorrow, the CU level belongs in the tag (for example2025-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=Developergave 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, setMSSQL_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/errorlogand 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
/* 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:
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/errorlogColumns 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 stopanddocker 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.