NAME
Rex::Rancher::Server - Rancher Kubernetes server (control plane) installation
VERSION
version 0.003
SYNOPSIS
use Rex::Rancher::Server;
# Install RKE2 server (default)
install_server(
token => 'my-cluster-secret',
tls_san => ['lb.example.com'],
);
# Install K3s server
install_server(
distribution => 'k3s',
token => 'my-cluster-secret',
tls_san => ['lb.example.com'],
);
# Join additional control plane node (HA setup)
install_server(
distribution => 'rke2',
token => 'my-cluster-secret',
server => 'https://first-server:9345',
);
# Retrieve kubeconfig and join token from a running server
my $kubeconfig = get_kubeconfig('rke2');
my $token = get_token('rke2');
# The kubeconfig for this machine: pointed at the server's address, saved 0600
fetch_kubeconfig(
distribution => 'rke2',
server => 'lb.example.com',
file => "$ENV{HOME}/.kube/cluster.yaml",
);
# Update registry mirrors on an already-running node
update_registries(
distribution => 'rke2',
registries => {
mirrors => { 'docker.io' => { endpoint => ['http://cache:5000'] } },
},
);
DESCRIPTION
Rex::Rancher::Server handles control plane installation for both RKE2 and K3s Kubernetes distributions. It provides a unified interface for installing, configuring, and managing server nodes.
RKE2 installation
By default the official install script at https://get.rke2.io is fetched and run via curl -sfL … | sh -; with install_method => 'artifact' the checksum-verified release tarball is installed instead (see "install_server"). The service is started with --no-block to avoid systemd's 90-second activation timeout (RKE2's first start pulls many container images), then polled with systemctl is-active for up to 10 minutes; a failed or never-active service dies with its journal tail. A running rke2-server is left running on a re-run (systemctl start) unless it has to take something new (see "Re-runs"). Before the service is (re)started, an agent/etc/containerd/config.toml.tmpl that holds only imports and version = 2, as Rex::GPU 0.001 wrote it, is removed with a warning: rke2 renders it instead of its own containerd config, so no SystemdCgroup, sandbox image or registry mirrors. The content decides, never the path: any other template stays, with a log line. If the removal fails, install_server dies before the start. While agent/etc/containerd/config.toml is still that template's output, a running service is then restarted once, with a warning, so rke2 renders its own containerd config (see "remove_bare_containerd_template" in Rex::Rancher::Distribution). K3s is restarted on every run anyway (below), so it renders its config either way. After that the function waits until the kubeconfig file appears at /etc/rancher/rke2/rke2.yaml; API readiness is confirmed separately by the caller using "wait_for_api" in Rex::Rancher::K8s.
Re-runs
A running RKE2 reads its configuration only when it starts, so install_server (and "install_agent" in Rex::Rancher::Agent for rke2-agent) restarts a running service with --no-block, logging why, when the host has changed under it since its main process started:
config.yaml,config.yaml.d/,registries.yamlor/etc/default/rke2-server(-agent) modified, or a containerdconfig.toml.tmpl,config-v3.toml.tmplorconfig-v3.toml.d/drop-in (as Rex::GPU writes) — by modification time, and Rex rewrites a file only when its content differs, so the same options twice change nothing. An upgradednvidia-container-runtimealone is no reason, containerd runs it anew per container;an installed
rke2binary of another version than the running one, where the version skew policy lets it be restarted: a patch release, or the next minor withversionpinned.
Before any of that, the installed binary is compared with the running one (RKE2 and K3s): the next minor without a pinned version is not restarted onto, with a warning, and a jump of more than one minor or a downgrade (possible only when the stable channel could not be resolved before the install, or moved in between) dies without a restart. See version under "install_server".
Nothing changed: systemctl start, the service keeps running. Changes made by hand or left by an interrupted earlier run count as well. What cannot be determined (no ps) counts as unchanged, with a warning. Details in "restart_reasons" in Rex::Rancher::Distribution.
A restart takes this server's API and etcd member down for its duration and touches no other node. Several servers of one HA cluster restarting at the same time can lose etcd quorum: deploy them one after another (Rex's default), not in parallel. K3s is restarted on every run, as its install script rewrites the unit each time, except onto an unpinned new minor, and except with hold_running: then it is restarted as RKE2 is, and also when the install script changed the content of its unit or env file (see hold_running under "install_server").
K3s installation
The official install script at https://get.k3s.io is used (piped, or run against the checksum-verified binary with install_method => 'artifact'), with K3S_URL set when joining an existing server. The script runs with INSTALL_K3S_SKIP_START: instead of its own blocking restart, k3s.service is restarted with --no-block, then the same systemctl is-active wait (at most 10 minutes, journal tail on failure) as for RKE2 follows, so a joining server that cannot reach the first one dies instead of hanging the deploy. A bare config.toml.tmpl in /var/lib/rancher/k3s/agent/etc/containerd is removed before that restart, as on RKE2. Then the kubeconfig wait. The token is read from config.yaml and never passed on the command line. Traefik and ServiceLB are disabled by default (disable in config.yaml, see "install_server") to leave room for Cilium and external load balancers.
Config layout
Both distributions use /etc/rancher/<dist>/config.yaml with the same key names (token, tls-san, node-name, node-label, disable, cni, etc.). When cilium => 1 (the default), the distribution's own CNI is switched off so that Cilium is the only one: on RKE2 cni: none and disable-kube-proxy: true, on K3s flannel-backend: none, disable-network-policy: true, disable-kube-proxy: true and cluster-cidr: 10.42.0.0/16; on both, Cilium's kube-proxy replacement takes over. See "install_server"'s cilium.
Registry mirrors are written to registries.yaml in the same directory. Both files are 0600 root:root: config.yaml holds the join token, registries.yaml may hold registry credentials.
FUNCTIONS
install_server(%opts)
Write the cluster configuration file, optionally write registries.yaml, install the distribution, start the service, wait until systemctl is-active reports it active, and then wait until the kubeconfig file is written to disk by the server process.
Returns 1 on success. Dies if installation fails, the distribution is unknown, the host carries Cilium datapath state from an earlier cluster but no RKE2 or K3s (see below), the installed version differs from a pinned version, a server already set up on the host would get another cluster-cidr (see "cluster_cidr"), or the service does not become active within 10 minutes. A service that ends up failed or never gets active makes the die message carry the last 50 lines of its journal (journalctl -u SERVICE -n 50 --no-pager).
The first thing read from the host, before anything is written or installed, is whether an earlier cluster left Cilium's datapath behind: the pins in /sys/fs/bpf/cilium, the /run/cilium/cgroupv2 mount or the cilium_host device, on a host with neither RKE2 nor K3s (no rke2 or k3s on PATH, none of their server and agent units active). The vendor uninstall scripts leave that state until a reboot, and its socket load balancer makes connections to the old cluster's service addresses hang, so every image pull of the new install stalls on a registry mirror among them. Such a host dies naming what was found and asking for a reboot; see "check_cilium_residue" in Rex::Rancher::Uninstall. With RKE2 or K3s on the host the state is its running cluster's (a re-run, an upgrade) and is not checked.
Options:
distribution-
rke2(default) ork3s. Both have been run live through Rex::Rancher; what the k3s runs covered is listed in Rex::Rancher::Distribution::K3s. token-
Shared secret used for node joining. If omitted, the token the server is already sealed with (
/var/lib/rancher/rke2/server/token, K3s:/var/lib/rancher/k3s/server/token) is reused, so re-runninginstall_serveron a live control plane never rotates its token. Only on a fresh server (no such file) is a new one generated (up to 48 random alphanumeric characters, never fewer than 32). A passedtokenalways wins.The token is written to
config.yamlonly; it is never put on the installer command line or into its environment, wherepswould show it.config.yamlis written0600 root:root, including when it already exists. server-
URL of an existing server node to join. Used for multi-server HA setups (omit for the first/only server). For RKE2 the port is
9345; for K3s it is6443. tls_san-
Additional TLS Subject Alternative Names for the API server certificate, as an arrayref or a comma-separated string. Include the load balancer address, public IP, or DNS name so that kubeconfig clients can connect.
version-
Pinned version string, e.g.
v1.30.4+rke2r1for RKE2 orv1.30.4+k3s1for K3s, handed to the installer asINSTALL_RKE2_VERSION/INSTALL_K3S_VERSION. If omitted, the latest stable release is installed.When given, the version the installed binary reports (
rke2 --version/k3s --version) is compared with it after the installer ran, and a mismatch dies before the service is started, on RKE2 and K3s alike (the K3s install script runs withINSTALL_K3S_SKIP_START). This catches a pinned install or upgrade that failed while an older binary is still on the host.Against a running server the version skew policy applies, for RKE2 and K3s alike, before anything is written or installed: a version more than one minor ahead of the running one, or older than it, dies with both versions and the host unchanged. Without
versionthe version checked is the one the install script would take from the stable channel (https://update.rke2.io/v1-release/channels/stable, K3s:update.k3s.io), resolved on the host withcurl; if that fails, a warning, and the check happens after the install instead, before any restart (see "Re-runs"). A patch release of the running minor is installed and the service restarted, pinned or not. The next minor restarts it only whenversionis pinned; unpinned, it is installed but the running server is not restarted and keeps its old version until its next start (a reboot), with a warning that says so and namessystemctl restart SERVICE. Upgrade a cluster server by server, one minor at a time, then the agents.A server that is not running (stopped, crashed, never started) is held to the same rules against the installed binary (
rke2 --version/k3s --version), the version it would start on: a jump or a downgrade dies before anything is installed, and the next minor withoutversionis installed with a warning, since nothing holds a stopped service back from starting on it. No binary on the host is a fresh install and is not checked. If the stable channel cannot be resolved there, only a warning says the skew is not checked. hold_running-
If true, the host stays on the version it runs: the version of the running server (its main process,
/proc/PID/exe --version) is this run'sversion, for the version skew check, the installer, the check of the installed version and the restart decision alike. A server that is not running holds the installed binary's version (rke2 --version/k3s --version); with neither,versionapplies, and without it the stable channel, as withouthold_running. A running server whose version cannot be read holds the installed binary instead, and without oneversion, each with a warning. It is read before anything is written or installed, RKE2 and K3s alike (see "held_version" in Rex::Rancher::Distribution).A
versiongiven as well is installed only where there is nothing to hold; otherwise the held version wins, and a warning names both.install_method => 'artifact'still requiresversion, for a host with nothing to hold.Held, the server is restarted only when its configuration changed (see "Re-runs"; the binary stays on the version it runs): RKE2 as without
hold_running, and K3s, which is otherwise restarted on every run, the same way, and also when the install script wrote/etc/systemd/system/k3s.serviceork3s.service.envwith other content than before, the check the K3s install script itself makes before it restarts K3s. Default:0. install_method-
How the distribution gets onto the host.
script(default) pipes the official install script intosh(curl -sfL https://get.rke2.io | sh -, K3s:https://get.k3s.io), exactly as without this option.artifactpre-downloads the release artifact for the node's own architecture (uname -mon the host:amd64orarm64, anything else dies) from the GitHub release, verifies it against the release's officialsha256sum-ARCH.txtand dies loudly on a mismatch, then runs the install script against the local file: RKE2 viaINSTALL_RKE2_ARTIFACT_PATH(tarballrke2.linux-ARCH.tar.gz), K3s by installing the binary to/usr/local/bin/k3sand running the script withINSTALL_K3S_SKIP_DOWNLOAD=binary. Downloads run on the host withcurl(no SFTP, nothing is uploaded) into/tmp/rke2-artifacts//tmp/k3s-artifacts, which are emptied first. Requiresversion; dies without one.On RPM-based hosts (Rocky, RHEL) RKE2's install script uses the tarball instead of its RPM method when given an artifact path, so no
rke2-selinuxpackage is installed, and a host that already carries RKE2 from RPMs is refused by the script ("existing RKE2 RPMs"). node_name-
Kubernetes node name, written as
node-nametoconfig.yaml. If omitted, the system hostname is used. disable-
Packaged components to switch off, as an arrayref or a comma-separated string, written as
disabletoconfig.yaml. The names are distribution-specific. Default:['rke2-ingress-nginx', 'rke2-traefik', 'rke2-traefik-crd']on rke2 (no bundled ingress controller: RKE2 ships the Traefik charts since v1.30.3, opt-in, and deploys Traefik by default on new clusters since v1.36; a name the installed RKE2 does not ship is ignored),['traefik', 'servicelb']on k3s. A given list replaces the default rather than extending it;[]disables nothing. Independent ofcilium.# keep the default and also drop metrics-server disable => [qw( rke2-ingress-nginx rke2-traefik rke2-traefik-crd rke2-metrics-server )], cluster_cidr-
The pod network, one IPv4 CIDR such as
10.42.0.0/16, written ascluster-cidrtoconfig.yamlon RKE2 and K3s alike, with or withoutcilium. Anything else (including a dual-stack list) dies before the host is touched. Every server of a cluster needs the same value (RKE2 refuses a joining server that differs), and it cannot be changed on a running cluster. "install_cilium" in Rex::Rancher::Cilium takes the same value as Cilium's pool, used incluster-poolmode (see there). Default: nothing written on RKE2 (RKE2's own default,10.42.0.0/16, applies); on K3s withcilium10.42.0.0/16is written, without it nothing.On a server already set up on the host (its service active, or
server/tokenthere), RKE2 and K3s alike, the value it runs with is read first:cluster-cidrfromconfig.yamland itsconfig.yaml.d/drop-ins, merged as the distribution merges them (the last one wins), or without one the built-in10.42.0.0/16. When this run would give it another one (cluster_cidr, or without it the default above),install_serverdies with both values before anything is written or installed; that includes a re-run that leavescluster_cidrout against a server set up with another. Pass the value it runs with. A file there that cannot be read or parsed dies too, with its name. A server not set up yet is not checked. node_labels-
Node labels applied at join time, as an arrayref of
key=valuestrings. registries-
Private registry mirror configuration. Written to
registries.yamlin the distribution config directory,0600 root:root(it may hold registry passwords). Structure:{ mirrors => { 'docker.io' => { endpoint => ['http://registry.internal:5000'] }, }, configs => { 'registry.internal:5000' => { auth => { username => 'user', password => 'pass' }, }, }, } cilium-
If true (default:
1), switch off the distribution's own CNI in the server config so that Cilium is the only one. Set to0to keep the distribution's default CNI (Canal on RKE2, Flannel on K3s).On rke2,
cni: noneanddisable-kube-proxy: trueare written, preparing the node for Cilium with full kube-proxy replacement.On k3s,
flannel-backend: none,disable-network-policy: true,disable-kube-proxy: trueandcluster-cidr: 10.42.0.0/16(orcluster_cidr) are written: Flannel, k3s's embedded network policy controller and kube-proxy are switched off, and Cilium takes over all three with kube-proxy replacement.cluster-cidris k3s's own default, written out because Cilium's cluster-pool IPAM is given the same range (see Rex::Rancher::Cilium). These are server settings that k3s agents take from the server; an additional server joining withservergets the same keys, as k3s requires them to match across servers. The same keys and Cilium values were verified live in kubernetes-ocp (k3s v1.36.4+k3s1, Cilium 1.20.0, Gateway API v1.6.1), the Cilium version Rex::Rancher::Cilium defaults to, and have since been run live through Rex::Rancher's k3s path (a control plane with one worker; see Rex::Rancher::Distribution::K3s). nvidia_runtime_path-
If true, and
nvidia-container-runtimeis on the host'sPATH, writePATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binto/etc/default/rke2-serverbefore the installer runs. The rke2 unit sets noPATH, and rke2 looks for the NVIDIA runtime only when the service starts; without it a host- or vendor-installed toolkit (/usr/bin, e.g. DGX OS) is not wired into containerd. Other lines of the file are kept, an existingPATH=line is replaced. If the file changed while the service is already running, the service is restarted (see "Re-runs"). The GPU Operator's toolkit (/usr/local/nvidia/toolkit) is found by rke2 without this. No effect on k3s. Default:0; "rancher_deploy_server" in Rex::Rancher turns it on forgpu => 1, gpu_setup => 0.
install_server(
distribution => 'rke2',
token => 'my-cluster-secret',
tls_san => ['loadbalancer.example.com'],
node_labels => ['role=control-plane'],
version => 'v1.30.4+rke2r1',
node_name => 'cp-01',
);
# Checksum-verified release artifact instead of curl | sh
install_server(
version => 'v1.30.4+rke2r1',
install_method => 'artifact',
);
preflight_server(%opts)
my $checked = Rex::Rancher::Server::preflight_server(%opts);
# { version => ..., install_method => 'script', cluster_cidr => ... }
Everything "install_server" checks before it writes or installs, with the same options and in the same order, and nothing else: it only reads the host. "install_server" runs it first thing; "rancher_deploy_server" in Rex::Rancher runs it before it prepares the node, and a caller that prepares the node on its own can do the same, so a refused host is left as it was. Not exported.
- 1. the pure option checks:
distribution,install_method(withversion),cluster_cidr; - 2. Cilium datapath state an earlier cluster left on a host without RKE2 or K3s ("check_cilium_residue" in Rex::Rancher::Uninstall), the first read of the host;
- 3. with
hold_running, the version to hold ("held_version" in Rex::Rancher::Distribution); - 4. the version skew against the running server or installed binary ("check_version_skew" in Rex::Rancher::Distribution), with the held version under
hold_running, elseversion; - 5. the
cluster-cidrof a server already set up on the host ("check_established_cluster_cidr" in Rex::Rancher::Distribution).
Dies as "install_server" would. Returns a hashref: the version this run installs (held, pinned, or undef for the stable channel), the install_method and the cluster_cidr (undef when not given).
update_registries(%opts)
Update registries.yaml on an already-running node and restart the distribution service to pick up the new registry mirror configuration.
Use this to add or change registry mirrors after the cluster is up — for example, after deploying an in-cluster registry that you want every node to use as a pull-through cache.
Before the restart, an agent/etc/containerd/config.toml.tmpl that holds only imports and version = 2, as Rex::GPU 0.001 wrote it, is removed with a warning, for RKE2 and K3s alike: the restart would render it instead of the distribution's own containerd config, which carries the registry mirrors, so the new ones would not take effect. Any other template stays, with a log line; no template changes nothing (see "remove_bare_containerd_template" in Rex::Rancher::Distribution). If the removal fails, update_registries dies after writing registries.yaml and before the restart, so nothing is restarted.
Required options:
registries-
Registry mirror hashref (same structure as
install_server'sregistriesoption).
Optional options:
distribution-
rke2(default) ork3s. Controls which service is restarted.
update_registries(
distribution => 'rke2',
registries => {
mirrors => {
'docker.io' => { endpoint => ['http://registry.internal:5000'] },
'registry.internal' => { endpoint => ['http://registry.internal:5000'] },
},
},
);
get_kubeconfig($distribution)
Read the kubeconfig file from the remote server and return its content as a string. The file is read directly via cat over SSH; no SFTP is used.
$distribution defaults to rke2.
Note: RKE2 and K3s both write a loopback server address (https://127.0.0.1, see "patch_kubeconfig_server"). The content comes back as the node has it; "fetch_kubeconfig" points it at an address that works from elsewhere and saves it, as "rancher_deploy_server" in Rex::Rancher does.
Dies if the file cannot be read.
fetch_kubeconfig(%opts)
my $kubeconfig = fetch_kubeconfig(
distribution => 'rke2',
server => 'cp.example.com',
file => "$ENV{HOME}/.kube/cluster.yaml",
);
The server's kubeconfig for use from this machine: read from the host Rex is connected to ("get_kubeconfig", over the exec channel, no SFTP), pointed at server ("patch_kubeconfig_server"), passed through filter if given, written to file if given, and returned. The CA and the admin client certificate and key are kept.
Options:
distribution-
rke2(default) ork3s. Anything else dies before the host is read. server-
The address the kubeconfig will reach the Kubernetes API at: a host name or an IPv4/IPv6 address, without scheme or port. It must be a name the API server certificate is made for (the node's own IPs and host name, or a
tls_san), since the CA stays. Omitted, the kubeconfig keeps the loopback address the node wrote, which works only on the node itself (aLocalconnection) or through a tunnel to its port 6443.There is no fallback to the host Rex is connected to: the SSH address need not be one the API is reached at or certified for (an ssh_config alias, a jump host, a NAT address). Where it is, pass it:
server => connection->server. filter-
A code reference. It gets the patched kubeconfig and returns the one to write and return: the place for a caller's own policy, which this module does not bring (see the example below). If it returns nothing (
undefor an empty string),fetch_kubeconfigdies and writes nothing. Afilterthat is not a code reference dies before the host is read. file-
Local path the result is written to, mode
0600from the moment it exists: the kubeconfig carries the cluster admin's client certificate and key. An existing file is set to0600before its content is replaced, in place, so a symlink keeps pointing where it did. The directory must exist. Withoutfilenothing is written.
Dies naming the cause if the kubeconfig cannot be read from the host, is empty, or cannot be written.
A caller that wants the CA dropped and certificate verification off, for instance because it reaches the API at a name the certificate does not carry, says so in filter; this module keeps the CA itself:
my $kubeconfig = fetch_kubeconfig(
distribution => 'k3s',
server => connection->server,
file => "$ENV{HOME}/.kube/k3s.yaml",
filter => sub {
my ( $kc ) = @_;
$kc =~ s/^[ \t]*certificate-authority-data:.*\n//mg;
$kc =~ s/^([ \t]*)(server: https:\/\/\S+)\n/$1$2\n$1insecure-skip-tls-verify: true\n/mg;
return $kc;
},
);
patch_kubeconfig_server($content, $server)
my $kubeconfig = patch_kubeconfig_server($content, '2001:db8::10');
# server: https://[2001:db8::10]:6443
Return $content, a kubeconfig as RKE2 or K3s wrote it, with its server URL pointed at $server. Pure: no host, no connection, so it serves a kubeconfig read over any channel, not only through "get_kubeconfig".
RKE2 and K3s write https://127.0.0.1:PORT, or https://[::1]:PORT when the cluster's (first) service CIDR is IPv6; both become https://SERVER:PORT, the port kept. Nothing else changes: the CA (certificate-authority-data) and the client certificate and key stay, so $server must be a name the API server certificate is made for. A server URL that is not loopback (a configured bind-address) is left alone.
$server is a host name or an IPv4/IPv6 address, without scheme or port; an IPv6 address is put in brackets (https://[2001:db8::10]:6443), one already in brackets is taken as it is. Dies without $content or $server.
get_token($distribution)
Read the node join token from the server and return it as a string (trailing newline stripped).
$distribution defaults to rke2.
The token is stored at:
Dies if the file cannot be read (e.g. server not yet started).
SEE ALSO
Rex::Rancher, Rex::Rancher::Node, Rex::Rancher::Agent, Rex::Rancher::Cilium, Rex::Rancher::K8s, Rex
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/rex-rancher/issues.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <getty@cpan.org>
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.