Operating Systems Azure

CentOS Stream 9 Arm64 on Azure User Guide

| Product: CentOS Stream 9 Arm64 on Azure

Overview

This guide covers the deployment and configuration of CentOS Stream 9 on 64-bit Arm (AArch64/arm64) on Microsoft Azure using cloudimg's pre configured virtual machine image from the Azure Marketplace.

CentOS Stream is the continuously delivered distribution that sits immediately upstream of Red Hat Enterprise Linux. Stream 9 is where the next RHEL 9 minor release is built in the open, so it carries the RHEL 9 package set and the same 5.14 kernel line, and it is the natural platform for anyone who wants to develop or test against RHEL 9 before an update ships. This is the native Arm build. It runs on Azure's Arm virtual machine sizes, which are powered by the Azure Cobalt 100 processor (the Dpsv6, Dplsv6 and Epsv6 families), where many workloads deliver the same performance at a lower cost than on x86.

What's included:

  • CentOS Stream 9, native 64-bit Arm (arm64 / AArch64) with the standard 4k page size kernel
  • Every available update applied at build time, and again immediately before capture
  • Automatic security updates installed, armed and enabled, so the machine keeps patching itself
  • Azure Linux Agent (waagent), cloud-init and azure-vm-utils for Azure integration
  • Accelerated Networking that boots cleanly: the stock image fails its network online check on Azure, and this image fixes it
  • The Arm serial console corrected to ttyAMA0, so Azure boot diagnostics and the serial console work
  • The portmapper (rpcbind), which the stock image leaves listening on port 111, switched off
  • The clock on UTC rather than the stock image's US Eastern time zone
  • Chronyd for NTP time synchronisation against the Azure host clock
  • SELinux in enforcing mode
  • BaseOS, AppStream and Extras repositories enabled, so your update path is intact
  • SSH as the only network listener, with no swap on the operating system disk
  • Gen2 Hyper V virtual machine support
  • 24/7 cloudimg support

Platform: Microsoft Azure (Gen2 Hyper V, Arm64, Azure Cobalt 100) Default user: azureuser

CentOS Stream 9 is a rolling release, on Arm

CentOS Stream has no minor releases. It is continuously delivered: VERSION_ID stays 9 for the life of the stream, and every security fix and package update flows into the same stream as it is prepared for the next RHEL 9 minor. There is no frozen point release to pin to and no vault to fall off, so sudo dnf upgrade always moves the machine to the current CentOS Stream 9 package set.

CentOS Stream 9 became generally available on 15 September 2021 and is supported until 31 May 2027, the end of the RHEL 9 full support phase. That is a shorter runway than CentOS Stream 10, which is supported until 31 May 2030. Choose Stream 9 when you specifically need to track RHEL 9; for a new long lived Arm platform, CentOS Stream 10 Arm64 gives you three more years.

Because this is the Arm build, it is a genuinely separate product from the x86 CentOS Stream 9 image: it carries aarch64 packages and an aarch64 kernel, and it only runs on Arm virtual machine sizes. It is the standard 4k page size build. The 64k page size kernel is an optional package on this release and is not installed here.

If you need a frozen minor release with a fixed lifecycle rather than a rolling stream, one of the RHEL rebuild images (AlmaLinux, Rocky Linux or Oracle Linux) is the better fit. CentOS Stream is for tracking what RHEL is about to become.

Security posture

This image ships with no known credential. There is no default password and no baked in SSH key:

  • The root account password is locked, so no one can log in as root with a password.
  • No account on the image has a usable password at all.
  • PasswordAuthentication and keyboard interactive authentication are disabled in the SSH daemon, so every login is by key.
  • PermitRootLogin is set to prohibit-password, which sshd -T reports under its older name without-password, and the root password is locked. The image itself ships no authorized_keys at all. cloud-init does copy your own public key into root's authorized_keys at first boot, but behind its standard forced command, so an ssh root@ attempt prints Please login as the user "azureuser" rather than the user "root". and disconnects. Use sudo from your administrator account instead.
  • SSH host keys are removed before capture and regenerated on each machine at first boot, so no two machines you launch share a host identity.
  • /etc/machine-id is cleared before capture and regenerated per machine.
  • The only key that works is the public key you supply when you create the virtual machine. Azure injects it into the administrator account you name at first boot.
  • SELinux is left in enforcing mode, the CentOS default.
  • SSH on port 22 is the only network listener. The upstream CentOS Stream 9 cloud image ships the rpcbind portmapper enabled and reachable on port 111 over both TCP and UDP; it is switched off in this image.

Prerequisites

Before deploying this image, ensure you have:

  1. An active Microsoft Azure subscription
  2. Access to the Azure Portal or Azure CLI
  3. An SSH key pair for Linux VM access
  4. Familiarity with Azure VM management

Recommended VM Size: Standard_D2ps_v6 (2 vCPU, 8 GB RAM, Azure Cobalt 100) or larger. This is an Arm64 image, so it must be launched on an Arm virtual machine size, such as the Dpsv6, Dplsv6 or Epsv6 families. An x86 size such as Standard_B2s will not accept this image.

Step 1: Deploy the Virtual Machine

Option A: Azure Portal

  1. Navigate to the Azure Marketplace and search for "CentOS Stream 9 Arm64 cloudimg"
  2. Select the image and click Create
  3. Configure the basics:
  4. Subscription: Select your Azure subscription
  5. Resource Group: Create new or select existing
  6. Virtual Machine Name: Enter a name for your VM
  7. Region: Select a region that offers Arm sizes
  8. Size: Standard_D2ps_v6 recommended (an Arm64 size)
  9. Under Administrator Account, select SSH public key and enter your key
  10. Under Inbound Port Rules, allow SSH (port 22)
  11. Click Review + Create, then Create

Option B: Azure CLI

Run this on your own workstation, substituting your resource group, VM name and preferred region. Accept the Marketplace terms once per subscription, then create the machine:

az vm image terms accept --urn cloudimg1647283583153:centos-stream-9-arm64:default:latest

az vm create \
  --resource-group myResourceGroup \
  --name my-centos-stream-9-arm64-vm \
  --image cloudimg1647283583153:centos-stream-9-arm64:default:latest \
  --size Standard_D2ps_v6 \
  --admin-username azureuser \
  --generate-ssh-keys \
  --public-ip-sku Standard

The --size must be an Arm64 family (Standard_D2ps_v6 above). Azure rejects an x86 size for an Arm image at create time.

Step 2: Connect via SSH

Run these on your own workstation, not on the VM. Find the public IP, then connect:

az vm show --resource-group myResourceGroup --name my-centos-stream-9-arm64-vm --show-details --query publicIps -o tsv
ssh azureuser@<vm-ip>

There is no password to enter. If SSH asks you for one, the key you supplied at create time is not the key your client is offering.

Step 3: Confirm the release and the architecture

Check that the machine is CentOS Stream 9:

cat /etc/centos-release
CentOS Stream release 9

The os-release fields carry the same identity in machine readable form. Note that VERSION_ID is 9 with no minor component, because CentOS Stream does not have minor releases:

grep -E '^(NAME|VERSION|ID|VERSION_ID|PLATFORM_ID)=' /etc/os-release
NAME="CentOS Stream"
VERSION="9"
ID="centos"
VERSION_ID="9"
PLATFORM_ID="platform:el9"

/etc/redhat-release records the RHEL family lineage and reads the same on CentOS Stream:

cat /etc/redhat-release
CentOS Stream release 9

Confirm the kernel, the architecture and the page size. aarch64 is the proof this is the Arm build, and 4096 is the page size this release ships:

uname -srm
getconf PAGESIZE
Linux 5.14.0-745.el9.aarch64 aarch64
4096

The 64k page size kernel is an optional package on EL9 and is deliberately not installed, so the page size above will not change under you on a kernel update:

rpm -q kernel-64k || echo "(kernel-64k absent - this image is the standard 4k page size build)"
package kernel-64k is not installed
(kernel-64k absent - this image is the standard 4k page size build)

Confirm the processor. On an Azure Arm size the core is an Arm Neoverse N2, the core inside Azure Cobalt 100:

lscpu | grep -E '^(Architecture|Vendor ID|Model name):'
Architecture:                            aarch64
Vendor ID:                               ARM
Model name:                              Neoverse-N2

The terminal shows cat of etc centos-release reporting CentOS Stream release 9, the os-release fields NAME CentOS Stream, VERSION 9, ID centos, VERSION_ID 9 and PLATFORM_ID platform el9, the redhat-release lineage line, uname reporting the 5.14.0-745 el9 kernel on aarch64 with a 4096 byte page size, kernel-64k reported as not installed, and lscpu reporting an ARM Neoverse-N2 processor

Step 4: Check the patch level and automatic updates

The image is fully patched at capture. dnf check-update exits 0 when nothing is pending and 100 when updates are waiting, so an exit code of 0 is the check you want:

sudo dnf -q check-update >/dev/null 2>&1; echo "exit=$?"
exit=0

Confirm nothing is waiting on a reboot:

sudo dnf needs-restarting -r
No core libraries or services have been updated since boot-up.
Reboot should not be necessary.

The update path is intact. BaseOS carries the core operating system, AppStream carries the application packages and the security updates that reach them, and Extras carries the CentOS specific additions:

sudo dnf repolist --enabled
repo id                       repo name
appstream                     CentOS Stream 9 - AppStream
baseos                        CentOS Stream 9 - BaseOS
extras-common                 CentOS Stream 9 - Extras packages

A single kernel is installed, the one the machine is running:

rpm -q kernel-core
kernel-core-5.14.0-745.el9.aarch64

Automatic updates

dnf-automatic is installed and its timer is enabled, so the machine applies security updates on its own without any action from you:

systemctl is-enabled dnf-automatic.timer
enabled

It runs daily at 06:00 with up to an hour of random delay, so a fleet does not stampede the mirrors at the same second:

systemctl cat dnf-automatic.timer | grep -E 'OnCalendar|RandomizedDelaySec'
OnCalendar=*-*-* 6:00
RandomizedDelaySec=60m

The policy downloads and applies security updates, and never reboots the machine by itself. An unannounced reboot on a production machine is worse than a deferred kernel, so rebooting is left to you:

grep -E '^(apply_updates|upgrade_type|download_updates|reboot) ' /etc/dnf/automatic.conf
upgrade_type = security
download_updates = yes
apply_updates = yes
reboot = never

To apply everything rather than just security fixes, edit upgrade_type to default in /etc/dnf/automatic.conf. To take all pending updates by hand at any time, run sudo dnf -y upgrade.

The terminal shows dnf check-update returning exit code 0 meaning no updates are pending, needs-restarting reporting no reboot necessary, dnf repolist listing appstream, baseos and extras-common as enabled, dnf-automatic.timer reported as enabled with its six o clock schedule and sixty minute randomised delay, the automatic.conf policy showing upgrade_type security, download_updates yes, apply_updates yes and reboot never, and a single installed kernel-core package

Step 5: Verify the security posture

SELinux is enforcing, the CentOS default, and is persisted in /etc/selinux/config so it survives your reboots:

getenforce
Enforcing

The root account password is locked. LK in the second field is the proof:

sudo passwd -S root
root LK 2009-12-22 -1 -1 -1 -1 (Alternate authentication scheme in use.)

No account on the image ships a usable password. This command prints the name of every account that has one, and prints nothing:

sudo awk -F: '($2 !~ /^[!*]/) && ($2 != "") {print $1}' /etc/shadow

Read the SSH policy from the daemon's own effective configuration rather than from the file, since drop-ins and includes can override what you see in /etc/ssh/sshd_config:

sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|pubkeyauthentication|kbdinteractiveauthentication) '
permitrootlogin without-password
pubkeyauthentication yes
passwordauthentication no
kbdinteractiveauthentication no

without-password is the older name for prohibit-password: root may not log in with a password, and any key cloud-init places for root is wrapped in a forced command that refuses the login.

There is no swap on the operating system disk, so nothing pages onto the OS disk and nothing writes secrets to it:

swapon --show

SSH is the only thing listening off box. Port 22 is the entire TCP listener set, and the only UDP socket is chrony's on the loopback address, which is not reachable from off the machine:

ss -tln
State  Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0      128          0.0.0.0:22        0.0.0.0:*
LISTEN 0      128             [::]:22           [::]:*
ss -uln
State  Recv-Q Send-Q Local Address:Port Peer Address:Port
UNCONN 0      0          127.0.0.1:323       0.0.0.0:*
UNCONN 0      0              [::1]:323          [::]:*

The upstream CentOS Stream 9 cloud image ships rpcbind, the NFS portmapper, enabled and listening on port 111 over both TCP and UDP. It is switched off in this image, and a systemd preset keeps it off even if the package is reinstalled or systemctl preset is run. The Cockpit web console package is present but its socket is disabled, so nothing listens on port 9090 either:

systemctl is-enabled rpcbind.socket rpcbind.service cockpit.socket || true
disabled
disabled
disabled
grep '^disable' /etc/systemd/system-preset/00-cloudimg.preset
disable rpcbind.service
disable rpcbind.socket
disable rpcbind.target

If you need NFS, turn the portmapper back on with sudo systemctl enable --now rpcbind.socket, and restrict port 111 in your Network Security Group to the hosts that need it. If you want the Cockpit web console, run sudo systemctl enable --now cockpit.socket and open port 9090 to your own address only.

The terminal shows getenforce reporting Enforcing, passwd -S root reporting the root account as LK meaning locked, no accounts holding a usable password, the effective sshd policy reporting permitrootlogin without-password, pubkeyauthentication yes, passwordauthentication no and kbdinteractiveauthentication no, swapon showing no swap, ss listing sshd on port 22 as the only TCP listener and chronyd on the loopback address as the only UDP socket, and cockpit, rpcbind socket and rpcbind service all disabled with the preset override that keeps rpcbind off

Step 6: Verify Azure integration

The Azure Linux Agent is running and enabled at boot. It is what makes VM extensions, the serial console, password reset and boot diagnostics work:

systemctl is-active waagent
systemctl is-enabled waagent
active
enabled

The Azure guest stack is installed from the CentOS repositories:

rpm -q WALinuxAgent cloud-init chrony azure-vm-utils
WALinuxAgent-2.14.0.1-4.el9.noarch
cloud-init-24.4-8.el9.1.noarch
chrony-4.8-1.el9.aarch64
azure-vm-utils-0.7.0-1.el9.aarch64

cloud-init ran to completion on this boot. It is what injected your SSH key, set the hostname and grew the root filesystem:

cloud-init status
status: done

Accelerated Networking

Azure Accelerated Networking gives the machine two network devices that share one MAC address: the synthetic eth0, which carries your IP address, and a hardware virtual function, eth1, that is silently bonded underneath it for speed. eth1 must be left alone by NetworkManager. On the stock CentOS Stream 9 cloud image it is not: the image writes a legacy network file that pins the MAC address, the virtual function gets caught by it, NetworkManager tries and fails to get an address on it, and every boot ends with NetworkManager-wait-online failed, the system reported as degraded, and a minute added to boot time.

This image renders its network configuration as NetworkManager keyfiles instead, so the virtual function is correctly left unmanaged and the machine boots clean:

nmcli -t -f DEVICE,TYPE,STATE,CONNECTION dev status
eth0:ethernet:connected:cloud-init eth0
lo:loopback:connected (externally):lo
eth1:ethernet:unmanaged:
systemctl is-system-running
running

Do not configure an IP address on eth1, and do not add a connection profile for it. All traffic flows through eth0. On a VM size or subnet without Accelerated Networking there is simply no eth1.

Serial console, time and storage

The serial console is on ttyAMA0, the Arm PL011 UART. This matters on Arm: the upstream cloud image inherits the x86 default of ttyS0, a 16550 UART that does not exist on Cobalt 100, and left alone the Azure serial console and boot diagnostics would show nothing at all. This image corrects it, and the fix persists across kernel upgrades:

grep -o 'console=ttyAMA0[^ ]*' /proc/cmdline
console=ttyAMA0,115200n8

Time is synchronised against the Azure host clock by chronyd, and the machine is on UTC. The upstream cloud image ships set to America/New_York; this image resets it to UTC so your logs and scheduled jobs are not silently on US Eastern time:

systemctl is-active chronyd
active
timedatectl | grep -E 'Time zone|System clock|NTP service'
                Time zone: UTC (UTC, +0000)
System clock synchronized: yes
              NTP service: active

To use your own time zone, run sudo timedatectl set-timezone Europe/London, substituting your zone.

The disk layout is flat: a 200 MB EFI system partition, a separate 1 GB /boot, and an XFS root that cloud-init grows to the full size of the operating system disk on first boot. There is no LVM to unpick:

findmnt -o TARGET,SOURCE,FSTYPE,SIZE -t xfs,vfat
TARGET        SOURCE    FSTYPE   SIZE
/             /dev/sda3 xfs     28.7G
└─/boot       /dev/sda2 xfs      960M
  └─/boot/efi /dev/sda1 vfat   199.8M
df -h / /boot
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda3        29G  2.0G   27G   7% /
/dev/sda2       960M  161M  800M  17% /boot

To use a larger operating system disk, set the size when you create the VM, or resize the disk in the portal and reboot. The root filesystem follows it automatically.

The terminal shows waagent active and enabled at boot, the WALinuxAgent, cloud-init, chrony and azure-vm-utils package versions from the CentOS repositories, cloud-init status reporting done, the kernel command line carrying console equals ttyAMA0 for the Arm PL011 serial console, nmcli showing eth0 connected and the accelerated networking device eth1 unmanaged with the system running, chronyd active with the clock synchronised on UTC, and lsblk showing an EFI partition, a separate boot partition and an XFS root

Step 7: Networking and the firewall

There is no host firewall in this image. firewalld is not installed:

rpm -q firewalld || echo "(firewalld absent - the Azure NSG is your only firewall)"
package firewalld is not installed
(firewalld absent - the Azure NSG is your only firewall)

The only packet filter rules on the machine are the three the Azure Linux Agent installs to protect the Azure wire server endpoint 168.63.129.16. They are present on every Azure Linux image, they only affect traffic to that one address, and they do not filter anything you serve. On CentOS Stream 9 the agent manages them with iptables:

sudo iptables -t security -S
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-A OUTPUT -d 168.63.129.16/32 -p tcp -m tcp --dport 53 -j ACCEPT
-A OUTPUT -d 168.63.129.16/32 -p tcp -m owner --uid-owner 0 -j ACCEPT
-A OUTPUT -d 168.63.129.16/32 -p tcp -m conntrack --ctstate INVALID,NEW -j DROP

Your Azure Network Security Group is the firewall. It sits in front of the network interface and is the right place to control inbound access, because it drops traffic before it reaches the machine at all. Open only the ports you actually serve, and restrict the source range on SSH to the addresses you administer from.

Add a rule from your own workstation like this:

az network nsg rule create \
  --resource-group myResourceGroup \
  --nsg-name my-centos-stream-9-arm64-vmNSG \
  --name allow-https \
  --priority 1010 \
  --destination-port-ranges 443 \
  --access Allow --protocol Tcp

If you prefer a host firewall as well, install one in the usual way with sudo dnf install -y firewalld and enable it with sudo systemctl enable --now firewalld. Open SSH before you start it, or you will lock yourself out.

Step 8: Install software

AppStream carries the application packages. Everything in the repositories is built for Arm, so dnf resolves aarch64 packages automatically with nothing extra to configure:

sudo dnf -q list --available nginx 2>/dev/null | tail -2
Available Packages
nginx.aarch64                     2:1.20.1-31.el9                      appstream

Install a package and enable it in the usual way, for example sudo dnf install -y nginx followed by sudo systemctl enable --now nginx, then open the port in your Network Security Group.

To add another administrator, create the account with sudo adduser <name>, add it to the wheel group with sudo usermod -aG wheel <name>, then copy your public key into /home/<name>/.ssh/authorized_keys with mode 600 and the .ssh directory mode 700, owned by that user. The image ships with no human accounts at all, so the administrator you named at create time is the only login until you add one.

Common Use Cases

  • Developing and testing against the next RHEL 9 minor release before it ships, on Arm
  • Building and validating packages, containers and RPM builds for the RHEL 9 ecosystem on Arm
  • Arm build and CI runners on cost efficient Azure Cobalt 100 virtual machines
  • Reproducing and fixing issues reported against RHEL 9 on Arm hardware
  • Upstream contribution and QA work on the CentOS Stream 9 distribution itself

Troubleshooting

Cannot connect via SSH

Check the Network Security Group allows inbound TCP 22 from your address, confirm the machine is running, and make sure your client is offering the private key that matches the public key you supplied at create time. Password authentication is disabled, so a password prompt means key negotiation already failed. Root cannot log in over SSH, so connect as the administrator you named at create time.

az vm show --resource-group myResourceGroup --name my-centos-stream-9-arm64-vm --show-details --query powerState -o tsv

The VM will not create or will not boot

This is an Arm64 image. It only runs on Arm virtual machine sizes (the Dpsv6, Dplsv6 and Epsv6 Azure Cobalt 100 families and other p sizes). If Azure rejects the size at create time, confirm you selected an Arm size and not an x86 one such as Standard_B2s or Standard_D2s_v5. If the machine never becomes reachable, open Boot diagnostics and the Serial console in the portal, which read from ttyAMA0 on this image.

The system reports degraded

A healthy machine reports running. If it reports degraded, list the failed units:

systemctl --failed --no-legend

If NetworkManager-wait-online.service is the one that failed, check whether a legacy network file has been added under /etc/sysconfig/network-scripts/ that pins a MAC address with HWADDR=. On Azure that catches the Accelerated Networking virtual function, which shares eth0's MAC. Remove the file, or drop the HWADDR= line, and reboot.

Azure agent not running

Check whether the agent is running and read its recent log:

systemctl is-active waagent
sudo journalctl -u waagent -n 20 --no-pager

If it reports anything other than active, start it:

sudo systemctl enable --now waagent

Package manager issues

Clear the metadata cache and rebuild it:

sudo dnf clean all
sudo dnf makecache

If a repository is unreachable, confirm the machine has outbound HTTPS and that DNS resolves, since the CentOS mirror system is reached over both.

An update changed behaviour unexpectedly

CentOS Stream is a rolling distribution: packages move forward continuously as the next RHEL 9 minor is prepared, so a dnf upgrade can bring a newer component than you had yesterday. This is the point of the distribution, not a fault. Pin production workloads that need a frozen package set to a RHEL rebuild image instead, and use dnf history to inspect or undo a transaction:

sudo dnf history list 2>/dev/null | awk 'NR<=5'

A service is blocked and the logs mention SELinux

SELinux is enforcing, which is the secure default and should stay that way. Look at the denial rather than disabling SELinux:

sudo ausearch -m AVC -ts recent </dev/null 2>/dev/null || echo "no recent AVC denials"

Most denials on a fresh machine come from software installed into a non standard path. Relabel with sudo restorecon -Rv /path before considering any policy change.

Important Notes

CentOS Stream is free and open source. It is not covered by a single licence: the distribution is a collection of free and open source licences across its package set, predominantly GPL and LGPL, with MIT, BSD, Apache and others. No subscription, entitlement or licence key is required, and no per seat cost applies.

CentOS is a trademark of Red Hat, Inc. cloudimg is not affiliated with, endorsed by or sponsored by Red Hat, and uses the name only to identify the distribution this image contains. Every package in this image comes from the CentOS Project's own repositories.

CentOS Stream 9 became generally available on 15 September 2021 and is supported until 31 May 2027, the end of the RHEL 9 full support phase. It is continuously delivered and has no minor releases, so there is nothing to pin and nothing to migrate between within the stream. Plan a move to CentOS Stream 10 Arm64, supported until 31 May 2030, before Stream 9 reaches end of life.

Support

For assistance with this image, contact cloudimg support: