#1100818 pg_lsclusters shows both default config port and the running port

#1100818#5
Date:
2025-03-19 03:57:49 UTC
From:
To:
Package:postgresql-common
Verison:273.pgdg24.04+1

*Dear Community,*

I have been exploring an issue where pg_lsclusters reports the default port
from postgresql.conf, rather than the actual port in use when a PostgreSQL
cluster is started with a custom port using pg_ctlcluster (e.g., -o "-p <
port>").

While this behavior is understandable given permission constraints on
postmaster.pid, it makes tracking running clusters more difficult. To
address this, I have created a *workaround script* that accurately detects
running PostgreSQL instances, retrieves their actual ports, and presents a
corrected cluster listing.

This ensures that administrators can see the correct ports in use, even
when pg_lsclusters does not reflect them. I would appreciate any guidance
on how we might resolve this as a permanent improvement within pg_ctl or
pg_lsclusters.
Please find the workaround patch below

Looking forward to your thoughts and suggestions.

Best regards,
Akash


#!/usr/bin/perl -w
use strict;
use warnings;

# ANSI color codes for output
my $WHITE = "\033[37m";
my $GREEN = "\033[32m";
my $RED = "\033[31m";
my $RESET = "\033[0m";

# Check if user has sudo privileges
my $HAS_SUDO = `sudo -n true 2>/dev/null && echo "yes" || echo "no"`;
chomp($HAS_SUDO);
$HAS_SUDO = $HAS_SUDO eq "yes";

# Fetch all running PostgreSQL processes once
my %running_ports;
if ($HAS_SUDO) {
my $output = `sudo ss -tulnp 2>/dev/null | grep postgres`;
foreach my $line (split /\n/, $output) {
if ($line =~ /:(\d+)\s/) {
$running_ports{$1} = 1;
}
}
}

# Function to get port from postmaster.pid file
sub get_port_from_pid_file {
my ($data_dir) = @_;
my $pid_file = "$data_dir/postmaster.pid";
return undef unless -r $pid_file;
my $port = `head -n 4 $pid_file | tail -n 1 2>/dev/null`;
chomp($port);
return $port if $port =~ /^\d+$/;
return undef;
}

# Function to get cluster owner
sub get_cluster_owner {
my ($port) = @_;
return "<unknown>" unless $HAS_SUDO;
# Try to get owner from process first
my $output = `sudo lsof -i :$port -n -P 2>/dev/null | grep LISTEN | awk
'{print \$3}' | head -1 2>/dev/null`;
chomp($output);
# If we couldn't get the owner from the process, it might be because of sudo
# In that case, we check the postgres process owner
if (!$output || $output eq "") {
$output = `sudo ps -p \$(sudo lsof -i :$port -t 2>/dev/null | head -1) -o
user= 2>/dev/null`;
chomp($output);
}
return $output || "<unknown>";
}

# Function to get PostgreSQL version
sub get_postgres_version {
my ($port) = @_;
return "<unknown>" unless $HAS_SUDO;
my $version = `sudo -u postgres psql -p $port -t -c "SHOW server_version;"
2>/dev/null`;
chomp($version);
$version =~ s/^\s+|\s+$//g;
$version =~ s/^(\d+).*/$1/;
return $version || "<unknown>";
}

# Function to check cluster status
sub is_cluster_running {
my ($port) = @_;
return exists $running_ports{$port} ? "online" : "down";
}

# Function to get data directory
sub get_data_directory {
my ($port) = @_;
return "<unknown>" unless $HAS_SUDO;
my $pid = `sudo lsof -i :$port -t 2>/dev/null`;
chomp($pid);
return "<unknown>" unless $pid;
my $dir = `sudo lsof -p $pid 2>/dev/null | grep 'cwd' | awk '{print \$9}' |
tail -n 1 2>/dev/null`;
chomp($dir);
return $dir || "<unknown>";
}

# Function to get configured port from postgresql.conf
sub get_configured_port {
my ($version, $cluster_name) = @_;
return "<unknown>" unless $HAS_SUDO;
my $config_file = "/etc/postgresql/$version/$cluster_name/postgresql.conf";
my $port = `sudo grep -E '^port\\s*=\\s*[0-9]+' $config_file 2>/dev/null |
awk -F '=' '{print \$2}' | tr -d ' ' | tr -d '#'`;
chomp($port);
$port =~ s/\(changerequiresrestart\)//g;
return $port || "<unknown>";
}

# Function to list all PostgreSQL clusters (including stopped ones)
sub get_all_clusters {
my %clusters;
my @version_paths = glob("/var/lib/postgresql/*");
foreach my $version_path (@version_paths) {
if ($version_path =~ /(\d+)$/) {
my $version = $1;
my @cluster_dirs = glob("$version_path/*");
foreach my $dir (@cluster_dirs) {
if ($dir =~ /([^\/]+)$/) {
my $cluster_name = $1;
my $config_port = get_configured_port($version, $cluster_name);
my $config_file = "/etc/postgresql/$version/$cluster_name/postgresql.conf";
my $pid_port = get_port_from_pid_file($dir);
$clusters{"$version:$cluster_name"} = {
version => $version,
cluster_name => $cluster_name,
config_port => $config_port,
config_file => $config_file,
data_dir => $dir,
port => $pid_port || $config_port,
status => $pid_port ? "online" : "down",
owner => "<unknown>"
};
}
}
}
}
return %clusters;
}

# Get all known clusters
my %all_clusters = get_all_clusters();

# Collect running PostgreSQL instances (only for clusters without pid file
info)
foreach my $port (keys %running_ports) {
my $owner = get_cluster_owner($port);
my $data_dir = get_data_directory($port);
my $version = get_postgres_version($port);
my $status = is_cluster_running($port);
next unless $data_dir =~ m{^/var/lib/postgresql/\d+/};
my ($cluster_name) = $data_dir =~ m{^/var/lib/postgresql/\d+/([^/]+)};
$cluster_name ||= "main";
my $key = "$version:$cluster_name";
if (exists $all_clusters{$key}) {
if ($all_clusters{$key}{status} eq "down") {
$all_clusters{$key}{port} = $port;
$all_clusters{$key}{status} = $status;
$all_clusters{$key}{owner} = $owner;
} else {
# Update owner for running clusters
$all_clusters{$key}{owner} = $owner;
}
} else {
$all_clusters{$key} = {
version => $version,
cluster_name => $cluster_name,
config_port => "<unknown>",
config_file => "<unknown>",
data_dir => $data_dir,
port => $port,
status => $status,
owner => $owner
};
}
}

# Update ownership info for all online clusters
foreach my $key (keys %all_clusters) {
my $cluster = $all_clusters{$key};
if ($cluster->{status} eq "online" && $cluster->{owner} eq "<unknown>") {
$cluster->{owner} = get_cluster_owner($cluster->{port});
}
}

# Update owner for stopped clusters
foreach my $key (keys %all_clusters) {
my $cluster = $all_clusters{$key};
if ($cluster->{status} eq "down" && $cluster->{owner} eq "<unknown>") {
my $data_dir = $cluster->{data_dir};
if (-d $data_dir) {
my $owner = `ls -ld $data_dir 2>/dev/null | awk '{print \$3}'`;
chomp($owner);
$cluster->{owner} = $owner if $owner;
}
}
}

# Prepare output data
my $output = "Ver Cluster Running-Port Config-Port Status Owner
Data-Directory Config-File\n";
foreach my $key (sort keys %all_clusters) {
my $cluster = $all_clusters{$key};
my $color = ($cluster->{status} eq "online") ? $GREEN : $RED;
$output .= $color . join(" ", @{$cluster}{qw(version cluster_name port
config_port status owner data_dir config_file)}) . "${RESET}\n";
}

# Output with proper alignment
if (!keys %all_clusters) {
print "No PostgreSQL clusters detected.\n";
} else {
if (open(my $pipe, "| column -t")) {
print $pipe $output;
close($pipe);
} else {
print $output;
}
}

#1100818#8
Date:
2025-03-19 11:07:40 UTC
From:
To:
Re: AKASH

Can you explain why you need to use these command line options and
aren't just updating the config? This usage of pg_ctlcluster will also
bypass starting the cluster as systemd service which isn't desirable.

Christoph

#1100818#17
Date:
2025-03-20 04:49:55 UTC
From:
To:
Thank you for your response. You raise a valid point about updating the
config file instead of using command-line options.

One genuine use case we encounter is during database failover testing and
recovery drills. We need to temporarily spin up secondary instances of
existing clusters on different ports to verify our failover procedures
without modifying the primary configuration files.

During these exercises, we:

   - Clone the data directory of a production cluster
   - Start the clone with a different port via command-line options
   - Test failover mechanisms and synchronization procedures
   - Shut down the temporary instance when testing completes

This allows us to validate our disaster recovery procedures without
disrupting our production configuration or permanently altering
postgresql.conf files. Since these are temporary instances, running them
outside systemd helps keep our production services untouched and avoids
unnecessary persistent configuration changes.

The challenge we face is that pg_lsclusters reports only the default port
from,postgresql.conf not the actual port in use when a cluster is started
with a custom port via pg_ctlcluster.My workaround script addresses this
immediate need by providing accurate port information specifically for
PostgreSQL instances, making our testing workflow more efficient.

Currently, the script requires sudo privileges to run properly. I'm also
interested in suggestions on how to modify it to work for normal users
without elevated permissions, if possible.

Best regards, Akash