This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch v3-0-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-0-test by this push:
     new 7212c8876ef [v3-0-test] Remove gunicorn daemonize for api-server 
(#52860) (#53372)
7212c8876ef is described below

commit 7212c8876efaa64e2d62d320ab53ef85491dcd65
Author: LIU ZHE YOU <[email protected]>
AuthorDate: Sun Jul 20 20:53:20 2025 +0800

    [v3-0-test] Remove gunicorn daemonize for api-server (#52860) (#53372)
    
    * [v3-0-test] Remove gunicorn daemonize for api-server (#52860)
    
    * Remove gunicorn daemonize for api-server
    
    * Rename _CommonCLIUvicornTestClass
    
    * Add test for api-server with daemon option
    
    * Fix setproctitle on MacOS
    
    * Replace api/access_logfile with api/log_config
    
    * Fix [api/log_config] cli_config
    
    * Remove logging and unsed option in api_server_command
    
    - the logging before daemonization is not logging the correct PID so
      remove it
    - should_setup_logging is default as False
    
    * Fix test_run_command_daemon
    
    * Add significant rst
    
    (cherry picked from commit ab1ee72c2bedf7b5559f6860c4004e3888c78e54)
    
    * Fix spelling wordlist
    
    * Fix configuration
---
 airflow-core/newsfragments/52860.significant.rst   |  17 ++
 airflow-core/src/airflow/cli/cli_config.py         |  10 +-
 .../src/airflow/cli/commands/api_server_command.py |  96 +++++++----
 .../src/airflow/config_templates/config.yml        |   9 +-
 airflow-core/src/airflow/configuration.py          |   1 +
 .../tests/unit/cli/commands/_common_cli_classes.py |  16 +-
 .../unit/cli/commands/test_api_server_command.py   | 192 ++++++++++++++++++---
 docs/spelling_wordlist.txt                         |   1 +
 8 files changed, 269 insertions(+), 73 deletions(-)

diff --git a/airflow-core/newsfragments/52860.significant.rst 
b/airflow-core/newsfragments/52860.significant.rst
new file mode 100644
index 00000000000..5962897ec20
--- /dev/null
+++ b/airflow-core/newsfragments/52860.significant.rst
@@ -0,0 +1,17 @@
+Replace API server ``access_logfile`` configuration with ``log_config``
+
+The API server configuration option ``[api] access_logfile`` has been replaced 
with ``[api] log_config`` to align with uvicorn's logging configuration instead 
of the legacy gunicorn approach.
+The new ``log_config`` option accepts a path to a logging configuration file 
compatible with ``logging.config.fileConfig``, providing more flexible logging 
configuration for the API server.
+
+This change also removes the dependency on gunicorn for daemonization, making 
the API server ``--daemon`` option consistent with other Airflow components 
like scheduler and triggerer.
+
+* Types of change
+
+  * [ ] Dag changes
+  * [x] Config changes
+  * [ ] API changes
+  * [ ] CLI changes
+  * [ ] Behaviour changes
+  * [ ] Plugin changes
+  * [ ] Dependency changes
+  * [ ] Code interface changes
diff --git a/airflow-core/src/airflow/cli/cli_config.py 
b/airflow-core/src/airflow/cli/cli_config.py
index 97c72fdbbdf..eb641e2ea5c 100644
--- a/airflow-core/src/airflow/cli/cli_config.py
+++ b/airflow-core/src/airflow/cli/cli_config.py
@@ -618,10 +618,10 @@ ARG_API_SERVER_HOSTNAME = Arg(
     default=conf.get("api", "host"),
     help="Set the host on which to run the API server",
 )
-ARG_API_SERVER_ACCESS_LOGFILE = Arg(
-    ("-A", "--access-logfile"),
-    default=conf.get("api", "access_logfile"),
-    help="The logfile to store the access log. Use '-' to print to stdout",
+ARG_API_SERVER_LOG_CONFIG = Arg(
+    ("--log-config",),
+    default=conf.get("api", "log_config", fallback=None),
+    help="(Optional) Path to the logging configuration file for the uvicorn 
server. If not set, the default uvicorn logging configuration will be used.",
 )
 ARG_API_SERVER_APPS = Arg(
     ("--apps",),
@@ -1795,7 +1795,7 @@ core_commands: list[CLICommand] = [
             ARG_DAEMON,
             ARG_STDOUT,
             ARG_STDERR,
-            ARG_API_SERVER_ACCESS_LOGFILE,
+            ARG_API_SERVER_LOG_CONFIG,
             ARG_API_SERVER_APPS,
             ARG_LOG_FILE,
             ARG_SSL_CERT,
diff --git a/airflow-core/src/airflow/cli/commands/api_server_command.py 
b/airflow-core/src/airflow/cli/commands/api_server_command.py
index 343890aa8e4..5a8e1fb8641 100644
--- a/airflow-core/src/airflow/cli/commands/api_server_command.py
+++ b/airflow-core/src/airflow/cli/commands/api_server_command.py
@@ -21,13 +21,13 @@ from __future__ import annotations
 import logging
 import os
 import subprocess
+import sys
 import textwrap
 
 import uvicorn
-from gunicorn.util import daemonize
-from setproctitle import setproctitle
 
 from airflow import settings
+from airflow.cli.commands.daemon_utils import run_command_with_daemon_option
 from airflow.exceptions import AirflowConfigException
 from airflow.utils import cli as cli_utils
 from airflow.utils.providers_configuration_loader import 
providers_configuration_loaded
@@ -40,6 +40,55 @@ log = logging.getLogger(__name__)
 # more info here: 
https://github.com/benoitc/gunicorn/issues/1877#issuecomment-1911136399
 
 
+def _run_api_server(args, apps: str, num_workers: int, worker_timeout: int, 
proxy_headers: bool):
+    """Run the API server."""
+    log.info(
+        textwrap.dedent(
+            f"""\
+            Running the uvicorn with:
+            Apps: {apps}
+            Workers: {num_workers}
+            Host: {args.host}:{args.port}
+            Timeout: {worker_timeout}
+            Logfiles: {args.log_file or "-"}
+            
================================================================="""
+        )
+    )
+    # get ssl cert and key filepaths here instead of passing them as arguments 
to reduce the number of arguments
+    ssl_cert, ssl_key = _get_ssl_cert_and_key_filepaths(args)
+
+    # setproctitle causes issue on Mac OS: 
https://github.com/benoitc/gunicorn/issues/3021
+    os_type = sys.platform
+    if os_type == "darwin":
+        log.debug("Mac OS detected, skipping setproctitle")
+    else:
+        from setproctitle import setproctitle
+
+        setproctitle(f"airflow api_server -- host:{args.host} 
port:{args.port}")
+
+    uvicorn_kwargs = {
+        "host": args.host,
+        "port": args.port,
+        "workers": num_workers,
+        "timeout_keep_alive": worker_timeout,
+        "timeout_graceful_shutdown": worker_timeout,
+        "ssl_keyfile": ssl_key,
+        "ssl_certfile": ssl_cert,
+        "access_log": True,
+        "proxy_headers": proxy_headers,
+    }
+    # Only set the log_config if it is provided, otherwise use the default 
uvicorn logging configuration.
+    if args.log_config and args.log_config != "-":
+        # The [api/log_config] is migrated from [api/access_logfile] and 
[api/access_logfile] defaults to "-" for stdout for Gunicorn.
+        # So we need to check if the log_config is set to "-" or not; if it is 
set to "-", we regard it as not set.
+        uvicorn_kwargs["log_config"] = args.log_config
+
+    uvicorn.run(
+        "airflow.api_fastapi.main:app",
+        **uvicorn_kwargs,
+    )
+
+
 @cli_utils.action_cli
 @providers_configuration_loaded
 def api_server(args):
@@ -47,7 +96,6 @@ def api_server(args):
     print(settings.HEADER)
 
     apps = args.apps
-    access_logfile = args.access_logfile or "-"
     num_workers = args.workers
     worker_timeout = args.worker_timeout
     proxy_headers = args.proxy_headers
@@ -74,6 +122,9 @@ def api_server(args):
         if args.proxy_headers:
             run_args.append("--proxy-headers")
 
+        if args.log_config and args.log_config != "-":
+            run_args.extend(["--log-config", args.log_config])
+
         # There is no way to pass the apps to airflow/api_fastapi/main.py in 
the development mode
         # because fastapi dev command does not accept any additional arguments
         # so environment variable is being used to pass it
@@ -85,35 +136,16 @@ def api_server(args):
             process.wait()
         os.environ.pop("AIRFLOW_API_APPS")
     else:
-        if args.daemon:
-            daemonize()
-            log.info("Daemonized the API server process PID: %s", os.getpid())
-
-        log.info(
-            textwrap.dedent(
-                f"""\
-                Running the uvicorn with:
-                Apps: {apps}
-                Workers: {num_workers}
-                Host: {args.host}:{args.port}
-                Timeout: {worker_timeout}
-                Logfiles: {access_logfile}
-                
================================================================="""
-            )
-        )
-        ssl_cert, ssl_key = _get_ssl_cert_and_key_filepaths(args)
-        setproctitle(f"airflow api_server -- host:{args.host} 
port:{args.port}")
-        uvicorn.run(
-            "airflow.api_fastapi.main:app",
-            host=args.host,
-            port=args.port,
-            workers=num_workers,
-            timeout_keep_alive=worker_timeout,
-            timeout_graceful_shutdown=worker_timeout,
-            ssl_keyfile=ssl_key,
-            ssl_certfile=ssl_cert,
-            access_log=access_logfile,
-            proxy_headers=proxy_headers,
+        run_command_with_daemon_option(
+            args=args,
+            process_name="api_server",
+            callback=lambda: _run_api_server(
+                args=args,
+                apps=apps,
+                num_workers=num_workers,
+                worker_timeout=worker_timeout,
+                proxy_headers=proxy_headers,
+            ),
         )
 
 
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index 503084e13a9..33d52dfe75a 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -1391,13 +1391,14 @@ api:
       type: integer
       example: ~
       default: "120"
-    access_logfile:
+    log_config:
       description: |
-        Log files for the api server. '-' means log to stderr.
+        Path to the logging configuration file for the uvicorn server.
+        If not set, the default uvicorn logging configuration will be used.
       version_added: ~
       type: string
-      example: ~
-      default: "-"
+      example: path/to/logging_config.yaml
+      default: ~
     ssl_cert:
       description: |
         Paths to the SSL certificate and key for the api server. When both are
diff --git a/airflow-core/src/airflow/configuration.py 
b/airflow-core/src/airflow/configuration.py
index 5fd67f53343..f4182f65c44 100644
--- a/airflow-core/src/airflow/configuration.py
+++ b/airflow-core/src/airflow/configuration.py
@@ -365,6 +365,7 @@ class AirflowConfigParser(ConfigParser):
         ("api", "secret_key"): ("webserver", "secret_key", "3.0.2"),
         ("api", "enable_swagger_ui"): ("webserver", "enable_swagger_ui", 
"3.0.2"),
         ("dag_processor", "parsing_pre_import_modules"): ("scheduler", 
"parsing_pre_import_modules", "3.0.3"),
+        ("api", "log_config"): ("api", "access_logfile", "3.0.4"),
     }
 
     # A mapping of new section -> (old section, since_version).
diff --git a/airflow-core/tests/unit/cli/commands/_common_cli_classes.py 
b/airflow-core/tests/unit/cli/commands/_common_cli_classes.py
index 37bbca0784a..3adfb78e699 100644
--- a/airflow-core/tests/unit/cli/commands/_common_cli_classes.py
+++ b/airflow-core/tests/unit/cli/commands/_common_cli_classes.py
@@ -33,7 +33,7 @@ from airflow.utils.cli import setup_locations
 console = Console(width=400, color_system="standard")
 
 
-class _CommonCLIGunicornTestClass:
+class _CommonCLIUvicornTestClass:
     main_process_regexp: str = "process_to_look_for"
 
     @pytest.fixture(autouse=True)
@@ -49,12 +49,12 @@ class _CommonCLIGunicornTestClass:
         # Confirm that nmain procss hasn't been launched.
         # pgrep returns exit status 1 if no process matched.
         # Use more specific regexps (^) to avoid matching pytest run when 
running specific method.
-        # For instance, we want to be able to do: pytest -k 'gunicorn'
+        # For instance, we want to be able to do: pytest -k 'uvicorn'
         airflow_internal_api_pids = 
self._find_all_processes(self.main_process_regexp)
-        gunicorn_pids = self._find_all_processes(r"gunicorn: ")
-        if airflow_internal_api_pids or gunicorn_pids:
+        uvicorn_pids = self._find_all_processes(r"uvicorn: ")
+        if airflow_internal_api_pids or uvicorn_pids:
             console.print("[blue]Some processes are still running")
-            for pid in gunicorn_pids + airflow_internal_api_pids:
+            for pid in uvicorn_pids + airflow_internal_api_pids:
                 with suppress(NoSuchProcess):
                     console.print(psutil.Process(pid).as_dict(attrs=["pid", 
"name", "cmdline"]))
             console.print("[blue]Here list of processes ends")
@@ -63,9 +63,9 @@ class _CommonCLIGunicornTestClass:
                 for pid in airflow_internal_api_pids:
                     with suppress(NoSuchProcess):
                         psutil.Process(pid).kill()
-            if gunicorn_pids:
-                console.print("[yellow]Forcefully killing all gunicorn 
processes")
-                for pid in gunicorn_pids:
+            if uvicorn_pids:
+                console.print("[yellow]Forcefully killing all uvicorn 
processes")
+                for pid in uvicorn_pids:
                     with suppress(NoSuchProcess):
                         psutil.Process(pid).kill()
             if not ignore_running:
diff --git a/airflow-core/tests/unit/cli/commands/test_api_server_command.py 
b/airflow-core/tests/unit/cli/commands/test_api_server_command.py
index 837ee0f61bd..89689d3d9ba 100644
--- a/airflow-core/tests/unit/cli/commands/test_api_server_command.py
+++ b/airflow-core/tests/unit/cli/commands/test_api_server_command.py
@@ -24,19 +24,19 @@ from rich.console import Console
 from airflow.cli.commands import api_server_command
 from airflow.exceptions import AirflowConfigException
 
-from unit.cli.commands._common_cli_classes import _CommonCLIGunicornTestClass
+from unit.cli.commands._common_cli_classes import _CommonCLIUvicornTestClass
 
 console = Console(width=400, color_system="standard")
 
 
 @pytest.mark.db_test
-class TestCliApiServer(_CommonCLIGunicornTestClass):
+class TestCliApiServer(_CommonCLIUvicornTestClass):
     main_process_regexp = r"airflow api-server"
 
     @pytest.mark.parametrize(
         "args, expected_command",
         [
-            (
+            pytest.param(
                 ["api-server", "--port", "9092", "--host", "somehost", 
"--dev"],
                 [
                     "fastapi",
@@ -47,8 +47,9 @@ class TestCliApiServer(_CommonCLIGunicornTestClass):
                     "--host",
                     "somehost",
                 ],
+                id="dev mode with port and host",
             ),
-            (
+            pytest.param(
                 ["api-server", "--port", "9092", "--host", "somehost", 
"--dev", "--proxy-headers"],
                 [
                     "fastapi",
@@ -60,6 +61,31 @@ class TestCliApiServer(_CommonCLIGunicornTestClass):
                     "somehost",
                     "--proxy-headers",
                 ],
+                id="dev mode with port, host and proxy headers",
+            ),
+            pytest.param(
+                [
+                    "api-server",
+                    "--port",
+                    "9092",
+                    "--host",
+                    "somehost",
+                    "--dev",
+                    "--log-config",
+                    "my_log_config.yaml",
+                ],
+                [
+                    "fastapi",
+                    "dev",
+                    "airflow-core/src/airflow/api_fastapi/main.py",
+                    "--port",
+                    "9092",
+                    "--host",
+                    "somehost",
+                    "--log-config",
+                    "my_log_config.yaml",
+                ],
+                id="dev mode with port, host and log config",
             ),
         ],
     )
@@ -119,40 +145,158 @@ class TestCliApiServer(_CommonCLIGunicornTestClass):
             # Assert that AIRFLOW_API_APPS was unset after subprocess
             mock_environ.pop.assert_called_with("AIRFLOW_API_APPS")
 
-    def test_args_to_uvicorn(self, ssl_cert_and_key):
-        cert_path, key_path = ssl_cert_and_key
-
-        with (
-            mock.patch("uvicorn.run") as mock_run,
-        ):
-            args = self.parser.parse_args(
+    @pytest.mark.parametrize(
+        "cli_args, expected_additional_kwargs",
+        [
+            pytest.param(
                 [
                     "api-server",
                     "--pid",
                     "/tmp/x.pid",
                     "--ssl-cert",
-                    str(cert_path),
+                    "ssl_cert_path_placeholder",
                     "--ssl-key",
-                    str(key_path),
+                    "ssl_key_path_placeholder",
                     "--apps",
                     "core",
-                ]
-            )
+                ],
+                {
+                    "ssl_keyfile": "ssl_key_path_placeholder",
+                    "ssl_certfile": "ssl_cert_path_placeholder",
+                },
+                id="api-server with SSL cert and key",
+            ),
+            pytest.param(
+                [
+                    "api-server",
+                    "--log-config",
+                    "my_log_config.yaml",
+                ],
+                {
+                    "ssl_keyfile": None,
+                    "ssl_certfile": None,
+                    "log_config": "my_log_config.yaml",
+                },
+                id="api-server with log config",
+            ),
+        ],
+    )
+    def test_args_to_uvicorn(self, ssl_cert_and_key, cli_args, 
expected_additional_kwargs):
+        cert_path, key_path = ssl_cert_and_key
+        if "ssl_cert_path_placeholder" in cli_args:
+            cli_args[cli_args.index("ssl_cert_path_placeholder")] = 
str(cert_path)
+            expected_additional_kwargs["ssl_certfile"] = str(cert_path)
+        if "ssl_key_path_placeholder" in cli_args:
+            cli_args[cli_args.index("ssl_key_path_placeholder")] = 
str(key_path)
+            expected_additional_kwargs["ssl_keyfile"] = str(key_path)
+
+        with (
+            mock.patch("uvicorn.run") as mock_run,
+        ):
+            args = self.parser.parse_args(cli_args)
             api_server_command.api_server(args)
 
             mock_run.assert_called_with(
                 "airflow.api_fastapi.main:app",
-                host="0.0.0.0",
-                port=8080,
-                workers=4,
-                timeout_keep_alive=120,
-                timeout_graceful_shutdown=120,
-                ssl_keyfile=str(key_path),
-                ssl_certfile=str(cert_path),
-                access_log="-",
-                proxy_headers=False,
+                **{
+                    "host": args.host,
+                    "port": args.port,
+                    "workers": args.workers,
+                    "timeout_keep_alive": args.worker_timeout,
+                    "timeout_graceful_shutdown": args.worker_timeout,
+                    "access_log": True,
+                    "proxy_headers": args.proxy_headers,
+                    **expected_additional_kwargs,
+                },
             )
 
+    @pytest.mark.parametrize(
+        "demonize",
+        [True, False],
+    )
+    @mock.patch("airflow.cli.commands.daemon_utils.TimeoutPIDLockFile")
+    @mock.patch("airflow.cli.commands.daemon_utils.setup_locations")
+    @mock.patch("airflow.cli.commands.daemon_utils.daemon")
+    
@mock.patch("airflow.cli.commands.daemon_utils.check_if_pidfile_process_is_running")
+    @mock.patch("airflow.cli.commands.api_server_command.uvicorn")
+    def test_run_command_daemon(
+        self, mock_uvicorn, _, mock_daemon, mock_setup_locations, 
mock_pid_file, demonize
+    ):
+        mock_setup_locations.return_value = (
+            mock.MagicMock(name="pidfile"),
+            mock.MagicMock(name="stdout"),
+            mock.MagicMock(name="stderr"),
+            mock.MagicMock(name="INVALID"),
+        )
+        args = self.parser.parse_args(
+            [
+                "api-server",
+                "--host",
+                "my-hostname",
+                "--port",
+                "9090",
+                "--workers",
+                "2",
+                "--worker-timeout",
+                "60",
+            ]
+            + (["--daemon"] if demonize else [])
+        )
+        mock_open = mock.mock_open()
+        with mock.patch("airflow.cli.commands.daemon_utils.open", mock_open):
+            api_server_command.api_server(args)
+
+        mock_uvicorn.run.assert_called_once_with(
+            "airflow.api_fastapi.main:app",
+            host="my-hostname",
+            port=9090,
+            workers=2,
+            timeout_keep_alive=60,
+            timeout_graceful_shutdown=60,
+            ssl_keyfile=None,
+            ssl_certfile=None,
+            access_log=True,
+            proxy_headers=False,
+        )
+
+        if demonize:
+            assert mock_daemon.mock_calls[:3] == [
+                mock.call.DaemonContext(
+                    pidfile=mock_pid_file.return_value,
+                    files_preserve=None,
+                    stdout=mock_open.return_value,
+                    stderr=mock_open.return_value,
+                    umask=0o077,
+                ),
+                mock.call.DaemonContext().__enter__(),
+                mock.call.DaemonContext().__exit__(None, None, None),
+            ]
+            assert mock_setup_locations.mock_calls == [
+                mock.call(
+                    process="api_server",
+                    pid=None,
+                    stdout=None,
+                    stderr=None,
+                    log=None,
+                )
+            ]
+            
mock_pid_file.assert_has_calls([mock.call(mock_setup_locations.return_value[0], 
-1)])
+            assert mock_open.mock_calls == [
+                mock.call(mock_setup_locations.return_value[1], "a"),
+                mock.call().__enter__(),
+                mock.call(mock_setup_locations.return_value[2], "a"),
+                mock.call().__enter__(),
+                mock.call().truncate(0),
+                mock.call().truncate(0),
+                mock.call().__exit__(None, None, None),
+                mock.call().__exit__(None, None, None),
+            ]
+        else:
+            assert mock_daemon.mock_calls == []
+            mock_setup_locations.mock_calls == []
+            mock_pid_file.assert_not_called()
+            mock_open.assert_not_called()
+
     @pytest.mark.parametrize(
         "ssl_arguments, error_pattern",
         [
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index f1106185bcc..873b93b965e 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -786,6 +786,7 @@ grpc
 GSoD
 gsuite
 Gunicorn
+gunicorn
 gz
 Gzip
 gzipped

Reply via email to