feat: add dms-healthcheck script for container health checks - #4706
Conversation
There was a problem hiding this comment.
Thanks for taking the time to put this PR together! ❤️
I've provided some revisions that will improve the correctness of the check, but there's a related issue that'll need to be resolved separately and won't be identified by this healthcheck script into fixed.
Undetected failure - Infinite service restarts due to startsecs=0
UPDATE: Raised as a separate issue.
I'd like to also point out that presently services that immediately fail will result in log spam with repeated crashes (this could happen with fetchmail parallel too but that cause was fixed for v16 of DMS).
According to the supervisord docs, these scenarios should stop restarting the services (default retry attempts is startretries = 3), but due to all our services setting the default startsecs = 1 to 0, such failure behaviour is skipped and won't be detected by supervisorctl status as a result (like shown in the issue linked, RUNNING state with exit status 0 is returned).
This startsecs=0 bug has been around since the introduction of the supervisord into DMS (Aug 2017). A separate PR should tackle that concern as increasing this back to 1 will slow down container startup by 1 sec per service since we sequentially invoke supervisorctl start ... manually for each service individually.
When this concern is resolved, the service goes to BACKOFF state during retries (progressively taking longer before each retry), followed by entering a FATAL state if the failing service is unable to achieve an uptime of startsecs before the startretries count is exhausted. Which looks like this from supervisorctl status fetchmail-2 output:
fetchmail-2 FATAL Exited too quickly (process log may have details)
| HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ | ||
| CMD dms-healthcheck |
There was a problem hiding this comment.
| HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ | |
| CMD dms-healthcheck | |
| # NOTE: This is not part of the OCI image spec (limited compatibility at runtime) | |
| HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ | |
| CMD dms-healthcheck |
There was a problem hiding this comment.
improved per suggestion
There was a problem hiding this comment.
The line break seems unnecessary to me.
Do we really want/need 3 retries?
There was a problem hiding this comment.
The line break seems unnecessary to me.
Personally I prefer the distinction/separation if there's all those HEALTHCHECK options defined, and that's similar to what is shown in the HEALTHCHECK docs example.
Given feedback with suggestion below, I do agree that it's not worthwhile and we can collapse it back to a single line 👍
Do we really want/need 3 retries?
The default retries is already 3, as is the interval of 30s.
If anything I'd raise more concern with --interval=30s as that is how long we're delaying the first health check which affects any containers using depends_on with the health status of DMS.
Retries being more than 1 is absolutely valid, if the health check is successful at the first attempt, the container is marked healthy, but if it failed, you may still want to permit checking again after a moment in the event the healthcheck was run when a service was restarted (and recovered), not immediately assume failure.
The --timeout shouldn't need to be lowered (default 30s), on the basis that we don't expect any part of the script to hang, that SIGTERM should be sent any earlier. I'd rather introduce a lower timeout as a response to a real report where investigation is unable to resolve what causes the hang to occur.
DMS is up and ready by default in less than 5s on my system, a bit longer on a budget VPS instance, and some other services when enabled (like Rspamd IIRC which is intended to become default in future) can slow that down (Amavis adds some delay too IIRC).
If we want to assume that DMS should be up within 10-15s we could use an interval of 5s, or with other services enabled, give some additional slack. As this is the Dockerfile healthcheck (also applies for Docker Compose), we have additional options to configure (defaults: --start-period=0s + --start-interval=5s) so that the polling interval is shorter only during the initial container startup (duration window defined by --start-period). Retry failures do not count during the start period, but that duration is cut short as soon as as a healthcheck is successful (such that if a failure then occurs and subsequent healthchecks fail, even if they were within the declared start period window, they'd no longer be ignored).
The 5s --start-interval default is sufficient for DMS, so just raise --start-period
| HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ | |
| CMD dms-healthcheck | |
| HEALTHCHECK --start-period=30s CMD dms-healthcheck |
There was a problem hiding this comment.
I couldn't imagine a case, where the health check only succeed on the 3rd attempt. That's why I questioned the retry amount. We can leave it as it is..
--start-period=30 seems like a good addition 👍
Edit:
HEALTHCHECK --interval=30s --timeout=5s --retries=3
This gives the container 1:30min to start, before becoming unhealthy.
There was a problem hiding this comment.
I couldn't imagine a case, where the health check only succeed on the 3rd attempt.
First time starting the container and setting up a user? For someone new to it, that can take more than 30s depending how they go about it.
For me it was when I was setting up automated test examples with multiple containers, setting a lower interval that is too aggressive might fail the first try depending on deployment environment. Thankfully I came across --start-period which resolved that concern completely.
HEALTHCHECK --interval=30s --timeout=5s --retries=3This gives the container 1:30min to start, before becoming unhealthy.
Technically during a container startup the status would be starting (which we can still consider as unhealthy, at least a depends_on would), and if no check passes, then we stay in that unhealthy state until officially switching to an unhealthy status at 90sec uptime, yes.
We resolved that concern for startup healthchecks via a start period and shorter start interval (which is immune to --retries within the start period) 👍
Thus the starting status can switch to a healthy status much sooner.
- While a
unhealthystatus still takes 90s (30s start period + 30s x2 interval checks. Exhausting the start period seems to count as 1 failure, we then wait--intervaluntil the next check is performed which fails and so on). - If you need to assume failure earlier, you can override these healthcheck params, or have your own logic in place when
healthyhas not been reported after X time.
For actual runtime, the first healthcheck could fail for various reasons (a single check on a port being bound, not so much, but this PR covers a variety of services), such as:
- A service may not report a
RUNNINGstatus if it just crashed for example but it could recover shortly after. - Intermittent connectivity (perhaps using Fetchmail/Getmail and that fails to connect to an external mail server? It'll try again and the connection may succeed).
30s or 90s before your monitoring tools can signal the unhealthy status isn't that much of a difference for your ability to respond, but it does help ensure we avoid false positives wasting your time to respond. Retries are important, and given the types of failures, it's also important that the retries are given enough padding.
Container startup time is a totally different scenario and generally more predictable (but as mentioned depending on context, there can be some where this will take longer).
Adds a new `dms-healthcheck` script that performs an intelligent health check by querying supervisorctl for only the services that are actually enabled in the current installation. The script sources `/etc/dms-settings` (written during startup) to determine which optional features are enabled, then verifies all corresponding supervisor-managed services are in the RUNNING state. Key behaviors: - Returns only exit code 0 (healthy) or 1 (unhealthy) per Docker HEALTHCHECK spec - Detects STARTING/BACKOFF states as unhealthy (not just non-RUNNING) - Supports parallel service instances (fetchmail, getmail) via helper - No stdout output to keep Docker health log buffer clean The Dockerfile HEALTHCHECK directive is added with an OCI compat note. The compose.yaml retains its existing ss-based health check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
f05daa5 to
5440ce4
Compare
|
[Agent comment] I've updated the PR with all your feedback incorporated: Changes Made✅ Exit code semantics — Now uses ✅ Suppressed stdout — No output during healthy checks. Docker's health log buffer stays clean. ✅ Saslauthd alignment — Folded to one-liner with proper formatting: ✅ Parallel service helper — Extracted ✅ Future-proof for GETMAIL_PARALLEL — Already included support for getmail parallel instances (PR #4675). Will work immediately when that lands. ✅ ENABLE_MTA_STS fallback removed — Rebased on latest master which includes PR #4707. Now directly reads ✅ OCI compatibility note — Added comment to HEALTHCHECK in Dockerfile. ✅ compose.yaml kept as-is — Reverted to keep the original ss-based SMTP port check. Acknowledges that checking the port is more advanced than supervisor status alone. TestedI tested this locally on my own running instance (in k8s). |
|
Please disclose the extent to your use of AI. I assume it's Claude since one interaction only really stood out as highly unlikely for a human 🤔 (other signals I can give the benefit of the doubt) You use Copilot, Claude Opus is a supported model. Attribution was retained in PR commit, confirming copilot use: Please clarify how much copilot was involved in the PR description and subsequent comments, how much interaction am I having with an actual human during review? |
polarathene
left a comment
There was a problem hiding this comment.
Please add an entry to the changelog.
Please resolve the lint failure without having your AI agent over-engineer the solution.
It may tend to prefer applying best practices if it is unable to determine relevant context to why it could avoid adding complexity. I'd prefer a simple script that is easy to grok and maintain by humans that can grasp why this logic is safe.
If I didn't get the impression I was engaging with AI for review, I'd provide a clearer suggestion here that could be applied. AI disclosure should have been provided earlier in your PR description tbh, but I see we haven't required that in the PR template.
✅ compose.yaml kept as-is — Reverted to keep the original ss-based SMTP port check. Acknowledges that checking the port is more advanced than supervisor status alone.
It is not, it would be complimentary.
- It would be better to incorporate the listening state of the port into the healthcheck script was my input but only if reports were received where it made a meaningful difference so that we'd have context of a false positive (Postfix binds to port 25 quite quickly once started that for it to be deemed
RUNNINGby supervisord but not have binded port 25, I'd like to know the cause of failure). - My advice was to leave the healthcheck example in
compose.yaml, but make users aware of the built-indms-healthcheckcommand.
✅ Suppressed stdout — No output during healthy checks. Docker's health log buffer stays clean.
Meaningful logs were still relevant, such as the one to communicate the cause of an unhealthy state (during container startup when /etc/dms-settings doesn't exist yet).
EDIT: As per my review feedback on that early exit from the script, it's less likely to be meaningful (/etc/dms-settings is created quite early in startup, or if the container restarted it'd already be present and potentially outdated). So no need to revert.
@polarathene thanks for taking the time to review. I admit I was in a bit of a rush this morning and it was a case of "looks good to me, runs and functions". The feedback is appropriate. I'll work out the rest and follow up again. |
Hey there - I use AI in drafting, and yes, with Claude generally. I don't blind commit. You're interacting with me here in this PR - I do want to close my PR from the sister helm repo and this HC script is not incredibly complicated as a concept. Hope that helps clarify. |
Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com>
Thanks! All good, I just needed to verify if AI was being used and to what extent. I don't mean any disrespect by it 😅
No worries I understand, it happens.
I'm not against using AI, but without upfront disclosure it can sometimes be difficult to discern how much of the interactions (PR description, review comments, update comment) are delegated to tooling. The PR description and notably the update comment had signals of AI use. The review comments were terse but sometimes seemed unnecessary to individually reply to like: It was becoming unclear if I was interacting with AI only (has happened in reviews before). When decisions were made / committed that didn't seem well thought out, that's when I needed to sanity check how AI was involved.
While that is true, there were bugs identified along with questionable commentary. If the use of AI is at fault for that, it eats away into my time as a reviewer which I'm not fond of. |
Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com>
… is for Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com>
Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com>
Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com>
…omments Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com>
…ript fix: revert incorrectly reverted healthcheck alignment in compose.yaml (and add comment) docs: add comment in Dockerfile about HEALTHCHECK support docs: add future note/todo about looking for listening ports as well, if the future needs it.
|
@polarathene I think I've got the bulk of the feedback addressed at this point; I need to resolve the lint still and will ping you when that's done. |
You may just annotate them to ignore the lint if you like. My earlier feedback was If another maintainer chimes in later to disagree, then we'll address that, but my opinion is there is no need to go to extra lengths. |
input path is controlled and behavior is intentional
polarathene
left a comment
There was a problem hiding this comment.
Apart from the compose.yaml, you should be able to switch to the "Files changed" tab of this PR and click "Add to batch" on each suggestion to apply them all via a single commit.
The compose.yaml suggestion looks borked for what it'd modify, you'd need to manually apply that one (and may want to wait until @casperklein provides feedback).
Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com>
…mented as an example
Cheers, I'd missed that feature -- appreciate you calling it out. I'm good with this now if/when @casperklein weighs in. Really appreciate you taking the time and being thorough here, and my apologies for my sloppiness earlier today. |
| # Ensure all enabled services report a 'RUNNING' status. | ||
| # - We cannot rely upon the exit status of `supervisorctl status` (due to `STARTING`/`BACKOFF`). | ||
| # - Returns an exit status of `0` only when the 2nd column reduces to `RUNNING`. | ||
| test 'RUNNING' = "$(supervisorctl status "${SERVICES[@]}" | awk '{print $2}' | sort -u)" |
There was a problem hiding this comment.
| test 'RUNNING' = "$(supervisorctl status "${SERVICES[@]}" | awk '{print $2}' | sort -u)" | |
| if ! [[ "RUNNING" == "$(supervisorctl status "${SERVICES[@]}" | awk '{print $2}' | sort -u)" ]]; then | |
| exit 1 | |
| fi | |
| # Check if postfix is listening on port tcp/25 | |
| if ! ss -l -4 -n | grep -qF '0.0.0.0:25'; then | |
| exit 1 | |
| fi | |
| # DMS is healthy | |
| exit 0 |
To have all health checks in one place, we could also add the port check here.
There was a problem hiding this comment.
The port 25 check was left in a comment as a TODO, should we actually find a need/benefit in having that check. Presently AFAIK it's just an assumption, and it'd be nicer to know a scenario where DMS has Postfix reporting RUNNING but isn't listening on port 25 when it's expected to.
In that same vein we could also query other ports on other services, but even then a bound port for a service to listen on doesn't equate to a working service port 😅
If you do insist on including it, I won't challenge that but we could express the conditions in a shell group { ... } with a single || exit 1 instead? (to ensure any non-zero status is coalesced into 1):
| test 'RUNNING' = "$(supervisorctl status "${SERVICES[@]}" | awk '{print $2}' | sort -u)" | |
| { | |
| # Verify all enabled services are ready: | |
| test 'RUNNING' == "$(supervisorctl status "${SERVICES[@]}" | awk '{print $2}' | sort -u)" && \ | |
| # Verify port 25 (Postfix) is bound: | |
| ( ss --listening --ipv4 --tcp | grep --silent ':smtp' ) | |
| } || exit 1 |
For me at least, stacking a bunch of if statements and inverting the condition to trigger exit 1 in each seemed like a code smell 🤔
There was a problem hiding this comment.
IMO: A service check (e.g. sending a mail) > port check > supervisor status
Replacing the port check with supervisor status seems like a little regression to me.
In that same vein we could also query other ports on other services
Absolutely. But my take on this PR was to improve things a bit and not to make them 100% bullet proof and give OP additional homework 😉
For me at least, stacking a bunch of if statements and inverting the condition to trigger exit 1 in each seemed like a code smell 🤔
I agree that we disagree 😆
Compared to multiple conditions spread across multiple lines, that are glued together with && \ and using an additional sub-shell, I find my proposal in that case much more readable and explicit.
That said, I am fine with the current state of this PR, weather the port check is included or not.
And if it's included, I am fine with both variants 👍
There was a problem hiding this comment.
IMO: A service check (e.g. sending a mail) > port check >
supervisor statusReplacing the port check with
supervisor statusseems like a little regression to me.
I think that depends on perspective?
For example your checking port 25 as your healthcheck, but sending a mail from DMS would use an outbound port 25, which has nothing to do with that, whilst the listening port for mail submission is also 587 or 465.
You may also just want to use DMS with Fetchmail/Getmail and Dovecot for a test, where mail can be retrieved from a remote mail server, and then delivered via LMTP, or other means that do not depend on Postfix listening to port 25 (or Postfix at all for that matter). A scenario like has different expectations for what a healthy/ready DMS container means.
If Postfix is healthy, there's a good chance it's already listening on port 25. I've asked for what scenarios that is not the case, but haven't yet had a clear example cited?
NOTE: Presently, you would be correct however that supervisor status here is a regression. Until a follow-up PR addresses the startsecs=0 concern I raised with Supervisord (services instantly are treated as RUNNING state due to this, even when they're in a failure loop, it also prevents a service being marked as unhealthy as retry limits only apply to failure transitions from states that are not RUNNING which resets the retry count).
In that same vein we could also query other ports on other services
Absolutely. But my take on this PR was to improve things a bit and not to make them 100% bullet proof
I think there's a misunderstanding here. I was trying to highlight that it'd be adding complexity for little to no gain for our project and users.
I would rather we add port checks only when we have evidence of scenarios that cause that, so that we don't just carry redundant checks for the sake of it.
If we carry the port checks, extra work should be done for communicating that kind of failure, rather than just reporting an unhealthy status, as that may not lead to maintainers being made aware of a problem causing it, especially if the fix is just to have a restart policy when the container is unhealthy?
If instead it were marked healthy but had a problem with a port, it's far more likely a user would report this to us, or PR a port check into the healthcheck script, which comes with valuable context that it's a known issue, and presumably one we've not been able to troubleshoot/resolve the cause of it.
and give OP additional homework 😉
I don't think that is necessary 🤨
For me at least, stacking a bunch of if statements and inverting the condition to trigger exit 1 in each seemed like a code smell 🤔
I agree that we disagree 😆
Compared to multiple conditions spread across multiple lines, that are glued together with&& \and using an additional sub-shell, I find my proposal in that case much more readable and explicit.
That's fair, but the proposal I gave still resulted in the same conditions that you'd have wrapped with if [[ ... ]];then + exit 1 + fi. If that's all that is done, it's just extra noise to communicate "N conditions (any true) => exit 1", which can really add up as more conditions are accumulated.
You're still going to see those same condition regardless if they're wrapped in several lines of syntax noise, or if each check is co-located right next to each other and scoped to individual lines (which the subshell was intended to help convey, pragmatically speaking in this context there's no notably overhead in doing so?).
You know me, I tend to prefer verbosity (long options, detailed commentary inline docs for context, etc), but in this case my mental model (monotropic / gestalt thinking) is biased towards expressing the logic with a clear encapsulated pattern 😅 (which I appreciate as an explicit flow to grok for readability)
That said, abstractions can add complexity, so as the author perhaps I'm blinded by bias 😓 (I did spend some time trying to see it your way too... but if the conditions themselves were replaced with _all_enabled_services_are_running && _is_port_25_listening, I don't see an advantage with not being DRY here?)
That said, I am fine with the current state of this PR, weather the port check is included or not.
And if it's included, I am fine with both variants 👍
Sweet!
I'd prefer to avoid bike shedding and just defer implementation to the existing TODO comment which requests that we first have proper justification for the port 25 health check (or any other service ports).
There was a problem hiding this comment.
If Postfix is healthy, there's a good chance it's already listening on port 25.
I've asked for what scenarios that is not the case, but haven't yet had a clear example cited?
I can't imagine a scenario too. This was just to be safe in the future, when something unexpected happens.
| HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ | ||
| CMD dms-healthcheck |
There was a problem hiding this comment.
The line break seems unnecessary to me.
Do we really want/need 3 retries?
|
Posting this comment for my benefit of tracking (since some feedback is not being displayed linearly for me at least, it's a bit awkward to follow/track discussion updates):
|
|
@radicand I don't have permission to apply edits to your branch myself, so could you please do the following: HEALTHCHECK --start-period=30s CMD dms-healthcheck# TODO: The script could also verify that service ports have listeners bound (if justified).
# Implementation approach was discussed with two examples for checking port 25 here:
# https://github.com/docker-mailserver/docker-mailserver/pull/4706#discussion_r3344258001These two discussions were referenced by my previous comment. After these changes are applied, PR is good for merging 👍 |
docs: clarify future options for improving healthcheck script with port checks
Sounds good - I've made the edits in 5ebd34b . Thanks again both for the thoughtful review. |
…r-mailserver#4706) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com>
Summary
Adds a new
dms-healthcheckscript totarget/bin/that performs an intelligent health check by queryingsupervisorctlonly for services that are actually enabled in the current DMS installation.The script sources
/etc/dms-settings(written during container startup) to determine which optional features are enabled, then checks that all corresponding supervisor-managed services are in theRUNNINGstate.supervisorctl statusreturns exit code0only when all named services are running — any non-running service results in a non-zero exit, which container runtimes (Docker, Kubernetes) correctly interpret as unhealthy.If
/etc/dms-settingsdoes not yet exist, the container is still initializing and the check exits non-zero immediately to avoid false positives during startup.Covered Services
cron,rsyslog,postfix,changedetectordovecotSMTP_ONLY != 1opendkimENABLE_OPENDKIM=1opendmarcENABLE_OPENDMARC=1amavisENABLE_AMAVIS=1clamavENABLE_CLAMAV=1fail2banENABLE_FAIL2BAN=1postgreyENABLE_POSTGREY=1postsrsdENABLE_SRS=1rspamd-redisENABLE_RSPAMD_REDIS=1rspamdENABLE_RSPAMD=1getmailENABLE_GETMAIL=1mta-sts-daemonENABLE_MTA_STS=1saslauthd_ldap/saslauthd_rimapENABLE_SASLAUTHD=1fetchmail/fetchmail-NENABLE_FETCHMAIL=1(parallel-aware)Changes
target/bin/dms-healthcheck— new script (installed to/usr/local/bin/via existingCOPY target/bin/*in Dockerfile)Dockerfile— addsHEALTHCHECKdirective using the new scriptcompose.yaml— updateshealthcheckfrom the basic SMTP port check to usedms-healthcheckMotivation
Discussed in docker-mailserver-helm #135 by @polarathene — the Helm chart currently embeds probe logic that needs to know which services are enabled, but this belongs upstream in DMS itself. With this script, external orchestrators can simply call
dms-healthcheckas their probe command without needing to replicate any of this logic.