Red Hat Certified Engineer (RHCE) #16: RHCSA automation 3 — storage (LVM), filesystems (NFS)

9 min read

In #15 RHCSA automation 2: services, chronyd, log we handled services, time synchronization, and logging with a playbook. This time we automate storage with Ansible — the most hands-intensive area of RHCSA. The goal is to express the whole sequence as idempotent tasks: create partitions on a disk, build a VG and LV with LVM, lay down a filesystem, and chain all the way to a persistent mount.

Storage is a regular on the RHCE practical exam. The work you learned by hand in RHCSA through the LVM series and the NFS/autofs series will be moved into a playbook that produces the same result.

The full picture of storage automation #

If you recall the storage work you used to do by hand, it ran in this order. Create a partition on the disk (parted), take that partition as a PV and build a VG (vgcreate), carve an LV out of the VG (lvcreate), format the LV (mkfs), then create a mount point, register it in /etc/fstab, and actually mount it.

Ansible provides a dedicated module for each of these steps.

Manual workAnsible module
parted partitioncommunity.general.parted
vgcreate (VG)community.general.lvg
lvcreate (LV)community.general.lvol
mkfs formatcommunity.general.filesystem
/etc/fstab + mountansible.posix.mount

The key here is that idempotency is guaranteed differently from module to module. lvg and filesystem simply skip if the resource already exists, but parted and lvol can be flagged as changed on every run, or can re-touch the size, if you pass the wrong options — so they need care.

parted: creating partitions #

community.general.parted creates a partition table and partitions on a disk. For idempotency, it’s safest to specify number (the partition number) along with the start and end positions.

Create a partition
- name: Create one partition on the disk
  community.general.parted:
    device: /dev/vdb
    number: 1
    state: present
    part_start: 1MiB
    part_end: 1024MiB

If you give the same number with the same part_start/part_end, the second run produces no changed. Conversely, giving the end position as a relative value like 100% can cause it to be recalculated on every run depending on the environment, which shakes idempotency — so an explicit size is recommended.

For a partition meant for LVM, you sometimes set the partition flag to LVM.

Create a partition for LVM
- name: Create a partition for LVM
  community.general.parted:
    device: /dev/vdb
    number: 1
    state: present
    part_start: 1MiB
    part_end: 2048MiB
    flags: [ lvm ]

That said, if the exam gives you a disk to use whole as a PV, you can hand /dev/vdb itself to lvg without any partition. If the question’s wording explicitly says “create a partition,” use parted; otherwise, using the whole disk as a PV is simpler.

lvg: creating a VG #

community.general.lvg bundles PVs into a VG. Hand it a disk or partition in pvs and it handles PV creation and VG creation in one shot.

Create a VG
- name: Create a VG
  community.general.lvg:
    vg: data_vg
    pvs: /dev/vdb1
    state: present

If the VG already exists and is built from the same PVs, no changed occurs. To bundle multiple PVs, pass them as a list.

Create a VG from multiple PVs
- name: Create a VG from multiple PVs
  community.general.lvg:
    vg: data_vg
    pvs:
      - /dev/vdb1
      - /dev/vdc1

lvol: creating an LV #

community.general.lvol carves an LV out of a VG. You specify the size with size, and it accepts both absolute sizes (2g, 500m) and relative ratios (50%VG, 100%FREE).

Create an LV
- name: Create an LV
  community.general.lvol:
    vg: data_vg
    lv: data_lv
    size: 1g
    state: present

The point where lvol breaks idempotency most easily is a size change. If you give a different size to an already-created LV, the module may try to shrink it, and shrinking leads to data loss. There are two options that prevent this.

  • shrink: false. Blocks the shrink action and prevents an LV from accidentally getting smaller.
  • resizefs: true. When the LV size changes, it grows the filesystem on top of it too, keeping them consistent.
Manage LV size safely
- name: Manage LV size safely
  community.general.lvol:
    vg: data_vg
    lv: data_lv
    size: 2g
    shrink: false
    resizefs: true

In the exam, “create an LV of N gigabytes” is done once and you’re finished, but if “extend an existing LV” comes up, recall the pattern of growing the filesystem alongside it with resizefs: true.

filesystem: formatting #

community.general.filesystem lays a filesystem onto an LV or partition. You specify fstype and the target device (dev).

Format as xfs
- name: Format the LV as xfs
  community.general.filesystem:
    fstype: xfs
    dev: /dev/data_vg/data_lv

If that filesystem already exists, no changed occurs. To force a re-creation you can pass force: true, but since it wipes existing data, you’ll almost never use it on the exam. The target device path works in either the /dev/<vg>/<lv> or the /dev/mapper/<vg>-<lv> form.

mount: registering in fstab and the live mount #

ansible.posix.mount handles the /etc/fstab entry and the live mount together. The key is the state value.

stateAction
mountedRegister in fstab and mount right now
presentRegister in fstab only (don’t mount now)
unmountedUnmount now (keep the fstab entry)
absentUnmount and remove the fstab entry too

If a persistent mount is the goal, it’s almost always state: mounted. Since it does the fstab registration and the live mount in one shot, the mount survives reboots while also being usable right now.

Mount point and persistent mount
- name: Create the mount point directory
  ansible.builtin.file:
    path: /data
    state: directory
    mode: '0755'

- name: Mount the LV persistently
  ansible.posix.mount:
    path: /data
    src: /dev/data_vg/data_lv
    fstype: xfs
    state: mounted

You can also give src as UUID=... or LABEL=... instead of a device path. To mount safely even if the device name changes, using a UUID is more robust.

Adding swap #

Swap is handled with the same module combination. Create the swap signature with fstype: swap in filesystem, then register it in fstab with the mount module. Since swap isn’t mounted on a directory, it’s common to register it in fstab only with path: none, fstype: swap, state: present and then activate it.

Format swap and add to fstab
- name: Format the swap LV
  community.general.filesystem:
    fstype: swap
    dev: /dev/data_vg/swap_lv

- name: Register the swap in fstab and activate it
  ansible.posix.mount:
    path: none
    src: /dev/data_vg/swap_lv
    fstype: swap
    opts: sw
    state: present

After registering in fstab with state: present, activate swap with command: swapon -a, or leave that step to a storage role that handles swap.

NFS remote mount #

An NFS mount is handled with the same ansible.posix.mount module. The only differences from a local filesystem are that fstype: nfs and src is in the server:exported_path form.

Persistent NFS mount
- name: Mount an NFS export persistently
  ansible.posix.mount:
    path: /mnt/share
    src: nfs-server.example.com:/exports/share
    fstype: nfs
    opts: defaults,_netdev
    state: mounted

Make a habit of putting _netdev in opts so the mount happens after the network is up. If the NFS client package (nfs-utils) is missing, the mount fails, so it’s safe to put a package-install task in front of it.

Install nfs-utils
- name: Install the NFS client package
  ansible.builtin.dnf:
    name: nfs-utils
    state: present

A full LVM playbook example #

Tying together the tasks so far into a single flow gives the following. It’s the standard answer for the exam type that says “build a VG/LV from a disk, format it as xfs, and mount it persistently on /data.”

Full LVM playbook
---
- name: Configure LVM storage
  hosts: storage
  become: true
  tasks:
    - name: Create a VG
      community.general.lvg:
        vg: data_vg
        pvs: /dev/vdb

    - name: Create an LV
      community.general.lvol:
        vg: data_vg
        lv: data_lv
        size: 1g
        shrink: false

    - name: Create the filesystem
      community.general.filesystem:
        fstype: xfs
        dev: /dev/data_vg/data_lv

    - name: Create the mount point
      ansible.builtin.file:
        path: /data
        state: directory
        mode: '0755'

    - name: Mount persistently
      ansible.posix.mount:
        path: /data
        src: /dev/data_vg/data_lv
        fstype: xfs
        state: mounted

Always run this playbook twice and confirm the second run yields zero changed. Since lvg/lvol/filesystem/mount all support idempotency, if you wrote it correctly every task is reported as ok on re-run.

The storage system role alternative #

The official role that bundles the work above in one go is the storage role of rhel-system-roles. It belongs to the system role family covered in #13 system roles, and declares PV, VG, LV, filesystem, and mount with a single variable.

storage role playbook
---
- name: Configure storage with the storage role
  hosts: storage
  become: true
  roles:
    - rhel-system-roles.storage
  vars:
    storage_pools:
      - name: data_vg
        disks:
          - vdb
        volumes:
          - name: data_lv
            size: 1g
            fs_type: xfs
            mount_point: /data

If you declare disks and volumes under storage_pools, the role internally handles VG/LV creation, formatting, fstab registration, and mounting all at once. Swap can also be declared in the same structure with fs_type: swap. Both wiring individual modules yourself and using the storage role are accepted on the exam, so use whichever you are more comfortable with. That said, if the wording specifies a particular module, using that module is the safe choice.

Exam points #

  • The mount module’s state: mounted handles fstab registration and the live mount in one shot. It’s the standard answer for persistent-mount questions. present registers in fstab only, so it misses the immediate mount.
  • parted and lvol break idempotency easily. Specify start and end positions for parted, and block shrinking with shrink: false for lvol.
  • For LV extension, pass resizefs: true to lvol to grow the filesystem along with it.
  • Handle NFS with fstype: nfs, a server:path-form src, and opts: _netdev on ansible.posix.mount, and put an nfs-utils install task in front.
  • Most storage modules belong to the community.general collection, and mount belongs to ansible.posix. Confirm the FQCN and whether the collection is installed.

Wrap-up #

What this post locked in:

  • The step-by-step modules of storage automation. parted (partition) → lvg (VG) → lvol (LV) → filesystem (format) → mount (fstab + mount)
  • Shrink prevention and simultaneous filesystem extension via lvol’s shrink: false and resizefs: true
  • The meaning of the mount module’s state. mounted is the right answer for a persistent mount
  • Swap is handled with filesystem’s fstype: swap and mount’s path: none
  • An NFS remote mount uses fstype: nfs, the _netdev option, and an nfs-utils install
  • The storage role of rhel-system-roles as an alternative to combining individual modules

Next: RHCSA automation 4 #

We’ve automated all the way to storage. The last piece of RHCSA automation is the security area.

In #17 RHCSA automation 4: firewall, SELinux, SSH keys, we’ll handle firewalld with ansible.posix.firewalld, automate SELinux modes and booleans/port labels with ansible.posix.selinux and community.general.sefcontext, and tie SSH public key distribution into the playbook as well.

X