• OrangePi AIpro (20T) is a development board under the Orange Pi brand owned by Xunlong Software and released in 2024. It features 4 CPU cores plus a neural processing unit (NPU) based on Huawei’s Ascend technology capable of running modern AI/ML workloads. The embedded NPU is capable of performing 20 TOPS or trillion operations per second.

    I purchased the OrangePi AIpro (20T) in January 2026 to explore the emerging field of LLM deployment, inferencing and fine-tuning as an infrastructure and platform engineer, as well as exploring the Ascend CANN ecosystem which is swiftly thriving and positioning itself as a strategic alternative to NVIDIA’s CUDA ecosystem in 2026.

    Follow me as I unpack and set up my OrangePi AIpro (20T) development board for exploring AI/ML applications and use cases.

    Unpacking the development kit

    To facilitate the setup process and make my adventure as smooth as possible, I purchased the full set of recommended utilities along with the development board. This includes:

    1. The OrangePi AIpro (20T) development board
    2. A signature Orange Pi protective metal casing
    3. A TF (microSD) card for initial booting of the development board
    4. An eMMC module for lightweight persistent storage
    5. A mini display monitor with HDMI support for accessing the Orange Pi desktop environment
    6. Various miscellaneous tools and utilities
    Development kit

    The packaging of the development board pre-installed within the protective casing as shown below.

    OrangePi AIpro (20T) packaging

    The protective metal casing is simple and elegant with the Orange Pi logo near the center of the top cover, vertically above the cooling fan attached to the Ascend NPU.

    OrangePi AIpro (20T) casing

    Removing the top cover exposes the development board. All the top-facing components such as the cooling fan and CPU processor are visible.

    OrangePi AIpro (20T) board

    Installing the base system

    Officially supported images for the OrangePi AIpro (20T) include the following operating systems.

    1. Orange Pi OS based on OpenHarmony
    2. Ubuntu 22.04 LTS (Jammy) with XFCE desktop
    3. openEuler 22.03 LTS, a Linux-based server operating system from Huawei, now part of the OpenAtom Foundation

    The board was initially set to boot from TF card. While it’s possible to run the Orange Pi entirely from TF card, it’s recommended to use the TF card as an initial boot device to write the OS image to a more durable and permanent storage solution such as the 256G eMMC module I purchased or an NVMe / SATA SSD.

    For this, I purchased a TF card reader compatible with my MateBook Pro laptop and used it to write the initial bootable OS image to the TF card. Unfortunately, I had issues booting the board with the Orange Pi OS image so I wrote the Ubuntu image instead and successfully booted to a login console.

    TF card reader

    With that, I downloaded the Ubuntu image again from my ephemeral TF card environment and wrote it to the eMMC module I purchased and installed manually to the back of the development board. I then powered off the board and flipped the boot order switch at the back of the development board to instruct it to boot from the eMMC module.

    Upon powering on the board a second time, the persistent Ubuntu installation written to the eMMC module was up and running.

    The boot order switch at the back of the development board as shown below. Unlike conventional PCs or servers, there is no boot menu to choose which device and operating system to boot into on each power cycle.

    Boot order switch

    Exploring the Orange Pi: image, included software and issues encountered

    Once I set up my development board via a serial connection to connect to my home Wi-Fi, the SSH service was already pre-enabled so I performed all subsequent tasks via SSH. One minor issue I encountered was that the Orange Pi’s wireless connection encountered frequent stability issues so every once in a while, the SSH connection would hang for a few seconds before resuming.

    TightVNC was also pre-installed in the Ubuntu image but not configured. I configured it and the Orange Pi desktop was accessible through VNC without connecting the display monitor, except the wireless stability issue made it unreliable and impractical for daily use. Fortunately, most tasks can be performed directly via the command line or through a web interface so VNC is not strictly necessary, plus I can use the display monitor directly for tasks requiring graphical operation.

    XFCE desktop

    Included with the Ubuntu image are examples of model training, serving and inferencing optimized for the OrangePi AIpro (20T). The provided examples are made accessible to students and hobbyists in the form of Jupyter notebooks, a common web environment and IDE used by data scientists and AI/ML practitioners alike.

    JupyterLab
    Jupyter terminal
    Jupyter notebook

    The updated collection of examples are available on GitHub: mindspore-lab/orange-pi-mindspore

    Environment setup: hardware and software info

    The CPU processor is ARM-based and its 4 cores support (re-)allocation and configuration as control, data or AI cores based on Ascend technology. The NPU chip is also based on Ascend technology and can be used by AI/ML workloads via the CANN framework to accelerate model training and inference. CANN stands for “Compute Architecture for Neural Networks”, a framework designed for the AI-native era as its name suggests with a rapidly evolving ecosystem as of 2026.

    Unfortunately, running the npu-smi info command to verify the health of my Ascend NPU chip revealed 2 hardware-related errors and my chip was stuck in the “Alarm” state.

    # Display the health of the 1st Ascend NPU chip in the 1st and only NPU
    # processor
    npu-smi info -t health -i 0 -c 0

    The output I got as shown below.

            Health Status                  : Alarm
            Error Code                     : 80E3A203 80F18003
            Error Information              : node type=LPM, sensor type=Chip
            Hardware, event state=The function of obtaining the current is abnormal
                                           : node type=DDRA, sensor type=RAS State,
            event state=internal config error
    

    On the software end, MindSpore version 2.4.10 was pre-installed on the official Ubuntu image but I upgraded it to 2.8.0 which is the latest stable version at the time of writing. Python is pre-installed and managed by Conda.

    MindSpore recommends running the following Python snippet to confirm that the framework is successfully installed. It is a framework for AI/ML applications developed by Huawei as an alternative to PyTorch, featuring first-class integration with the Ascend CANN ecosystem.

    import mindspore
    mindspore.set_context(device_target='Ascend')
    mindspore.run_check()

    The output I got as shown below.

    MindSpore version: 2.8.0
    The result of multiplication calculation is correct, MindSpore has been
    installed on platform [Ascend] successfully!

    View on Asciinema

    Concluding remarks and going further

    Setting up the OrangePi AIpro (20T) for the first time and getting it up to a fully functional system was an educational experience. Unfortunately, due to the hardware errors with the included Ascend NPU chip, I was not able to run the model training and inference tasks successfully from the included sample notebooks.

    Once the hardware issue is sorted out, the OrangePi AIpro (20T) should be an excellent companion in my AI/ML journey as an infrastructure and platform engineer.

    I hope you enjoyed reading my article as much as I did authoring it and stay tuned for updates 😉

    + , , , ,
  • Ray is an open source framework for scaling Python applications through distributed computing. The framework focuses on scaling AI/ML training and inference pipelines horizontally through distributed resource allocation and execution, though it can be used in other Python projects as well.

    Since October 2025, Ray joined the PyTorch Foundation alongside leading open source AI/ML projects such as PyTorch and vLLM, ensuring its sustained development in the AI/ML ecosystem and vendor-neutral governance.

    Follow me as I deploy a distilled variant of DeepSeek-R1 across 2 GPU-enabled cloud servers on Huawei Cloud with vLLM and Ray. Leveraging Ray as the framework for distributed inference enables us to scale horizontally when needed and increase the number of concurrent requests our model can handle compared to a single server.

    Environment setup

    I used Huawei Cloud ECS instances to set up my lab environment. Distributed inferencing with Ray requires NVIDIA GPUs which I do not have physical access to. Fortunately, Huawei Cloud offers a variety of GPU-enabled instance types in Hong Kong powered by NVIDIA Tesla T4 datacenter GPUs.

    1. Jump host: t6.xlarge.4 instance with 4 vCPU, 16Gi memory and 128Gi standard SSD
    2. Ray head node: g6.xlarge.4 instance with 4 vCPU, 16Gi memory, 128Gi standard SSD and 1x NVIDIA T4 GPU
    3. Ray worker node: same specification as Ray head node

    NVIDIA driver, CUDA and container toolkit versions

    The NVIDIA Tesla T4 datacenter GPU has compute capability 7.5 which is supported by vLLM v0.15.1.

    It supports the latest 590.x drivers with CUDA 13.1 as per the official cuDNN backend support matrix. However, the default Ubuntu 22.04 image provided by Huawei Cloud ships with driver version 470.x and CUDA 11.4 which is below the minimum supported CUDA version by vLLM (>= 11.8). Therefore, I removed the pre-installed drivers and replaced them with the latest supported NVIDIA drivers with ubuntu-drivers autoinstall.

    Once the latest drivers were installed, I installed the NVIDIA container toolkit as per official documentation. The latest version is 1.18.2 at the time of writing.

    Once the container toolkit was installed, I configured Docker to use the nvidia runtime and restarted the Docker daemon.

    nvidia-ctk runtime configure --runtime=docker
    systemctl restart docker.service

    View on Asciinema

    Preloading container images and models

    The vllm/vllm-openai:v0.15.1-cu130 container image was preloaded on the jump host with docker pull and exported to the local filesystem with the docker save command. This saved me unnecessary bandwidth pulling the same container image on multiple nodes as the image itself is 18G in size.

    docker pull vllm/vllm-openai:v0.15.1-cu130
    docker save -o vllm-openai.tar vllm/vllm-openai:v0.15.1-cu130

    The image was then copied to each Ray node and imported with docker load.

    docker load -i vllm-openai.tar

    Furthermore, to avoid downloading the deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B used in this lab on each individual node, I created an NFS share on the jump host mounted on each node as /mnt/huggingface and pointed the hf CLI to use the shared directory for caching via the HF_HOME environment variable.

    The model was downloaded with hf download as below.

    hf download deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B

    View on Asciinema

    Serving DeepSeek-R1 with vLLM from a single node

    Let’s serve a distilled variant of DeepSeek-R1 with vLLM as with last time. The main difference is that we will be using our NVIDIA GPU this time.

    The model deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B is available on Hugging Face.

    # Configure an API key for authentication
    # Feel free to modify this parameter
    export OPENAI_API_KEY="my-very-secure-api-key"
    # Run our distilled DeepSeek-R1 with vLLM
    docker run --name vllm-openai \
    --rm \
    -d \
    -p 8000:8000 \
    --runtime nvidia \
    --gpus all \
    --ipc host \
    -e LD_LIBRARY_PATH='/usr/local/nvidia/lib64:/usr/local/nvidia/lib:/usr/lib/x86_64-linux-gnu' \
    -v /mnt/huggingface:/root/.cache/huggingface \
    vllm/vllm-openai:v0.15.1-cu130 \
    deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B \
    --api-key="$OPENAI_API_KEY" \
    --reasoning-parser=deepseek_r1 \
    --gpu-memory-utilization=0.85

    The updated parameters explained below.

    1. --runtime nvidia: use the NVIDIA runtime provided by the container toolkit
    2. --gpus all: use all GPUs available on the node
    3. -e LD_LIBRARY_PATH='...': hardcode the CUDA library path to work around an issue in vLLM v0.15.1: vllm-project/vllm#33369
    4. -v /mnt/huggingface:/root/.cache/huggingface: mount the shared model cache to avoid nodes re-downloading the model on every run
    5. --reasoning-parser=deepseek_r1: parse DeepSeek-R1’s reasoning flow and separate it from the final content in inference responses
    6. --gpu-memory-utilization=0.85: use at most 85% of the available GPU memory to avoid running out of GPU memory and being killed. The NVIDIA Tesla T4 has 16Gi of GPU memory

    Wait for vLLM to start up and become ready.

    i=0
    docker logs vllm-openai 2>&1 | \
    grep "Application startup complete." > /dev/null
    while [ "$?" -ne 0 ]; do
    echo "Waiting $i seconds for vLLM to become ready ..."
    i=$((i + 1))
    sleep 1
    docker logs vllm-openai 2>&1 | \
    grep "Application startup complete." > /dev/null
    done
    echo "vLLM is ready after $i seconds."

    List the available models.

    curl -s -H "Authorization: Bearer $OPENAI_API_KEY" \
    http://localhost:8000/v1/models | jq

    Sample output:

    {
    "object": "list",
    "data": [
    {
    "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
    "object": "model",
    "created": 1771309159,
    "owned_by": "vllm",
    "root": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
    "parent": null,
    "max_model_len": 131072,
    "permission": [
    {
    "id": "modelperm-946de3cf56f5f72b",
    "object": "model_permission",
    "created": 1771309159,
    "allow_create_engine": false,
    "allow_sampling": true,
    "allow_logprobs": true,
    "allow_search_indices": false,
    "allow_view": true,
    "allow_fine_tuning": false,
    "organization": "*",
    "group": null,
    "is_blocking": false
    }
    ]
    }
    ]
    }

    Prepare the request body request.json and send an inference request. Save and inspect the response in response.json.

    {
    "model": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
    "messages": [
    {
    "role": "user",
    "content": "Explain DeepSeek-R1 to a non-technical audience in 100-150 words."
    }
    ],
    "max_tokens": 2048,
    "temperature": 0.6
    }
    # Send the inference request and save the response in response.json
    curl -s -XPOST \
    -H "Authorization: Bearer $OPENAI_API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(cat request.json)" \
    http://localhost:8000/v1/chat/completions \
    > response.json
    # Inspect the final response
    cat response.json | jq --raw-output '.choices[0].message.content'

    Sample output, formatted below as a quote for clarity:

    DeepSeek-R1 is an advanced AI tool developed by DeepSeek, designed to understand and generate text with high accuracy and efficiency. It uses powerful language models to solve complex problems, adapt to various scenarios, and provide meaningful insights. Whether used in healthcare, finance, or education, DeepSeek-R1 excels in understanding text, generating responses, and solving intricate tasks, making it a versatile and reliable tool for many applications.

    All good – our model can respond with a quick introduction of itself. How many concurrent requests can it handle with the current setup?

    docker logs vllm-openai 2> /dev/null | grep "Maximum concurrency"

    Sample output:

    (EngineCore_DP0 pid=70) INFO 02-16 22:18:33 [kv_cache_utils.py:1312] Maximum concurrency for 131,072 tokens per request: 2.17x

    Unfortunately it can only handle a maximum of 2 concurrent requests safely. Let’s enable it to handle more concurrent requests by distributing the inference workload across multiple nodes.

    View on Asciinema

    Deploying Ray for distributed inferencing

    Reference: Parallelism and Scaling – vLLM

    Ray allows us to distribute our inference workload horizontally across multiple GPU-enabled servers. This allows us to deploy larger models than would fit in a single server, increase the maximum number of concurrent requests our model can handle, or both.

    Ray concepts

    Reference: Key Concepts – Ray

    A Ray cluster consists of 2 types of nodes:

    1. Head node: responsible for management tasks such as job scheduling and autoscaling
    2. Worker node: responsible for executing submitted Ray jobs

    By default, head nodes can execute Ray jobs as well. For large production deployments, the default behavior may not be desirable. Configuring head nodes as per recommended best practice is outside the scope of this article.

    Setting up the second node

    A second GPU-enabled node ray-demo-node1 was provisioned with identical specifications to the first node ray-demo-node0 and configured identically.

    1. Docker Engine and jq installed
    2. Upgraded to the latest NVIDIA 590.x drivers and CUDA 13.1 with ubuntu-drivers autoinstall
    3. NVIDIA Container Toolkit v1.18.2 installed and Docker configured to use the NVIDIA runtime
    4. The vLLM container image preloaded and shared Hugging face model cache mounted to save bandwidth

    For our setup, we’ll use:

    1. ray-demo-node0 for our head node
    2. ray-demo-node1 for our worker node

    Starting the head node

    The vLLM container image includes Ray by default but we need to override the entrypoint to run ray start instead of vllm serve.

    Here’s the Ray command we will be running inside the container on the head node.

    ray start --block \
    --head \
    --node-ip-address=$HEAD_NODE_IP \
    --port=6379

    HEAD_NODE_IP is the IP address of the head node.

    1. --block: do not exit after Ray is initialized
    2. --head: designate the current node as the head node
    3. --node-ip-address=x.x.x.x: the IP address of the current node
    4. --port=6379: the Ray head node binds to port 6379/tcp by default

    The corresponding docker run command below.

    # Configurable parameters
    # Feel free to adapt to your environment and requirements
    export OPENAI_API_KEY="my-very-secure-api-key"
    export HEAD_NODE_IP="10.0.0.86"
    export WORKER_NODE_IP="10.0.0.62"
    # Start the Ray head node
    docker run --name vllm-openai-ray-head \
    --rm \
    -d \
    --entrypoint /bin/bash \
    --network host \
    --runtime nvidia \
    --gpus all \
    --ipc host \
    --shm-size 16G \
    -e LD_LIBRARY_PATH='/usr/local/nvidia/lib64:/usr/local/nvidia/lib:/usr/lib/x86_64-linux-gnu' \
    -e GLOO_SOCKET_IFNAME=eth0 \
    -e NCCL_SOCKET_IFNAME=eth0 \
    -e OPENAI_API_KEY="$OPENAI_API_KEY" \
    -v /mnt/huggingface:/root/.cache/huggingface \
    -v /dev/shm:/dev/shm \
    vllm/vllm-openai:v0.15.1-cu130 \
    -c \
    "ray start --block \
    --head \
    --node-ip-address=$HEAD_NODE_IP \
    --port=6379"

    Some notable differences from previous docker run commands:

    1. --entrypoint /bin/bash: override the entrypoint to run ray start
    2. --shm-size 16G: set the size of shared memory to 16G
    3. GLOO_SOCKET_IFNAME=eth0: instruct Ray to use the correct network interface to avoid “connection refused” errors
    4. NCCL_SOCKET_IFNAME=eth0: similar to above
    5. OPENAI_API_KEY=xxxx: we’ll shell into the container to run vllm serve once our Ray cluster is ready
    6. -v /dev/shm:/dev/shm: mount the host’s shared memory to the container

    Confirm that our head node is running properly.

    docker logs vllm-openai-ray-head

    Sample output:

    2026-02-17 03:46:51,232 INFO usage_lib.py:473 -- Usage stats collection is enabled by default without user confirmation because this terminal is detected to be non-interactive. To disable this, add `--disable-usage-stats` to the command that starts the cluster, or run the following command: `ray disable-usage-stats` before starting the cluster. See https://docs.ray.io/en/master/cluster/usage-stats.html for more details.
    2026-02-17 03:46:51,236 INFO scripts.py:917 -- Local node IP: 10.0.0.86
    2026-02-17 03:46:54,696 SUCC scripts.py:956 -- --------------------
    2026-02-17 03:46:54,696 SUCC scripts.py:957 -- Ray runtime started.
    2026-02-17 03:46:54,696 SUCC scripts.py:958 -- --------------------
    2026-02-17 03:46:54,696 INFO scripts.py:960 -- Next steps
    2026-02-17 03:46:54,696 INFO scripts.py:963 -- To add another node to this Ray cluster, run
    2026-02-17 03:46:54,696 INFO scripts.py:966 -- ray start --address='10.0.0.86:6379'
    2026-02-17 03:46:54,696 INFO scripts.py:975 -- To connect to this Ray cluster:
    2026-02-17 03:46:54,696 INFO scripts.py:977 -- import ray
    2026-02-17 03:46:54,696 INFO scripts.py:978 -- ray.init(_node_ip_address='10.0.0.86')
    2026-02-17 03:46:54,696 INFO scripts.py:1009 -- To terminate the Ray runtime, run
    2026-02-17 03:46:54,696 INFO scripts.py:1010 -- ray stop
    2026-02-17 03:46:54,696 INFO scripts.py:1013 -- To view the status of the cluster, use
    2026-02-17 03:46:54,696 INFO scripts.py:1014 -- ray status
    2026-02-17 03:46:54,697 INFO scripts.py:1132 -- --block
    2026-02-17 03:46:54,697 INFO scripts.py:1133 -- This command will now block forever until terminated by a signal.
    2026-02-17 03:46:54,697 INFO scripts.py:1136 -- Running subprocesses are monitored and a message will be printed if any of them terminate unexpectedly. Subprocesses exit with SIGTERM will be treated as graceful, thus NOT reported.
    2026-02-17 03:46:54,697 INFO scripts.py:1141 -- Process exit logs will be saved to: /tmp/ray/session_2026-02-17_03-46-51_241973_1/logs/ray_process_exit.log

    Our Ray head node is initialized and ready.

    View on Asciinema

    Starting the worker node

    The ray start command for the worker node is similar with a few differences.

    ray start --block \
    --address=$HEAD_NODE_IP:6379 \
    --node-ip-address=$WORKER_NODE_IP

    The --address=x.x.x.x:6379 option instructs our worker node to connect to the head node at port 6379/tcp.

    The actual docker run command as below.

    # Configurable parameters
    # Feel free to adapt to your environment and requirements
    export OPENAI_API_KEY="my-very-secure-api-key"
    export HEAD_NODE_IP="10.0.0.86"
    export WORKER_NODE_IP="10.0.0.62"
    # Start the Ray worker node
    docker run --name vllm-openai-ray-worker \
    --rm \
    -d \
    --entrypoint /bin/bash \
    --network host \
    --runtime nvidia \
    --gpus all \
    --ipc host \
    --shm-size 16G \
    -e LD_LIBRARY_PATH='/usr/local/nvidia/lib64:/usr/local/nvidia/lib:/usr/lib/x86_64-linux-gnu' \
    -e GLOO_SOCKET_IFNAME=eth0 \
    -e NCCL_SOCKET_IFNAME=eth0 \
    -v /mnt/huggingface:/root/.cache/huggingface \
    -v /dev/shm:/dev/shm \
    vllm/vllm-openai:v0.15.1-cu130 \
    -c \
    "ray start --block \
    --address=$HEAD_NODE_IP:6379 \
    --node-ip-address=$WORKER_NODE_IP"

    Check the logs to confirm our worker node is up and ready.

    docker logs vllm-openai-ray-worker

    Sample output:

    2026-02-17 04:01:04,485 INFO scripts.py:1101 -- Local node IP: 10.0.0.62
    2026-02-17 04:01:05,770 SUCC scripts.py:1117 -- --------------------
    2026-02-17 04:01:05,770 SUCC scripts.py:1118 -- Ray runtime started.
    2026-02-17 04:01:05,770 SUCC scripts.py:1119 -- --------------------
    2026-02-17 04:01:05,770 INFO scripts.py:1121 -- To terminate the Ray runtime, run
    2026-02-17 04:01:05,770 INFO scripts.py:1122 -- ray stop
    2026-02-17 04:01:05,770 INFO scripts.py:1132 -- --block
    2026-02-17 04:01:05,770 INFO scripts.py:1133 -- This command will now block forever until terminated by a signal.
    2026-02-17 04:01:05,770 INFO scripts.py:1136 -- Running subprocesses are monitored and a message will be printed if any of them terminate unexpectedly. Subprocesses exit with SIGTERM will be treated as graceful, thus NOT reported.
    2026-02-17 04:01:05,770 INFO scripts.py:1141 -- Process exit logs will be saved to: /tmp/ray/session_2026-02-17_03-46-51_241973_1/logs/ray_process_exit.log

    View on Asciinema

    Inspecting the Ray cluster

    Let’s inspect the Ray cluster with ray status from the head node.

    docker exec vllm-openai-ray-head \
    ray status

    Sample output:

    ======== Autoscaler status: 2026-02-17 04:45:31.532694 ========
    Node status
    ---------------------------------------------------------------
    Active:
    1 node_3d9e4088156b8f9efed00986478580e36580f363b71326bc41dd1a27
    1 node_9e8a3eb4bce2890748ebfc0cfc020ffc515925eb9e05e1be0b479e26
    Pending:
    (no pending nodes)
    Recent failures:
    (no failures)
    Resources
    ---------------------------------------------------------------
    Total Usage:
    0.0/8.0 CPU
    0.0/2.0 GPU
    0B/21.30GiB memory
    0B/9.13GiB object_store_memory
    From request_resources:
    (none)
    Pending Demands:
    (no resource demands)

    The output shows that we have 2 nodes for a total of 8 vCPU, 2 GPUs and approximately 32Gi of GPU memory. Excellent!

    View on Asciinema

    Serving DeepSeek-R1 for distributed inferencing

    Open a shell in the head node container.

    docker exec -it vllm-openai-ray-head /bin/bash

    Now run the vllm serve command below.

    vllm serve deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B \
    --api-key="$OPENAI_API_KEY" \
    --reasoning-parser=deepseek_r1 \
    --gpu-memory-utilization=0.85 \
    --distributed-executor-backend=ray \
    --tensor-parallel-size=1 \
    --pipeline-parallel-size=2

    A few additional options were included.

    1. --distributed-executor-backend=ray: instruct vLLM to use Ray as its distributed backend
    2. --tensor-parallel-size=1: each node has 1 GPU
    3. --pipeline-parallel-size=2: our Ray cluster has 2 nodes

    Confirm that the maximum concurrency is doubled from 2 to approximately 5. Here’s what you should see in the logs.

    (EngineCore_DP0 pid=346) INFO 02-17 05:16:01 [kv_cache_utils.py:1307] GPU KV cache size: 694,480 tokens
    (EngineCore_DP0 pid=346) INFO 02-17 05:16:01 [kv_cache_utils.py:1312] Maximum concurrency for 131,072 tokens per request: 5.30x

    Keep the terminal tab or window open. In a new, separate terminal tab, let’s verify that our model is functioning correctly.

    Run ray status again on the head node to confirm both GPUs are utiilized.

    docker exec vllm-openai-ray-head \
    ray status

    Sample output:

    ======== Autoscaler status: 2026-02-17 05:17:23.186492 ========
    Node status
    ---------------------------------------------------------------
    Active:
    1 node_9e8a3eb4bce2890748ebfc0cfc020ffc515925eb9e05e1be0b479e26
    1 node_3d9e4088156b8f9efed00986478580e36580f363b71326bc41dd1a27
    Pending:
    (no pending nodes)
    Recent failures:
    (no failures)
    Resources
    ---------------------------------------------------------------
    Total Usage:
    0.0/8.0 CPU
    2.0/2.0 GPU (2.0 used of 2.0 reserved in placement groups)
    0B/21.30GiB memory
    0B/9.13GiB object_store_memory
    From request_resources:
    (none)
    Pending Demands:
    (no resource demands)

    The line 2.0/2.0 GPU confirms both GPUs across both nodes are utilized.

    Prepare the same request.json.

    {
    "model": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
    "messages": [
    {
    "role": "user",
    "content": "Explain DeepSeek-R1 to a non-technical audience in 100-150 words."
    }
    ],
    "max_tokens": 2048,
    "temperature": 0.6
    }

    Invoke the Completions API once more and wait for our model to respond. Save the response to response.json. Inspect the response.

    # Send the inference request and save the response in response.json
    curl -s -XPOST \
    -H "Authorization: Bearer $OPENAI_API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(cat request.json)" \
    http://localhost:8000/v1/chat/completions \
    > response.json
    # Inspect the final response
    cat response.json | jq --raw-output '.choices[0].message.content'

    DeepSeek-R1 is an advanced AI tool developed by DeepSeek, designed to understand and generate text with high accuracy and efficiency. It uses powerful language models to solve complex problems, adapt to various scenarios, and provide meaningful insights. Whether used in healthcare, finance, or education, DeepSeek-R1 excels in understanding text, generating responses, and solving intricate tasks, making it a versatile and reliable tool for many applications.

    Congratulations, you have successfully deployed a distilled variant of DeepSeek-R1 to a Ray cluster of 2 nodes and confirmed that the model correctly utilizes the GPU resources from both nodes!

    View all in Asciinema12

    Concluding remarks and going further

    This article demonstrates how Ray can be used to deploy an LLM for distributed inferencing across 2 or more GPU-enabled servers. However, the setup presented in this article is far from production-ready and there is much more to be done to unlock true performance, scalability and high availability.

    Below are some recommended next steps towards a production-ready Ray deployment for enterprise-grade distributed inferencing.

    1. Deploy Ray to Kubernetes with KubeRay and automatically benefit from its orchestration capabilities
    2. Use Ray as a backend for your MLOps training and inference pipelines with Kubeflow Pipelines

    I hope you enjoyed this article as much as I did authoring it and stay tuned for updates 😉

    + , ,
  • vLLM is a model serving runtime supporting the most popular families of large language models (LLMs) such as DeepSeek and Llama. It exposes an OpenAI-compatible server implementing common OpenAI REST API endpoints such as the Chat, Completions and Responses API for text generation and inference, allowing existing clients and tools leveraging the OpenAI API to be reused simply by customizing the server URL. One of the main innovations of vLLM at the time it was initially released to the public is its paged attention mechanism addressing the critical bottleneck of key-value (KV) cache memory through its non-contiguous cache implementation, enabling significant efficiency and throughput gains in tasks such as text generation and inference.

    vLLM is an incubation project under LF AI & Data since October 2024 ensuring vendor-neutral governance.

    Follow me as I deploy my first locally hosted distilled variant of the DeepSeek-R1 model on a commodity Redmi Book 14 2024 laptop with vLLM in this exploratory lab. DeepSeek-R1 is a series of models trained via reinforcement learning available on Hugging Face.

    Hardware specifications and limitations

    The Redmi Book 14 2024 features a 13th Gen Intel Core i7 processor with 16 CPU cores and 16 GiB of system memory. Furthermore, to isolate the lab from my laptop environment, I performed the steps described in this article in an Ubuntu 24.04 (Noble) VM with 6 vCPU cores, 12 GiB of system memory and 32 GiB for the OS disk, using CPU host passthrough to expose the laptop’s native CPU capabilities directly to the underlying VM.

    Unfortunately, my CPU does not support the AVX-512 vector extensions required by the pre-compiled vLLM CPU Python wheel and Docker images so compiling and building a custom vLLM image from source is the only option.

    View on Asciinema

    Setting up the environment

    The following packages were installed and the administrative user added to the docker Unix group to allow running docker commands directly without sudo.

    1. docker.ioDocker engine and container runtime
    2. docker-buildx: the modern, default BuildKit build system for Docker images

    Compiling and building a custom image with AVX-512 disabled

    Reference: CPU – vLLM

    Clone the vLLM GitHub repository and make it your working directory. I used the v0.15.1 tag which is the latest stable release at the time of writing.

    git clone -b v0.15.1 https://github.com/vllm-project/vllm.git
    pushd vllm/

    Use the provided docker/Dockerfile.cpu Dockerfile and the vllm-openai build target to build the Docker image. The following build arguments were specified.

    1. VLLM_CPU_DISABLE_AVX512=true: disable AVX-512 vector extensions
    2. max_jobs=6: reduce the # of concurrent jobs to 6 (default: 32) to prevent running out of memory and being OOM killed
    # Specify your image repository, namespace, name and tag
    # Feel free to change these values
    IMAGE_REPOSITORY='quay.io'
    IMAGE_NAMESPACE='donaldsebleung'
    IMAGE_NAME='vllm-cpu-release-repo'
    IMAGE_TAG='20260214-disable-avx512'
    export IMAGE="$IMAGE_REPOSITORY/$IMAGE_NAMESPACE/$IMAGE_NAME:$IMAGE_TAG"
    # Build our custom vLLM CPU image with AVX-512 disabled
    docker build -f docker/Dockerfile.cpu \
    --build-arg VLLM_CPU_DISABLE_AVX512=true \
    --build-arg max_jobs=6 \
    --target vllm-openai \
    -t "$IMAGE" \
    .

    Push it to your image registry (optional).

    docker push "$IMAGE"

    Return to your home directory.

    popd

    View on Asciinema

    Serving DeepSeek-R1 with our custom image

    Reference: vllm serve – vLLM

    By default, our custom image runs vllm serve $ARGS where $ARGS is the command-line arguments passed to docker run. Verify this with docker inspect.

    docker inspect "$IMAGE" | \
    jq --raw-output '.[0].Config.Entrypoint | join(" ")'

    Sample output:

    vllm serve

    Let’s deploy the deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B model from Hugging Face with vLLM. It is a model distilled from DeepSeek-R1 based on Qwen with just 1.5 billion (B) parameters, which should run at a reasonable speed on our Intel CPU without GPU / NPU acceleration for demonstration purposes only.

    The vLLM command looks like this.

    vllm serve deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B \
    --dtype=bfloat16 \
    --api-key=my-very-secure-api-key

    Here’s how it translates to docker run.

    # Set an API key for authentication
    # Feel free to change these values
    export OPENAI_API_KEY="my-very-secure-api-key"
    # Serve the model with our custom image
    docker run --rm \
    --name vllm-openai \
    -d \
    -p 8000:8000 \
    --security-opt seccomp=unconfined \
    --cap-add SYS_NICE \
    --shm-size=4g \
    -e VLLM_CPU_KVCACHE_SPACE=4 \
    "$IMAGE" \
    deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B \
    --dtype=bfloat16 \
    --api-key="$OPENAI_API_KEY"

    Here’s a breakdown of the key options used.

    1. --security-opt seccomp=unconfined: disable the default Seccomp confinement for containers to allow NUMA-related syscalls such as migrate_pages. Not ideal from a security perspective but sufficient for demo purposes
    2. --cap-add SYS_NICE: add the SYS_NICE capability to the container recommended by vLLM for improved performance
    3. --shm-size=4g: size of /dev/shm in GiB
    4. -e VLLM_CPU_KVCACHE_SPACE=4: set the KV cache size to 4Gi explicitly (the default)
    5. --dtype=bfloat16: passed to vllm serve to improve accuracy and performance
    6. --api-key: specifies the OpenAI API key to authenticate inference requests

    Wait a few minutes for vLLM to boot and load our model. Optionally follow the logs with docker logs -f vllm-openai.

    i=0
    docker logs vllm-openai 2>&1 | \
    grep "Application startup complete." > /dev/null
    while [ "$?" -ne 0 ]; do
    echo "Waiting $i seconds for vLLM to become ready ..."
    i=$((i + 1))
    sleep 1
    docker logs vllm-openai 2>&1 | \
    grep "Application startup complete." > /dev/null
    done
    echo "vLLM is ready after $i seconds."

    Let’s explore the most common vLLM API endpoints. It’s mostly compatible with the OpenAI API with few differences.

    View on Asciinema

    Exploring vLLM common API endpoints

    Reference: Logging Configuration – vLLM

    GET /health: health checks

    The GET /health endpoint checks whether vLLM is up and running.

    curl -is http://localhost:8000/health

    Sample output:

    HTTP/1.1 200 OK
    date: Sat, 14 Feb 2026 09:10:57 GMT
    server: uvicorn
    content-length: 0

    GET /metrics: Prometheus metrics

    The GET /metrics endpoint exposes vLLM-related metrics in Prometheus format. Configure your monitoring stack to scrape this endpoint for monitoring, alerts and visualization.

    curl -is http://localhost:8000/metrics | head

    Sample output:

    HTTP/1.1 200 OK
    date: Sat, 14 Feb 2026 09:22:18 GMT
    server: uvicorn
    content-length: 55977
    content-type: text/plain; version=1.0.0; charset=utf-8
    # HELP python_gc_objects_collected_total Objects collected during gc
    # TYPE python_gc_objects_collected_total counter
    python_gc_objects_collected_total{generation="0"} 12629.0
    python_gc_objects_collected_total{generation="1"} 1879.0

    GET /load: server load metrics

    The GET /load endpoint reports the server load on the vLLM instance.

    curl -s http://localhost:8000/load | jq

    Sample output:

    {
    "server_load": 0
    }

    GET /v1/models: list available models

    The GET /v1/models endpoint is OpenAI-compatible and lists the available models for inference.

    This endpoint requires bearer authentication with the Authorization header if --api-key / VLLM_API_KEY is specified with vllm serve.

    curl -s -H "Authorization: Bearer $OPENAI_API_KEY" \
    http://localhost:8000/v1/models | jq

    Sample output:

    {
    "object": "list",
    "data": [
    {
    "id": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
    "object": "model",
    "created": 1771062179,
    "owned_by": "vllm",
    "root": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
    "parent": null,
    "max_model_len": 131072,
    "permission": [
    {
    "id": "modelperm-97eb0d294b2de221",
    "object": "model_permission",
    "created": 1771062179,
    "allow_create_engine": false,
    "allow_sampling": true,
    "allow_logprobs": true,
    "allow_search_indices": false,
    "allow_view": true,
    "allow_fine_tuning": false,
    "organization": "*",
    "group": null,
    "is_blocking": false
    }
    ]
    }
    ]
    }

    Completions API: send a prompt to vLLM for inference

    The Completions API is part of the OpenAI API specification. It is fully supported by vLLM.

    Let’s send an inference request to vLLM using the Completions API in the next section and watch DeepSeek-R1 respond to our prompt in real time!

    View in Asciinema

    Sending an inference request to vLLM and seeing it all in action

    Reference: OpenAI-Compatible Server – vLLM

    Let’s ask DeepSeek-R1 to introduce itself in 100-150 words.

    Explain DeepSeek-R1 to a non-technical audience in 100-150 words.

    The Completions API exposes the endpoint POST /v1/chat/completions which accepts a JSON payload containing the following fields.

    1. model: the ID of the model returned in GET /v1/models
    2. messages: a list of messages in role / content format with the content key containing the actual prompt. role can be either system or user for system and user prompts, respectively
    3. max_tokens: specifies the maximum number of tokens the model should return
    4. temperature: a tunable parameter to optimize the response from the model. DeepSeek-R1 recommends setting it between 0.5 and 0.7, with 0.6 being the optimal temperature

    Let’s prepare a request.json with our request payload.

    {
    "model": "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B",
    "messages": [
    {
    "role": "user",
    "content": "Explain DeepSeek-R1 to a non-technical audience in 100-150 words."
    }
    ],
    "max_tokens": 1024,
    "temperature": 0.6
    }

    Send the inference request and wait for the response. Since we’re running on an Intel CPU processor directly with no GPU / NPU acceleration, wait a minute or two for the model to respond to our prompt.

    Save the response in a separate file response.json.

    curl -s -XPOST \
    -H "Authorization: Bearer $OPENAI_API_KEY" \
    -H "Content-Type: application/json" \
    -d "$(cat request.json)" \
    http://localhost:8000/v1/chat/completions \
    > response.json

    Let’s extract the raw text from the response payload. Take the last line as the previous lines ending with </think> are part of DeepSeek-R1’s reasoning flow.

    cat response.json | \
    jq --raw-output '.choices[0].message.content' | \
    tail -1

    Sample output, formatted as a quote below for clarity.

    DeepSeek-R1 is an advanced AI developed by DeepSeek, known for its powerful capabilities in solving complex problems. It excels at handling technical queries, answering questions, and solving math problems with accuracy. Unlike traditional tools, DeepSeek-R1 not only provides answers but also explains the reasoning behind them, offering a deeper understanding. Its versatility extends beyond mathematics, capable of answering general knowledge, solving technical issues, and providing creative insights. The user-friendly interface makes it accessible to a broad audience, from students to professionals. DeepSeek-R1 is reliable, giving consistent, accurate answers, making it a trusted ally for problem-solving.

    Not bad! DeepSeek-R1 was able to satisfy our request and give a short, succint overview of itself in natural language.

    View on Asciinema

    Concluding remarks and going further

    This article covers only the tip of the iceberg in terms of local model serving with vLLM. Some interesting next steps would be to:

    1. Connect your locally hosted model with an MCP server to power your agentic workflows
    2. Connect it with OpenClaw hosted locally on the same or a different server to boost your software development workflow with ChatOps
    3. Deploy and serve your model on Kubernetes with a tried and tested model serving platform such as KServe

    I hope you enjoyed this article as much as I did writing it and stay tuned for updates 😉

    + ,
  • Back in mid-2025, I stumbled upon the MateBook Pro in a Huawei flagship store in Shenzhen. The laptop caught my attention since it came pre-installed with Huawei’s very own HarmonyOS 5 operating system, otherwise known as HarmonyOS NEXT.

    Unlike previous HarmonyOS releases such as HarmonyOS 4 which were based on Android, specifically the Android Open Source Project (AOSP), HarmonyOS NEXT features a fully independent software stack ranging from Huawei’s proprietary HongMeng kernel, to the Kernel Abstraction Layer (KAL) and userspace based on the OpenHarmony project and HarmonyOS-native applications. It’s not common to find a laptop or PC in the wild which is pre-loaded with an operating system distinct from Windows or macOS so I decided to purchase it and give it a try.

    I purchased the MateBook Pro with the highest form factor of 32G RAM and 2T of storage. The laptop came with an extravagant red casing.

    MateBook Pro with red casing
    MateBook Pro appearance

    At the time of purchase, HarmonyOS NEXT came with software for everyday usage and lightweight business apps such as WPS Office, a Chinese alternative to the Microsoft Office suite, as well as virtualization software such as OSEasy which allowed running Windows 10 alongside HarmonyOS, similar to Parallels Desktop on macOS. Unfortunately, it was lacking in developer tooling and ecosystem with no proper IDE and a locked down terminal application called HiShell with limited functionality and no path to elevated access such as “Run as Administrator” or sudo.

    By the end of 2025, the next major version of HarmonyOS known as HarmonyOS 6 was out and generally available. I updated my MateBook Pro to HarmonyOS 6 and was pleasantly surprised to see the addition of developer tooling in the Huawei AppGallery such as the native CodeArts IDE software as well as various officially supported applications by recognized third-party developers and noteworthy usability improvements in the built in HiShell terminal application. AppGallery is the officially approved and vetted app store for HarmonyOS, similar to the Apple App Store on macOS.

    This blog post introduces a subset of the developer tooling and ecosystem present in HarmonyOS 6 which I found interesting or useful, and various aspects where I think it can be further improved.

    Super Privacy Mode – hardware-native privacy controls

    If there’s one thing privacy-conscious users are looking for in the MateBook Pro, it’s the Super Privacy Mode built straight into the laptop as a hardware kill switch. Enabling and disabling Super Privacy Mode is as simple as flipping the kill switch to the right which toggles the ability for applications to access your laptop camera, microphone and geo-location information.

    MateBook Pro - Super Privacy Mode

    That’s right – no application, system or kernel-level controls to disable these peripheral devices in software. Using a pure hardware-based approach ensures that attackers can’t steal your location, voice or video feed even if they manage to breach your operating system as long as the kill switch is active.

    Celia – OS-native AI assistant

    HarmonyOS 6 includes a built-in AI assistant Celia which is deeply integrated with the OS itself. As you might expect in 2026, Celia is LLM-based and can produce coherent, practical answers to a range of enquiries, including developer use cases such as code generation. Furthermore, the “Deep Thinking” feature can be optionally enabled per prompt to produce a more detailed and accurate response at the expense of a few additional seconds in response time.

    Below is an example of Celia generating an Argo CD Application template based on user enquiry with Deep Thinking enabled, a common use case for Kubernetes developers and administrators.

    MateBook Pro - Celia user enquiry with deep thinking
    MateBook Pro - Celia response with code generation

    A noteworthy differentiator of Celia against SaaS-based LLMs such as Microsoft CopilotGemini or Grok is the ability to work offline in the absence of a functional Internet connection. For privacy-oriented users, an option is also available to default to the locally-hosted AI model under “Settings” even within a connected environment. This ensures that user prompts containing sensitive information are not forwarded unconditionally to the SaaS-based LLM service.

    MateBook Pro - Celia: force usage of local AI model

    CodeArts – Official IDE for HarmonyOS from Huawei

    CodeArts is the official IDE developed by Huawei. It was originally available for supported Windows and macOS versions only but was recently introduced in HarmonyOS 6 as well.

    The welcome page shows that there are currently 4 mainstream programming languages that are supported by CodeArts:

    1. Java
    2. Python
    3. C/C++
    4. JavaScript
    MateBook Pro - CodeArts welcome page

    CodeArts supports Git-based workflows as well, creating new Git repositories and importing existing Git projects.

    Creating a new project is easy. Creating a new Python project displays a menu dialog including options following Python recommended best practices such as creating a dedicated virtual environment for the project and initializing a unit testing framework such as pytest.

    MateBook Pro - CodeArts new Python project

    The newly created projects includes a minimal example of working code and allows setting breakpoints for debugging as well as normal program execution right within the IDE environment.

    MateBook Pro - CodeArts Python breakpoint debugging
    MateBook Pro - CodeArts Python program execution

    HiShell – System-native terminal application

    HarmonyOS includes a native terminal application known as HiShell. HarmonyOS 6 introduces usability improvements to HiShell such as a basic command-line user guide, the ability to elevate privileges with sudo and the introduction of additional command-line utilities for administration tasks and troubleshooting.

    MateBook Pro - HiShell system information

    Expanding the dropdown menu next to the “+” new tab button reveals 2 options:

    1. HiShell: system-native terminal
    2. openEuler (not installed): Linux terminal, perhaps similar to WSL2 on Windows?
    MateBook Pro - HiShell terminal options

    Unfortunately, I could not find how to install openEuler to enable the Linux terminal feature. Perhaps the openEuler terminal is a work in progress and hopefully it will provide feature parity with WSL2 on Windows once it is generally available.

    HiSH – Full-fledged Linux emulator for developers and enthusiasts

    In search for a comparable alternative to WSL2 for HarmonyOS, I stumbled upon an open source project created by a HarmonyOS enthusiast called harmoninux/HiSH. HiSH uses QEMU system emulation to emulate a complete Linux-based operating system with a specified kernel and rootfs image.

    Previously, HiSH was only available on GitHub and had to be compiled from source. Fortunately, it was officially approved and landed on AppGallery recently so installing it on my MateBook Pro is a single click away.

    MateBook Pro - HiSH in AppGallery

    The version of HiSH from AppGallery is 1.0.10 at the time of writing.

    MateBook Pro - HiSH version info

    The default emulator runs Alpine Linux with 1 vCPU, 512 MiB memory and a single 128 GiB system disk. The resource limits can be optionally adjusted, effective after HiSH is restarted. Additionally, new emulators can be created with different resource specifications or a different rootfs image.

    HiSH – Emulators and available rootfs images

    MateBook Pro - HiSH default emulator
    MateBook Pro - HiSH emulators

    By default, there is a single rootfs image for Alpine Linux. The harmoninux/linux-config project provides 2 alternative rootfs images which can be downloaded and imported to HiSH after decompression.

    1. Ubuntu 24.04 LTS
    2. Debian 12

    Advanced users can create, package and import their own rootfs images as well.

    MateBook Pro - HiSH emulator images

    When creating a new emulator, the CPU and memory limits can be specified with a maximum of 16 vCPUs and 16 GiB of memory. The default rootfs image is Alpine Linux but a custom rootfs image can be selected instead.

    HiSH – Creating a new emulator with custom resource requirements

    MateBook Pro - HiSH create new emulator

    HiSH also supports mapping network ports from the emulated guest to the host laptop. A list of common port mappings are available for selection; for example, mapping port 22/tcp (SSH) from the guest to port 2222/tcp on the host.

    MateBook Pro - HiSH map network ports
    MateBook Pro - HiSH default port mappings

    Additionally, if the suggested port mappings are not applicable, users can define their own port mappings as well.

    Let’s map port 9898/tcp from the guest to the same port on the host; we’ll use it later for running a sample application in a Docker container: stefanprodan/podinfo

    MateBook Pro - HiSH custom port mapping

    The newly created emulator appears in HiSH and can be selected as the default instance to boot when HiSH is started. This setting takes effect after HiSH is restarted.

    MateBook Pro - HiSH new emulator created
    MateBook Pro - HiSH new emulator running

    HiSH – Sharing files and folders with the host OS

    HiSH allows the emulated guest to share files and folders securely with the host OS via a dedicated shared folder. The folder is located under ~/Downloads/HiSH/ on the host and exposed as /mnt/share/ in the guest. This allows users and developers to process files and folders with standard Linux tools before sharing them with the host directly for convenient access, or vice versa.

    Limiting the shared files and folders to a specific directory on the host retains the convenience of comparable tools such as WSL2 on Windows, without the risk brought by mounting the entire host system drive such as C: on Windows to the guest which can damage or destroy the host system due to mis-operation on the guest system.

    MateBook Pro - HiSH shared folder

    Clicking the “+” button allows us to import an existing file or create a new shared folder from HarmonyOS. The selected file appears in HiSH once imported.

    MateBook Pro - HiSH import file to shared folder
    MateBook Pro - HiSH select file to import
    MateBook Pro - HiSH file imported successfully

    The imported file is now accessible in HiSH within the guest system. For example, we can display its contents with the standard cat utility command.

    MateBook Pro - HiSH accessing the imported file

    HiSH – Running containerized applications with Docker

    HiSH includes a collection of beginner-friendly user guides and tutorials on using HiSH or Linux in general. It is great for learning and experimenting with Linux.

    MateBook Pro - HiSH user guides and tutorials

    For example, there is a tutorial on how to install, enable and use Docker with HiSH for learning containers and basic software development tasks.

    MateBook Pro - HiSH Docker tutorial

    The instructions were straightforward and easy to understand. I was able to follow the Docker tutorial and adapt it to deploy the podinfo application as a standalone container which persists across guest reboots. By combining Docker with the mapped network ports in an earlier step, I was able to access the application directly from my browser at http://localhost:9898/.

    MateBook Pro - HiSH podinfo Docker application

    Here’s a quick recording of me setting up Docker on HiSH and using it to run the podinfo application as a standalone container. The recording is also available on Asciinema.

    HiSH – Advanced features for developers

    The version of HiSH from AppGallery relies on pure software emulation with no hardware acceleration possibly due to default system security policies on HarmonyOS 6 or app store policies dictated by Huawei. As seen in the Asciicast recording above, this results in the HiSH emulator to run at a sub-optimal speed not suited for heavy usage and proper software development tasks.

    The bundled documentation mentions that the GitHub version enables just-in-time (JIT) acceleration which allows the HiSH emulator to run 5-6x faster when compiled and installed from source instead of using the pre-packaged version on AppGallery.

    MateBook Pro - HiSH JIT acceleration for developers

    I haven’t tested the GitHub version on my laptop but such performance improvements should make HiSH viable for most light to mid-range Linux software development tasks and general experimentation.

    Concluding remarks

    The Huawei MateBook Pro 2025 is a great laptop for everyday usage, as well as software development tasks for the most common programming languages and frameworks since HarmonyOS 6.x.

    However, being a relatively new PC operating system, it’s not without its shortcomings. Here are a few areas where I think the HarmonyOS experience on the MateBook Pro can be improved.

    1. Celia: an “Agent Mode” for performing tasks directly on the OS level or integration with MCP servers would be incredibly useful. I remember such feature being mentioned recently with newer Huawei consumer products, so maybe it’s available on the latest Huawei smartphones and tablets, or maybe I missed the option already available and sitting somewhere on my laptop waiting to be discovered (-:
    2. CodeArts: support for additional programming languages such as Go or Rust would further expand the developer userbase and make software development on the MateBook Pro fun instead of just usable
    3. HiShell: really looking forward to openEuler mode for a proper Linux terminal with hardware acceleration for serious prototyping and development for stuff that’s just designed to run on Linux such as Kubernetes and AI/ML workloads
    4. HiSH: excellent proof-of-concept (POC) that running a full-fledged Linux system on HarmonyOS 6.x is possible, though perhaps a bit slow for proper prototyping and software development due to the lack of hardware acceleration. Kudos to the HarmonyOS developer and enthusiast who made it possible!

    That said, the HarmonyOS ecosystem is expanding rapidly and I anticipate that many of the issues or shortcomings I mentioned will be fixed in due time. Looking forward to upcoming developments with the HarmonyOS ecosystem and how it can provide a viable alternative to the Microsoft and Apple monopoly over the PC market.

    I hope you enjoyed this article and stay tuned for updates 😉

    + ,
  • The source code for this lab exercise is available on GitHub.

    GitOps is the practice of delivering infrastructure and applications in a declarative manner based on a single, authoritative source of truth. Oftentimes, the source of truth is defined within a Git repository, hence the term GitOps, though recent developments have started to decouple Git from GitOps with the source of truth defined in OCI artifacts instead in what is known as “Git-less GitOps”. Closely related is the concept of continuous delivery (CD) which is implemented in GitOps by continuously reconciling the actual state of the infrastructure and applications to match the desired configuration stored in the latest Git commit.

    Most GitOps implementations revolve around 2 CNCF Graduated projects:

    1. Flux: originally a WeaveWorks project; now spearheaded by ControlPlane
    2. Argo CD: actively developed on by startups and enterprise IT vendors such as Akuity and Red Hat

    This lab demonstrates the deployment of a sample podinfo application with GitOps using Argo CD deployed via the operator.

    Lab: Deploying an application with GitOps using Argo CD

    Architecture

    The high-level architecture for this lab is depicted below with the following components, each residing in a separate namespace:

    1. Argo CD Operator
    2. Argo CD instance
    3. podinfo application
    Argo CD GitOps demo architecture diagram

    The Argo CD Operator manages the lifecycle of the Argo CD instance via the ArgoCD custom resource. Meanwhile, the Argo CD instance manages the resources in the podinfo application namespace via GitOps.

    Prerequisites

    Familiarity with Linux and Kubernetes is assumed.

    Check out the following courses for a quick refresher:

    1. LFS101: Introduction to Linux
    2. LFS158: Introduction to Kubernetes

    Setup

    The lab instructions were validated against a VM with the following specifications.

    OSvCPUMemory (GiB)System Disk (GiB)
    Ubuntu 24.04 LTS4864

    Install required packages and tools

    Ensure the following software is installed. Follow the official installation instructions if in doubt.

    1. Docker / Podman
    2. jq
    3. mikefarah/yq
    4. kubectl 1.35
    5. KinD
    6. make
    7. Go 1.25

    Clone the project repository

    Clone the project repository and make it your working directory.

    git clone https://github.com/DonaldKellett/argocd-gitops-demo.git
    pushd argocd-gitops-demo/

    Create a cluster with KinD

    Create a Kubernetes cluster named argocd-gitops-demo with KinD. The configuration file to create the cluster is provided as kind-cluster.yaml.

    kind create cluster --config kind-cluster.yaml

    Wait a few moments for the command to complete, then wait for the node to become ready.

    kubectl wait \
    --for=condition=Ready \
    nodes \
    --all \
    --timeout=300s

    Sample output:

    node/argocd-gitops-demo-control-plane condition met

    Deploy the Argo CD Operator

    Reference: Manual Installation using kustomize – Argo CD Operator

    The Argo CD Operator manages Argo CD instances as custom Kubernetes resources. It simplifies the deployment, management and operation of multiple Argo CD instances in a multi-tenant Kubernetes cluster through separation of concerns and principle of least privilege (PoLP).

    Learn more about the Kubernetes operator pattern: Operator pattern | Kubernetes

    Clone the GitHub repository argoproj-labs/argocd-operator and make it your working directory. Use version v0.16.0 of the Argo CD Operator.

    pushd $HOME
    git clone \
    -b v0.16.0 \
    https://github.com/argoproj-labs/argocd-operator.git
    pushd argocd-operator/

    The Argo CD Operator defaults to the argocd-operator-system namespace if not specified. Create it now.

    kubectl create ns argocd-operator-system

    Use Make to build the Kustomize manifests and deploy the operator.

    make deploy

    Sample output – only the last few lines are shown for brevity:

    (output elided)
    ...
    configmap/argocd-operator-manager-config serverside-applied
    service/argocd-operator-controller-manager-metrics-service serverside-applied
    service/argocd-operator-webhook-service serverside-applied
    deployment.apps/argocd-operator-controller-manager serverside-applied

    Wait for the operator to become ready.

    kubectl -n argocd-operator-system wait \
    --for=condition=Available \
    deployments.apps \
    -l control-plane=argocd-operator \
    --timeout=300s

    Sample output:

    deployment.apps/argocd-operator-controller-manager condition met

    Deploy an Argo CD instance for podinfo

    Reference: Basics – Argo CD Operator

    The Argo CD Operator watches for resources on all namespaces by default.

    Deploy an Argo CD instance for our podinfo application. Our application should reside in the podinfo namespace. Meanwhile, the Argo CD instance managing this application should reside in a separate namespace argocd-podinfo. Placing the application and its managing Argo CD instance in separate namespaces ensures any changes to the application Git repository are scoped to the application namespace and do not affect the Argo CD instance itself.

    Switch back to our project directory.

    popd
    popd

    Create the namespace argocd-podinfo.

    kubectl create ns argocd-podinfo

    The ArgoCD custom resource is provided as manifests/argocd.yaml. Use it to create our Argo CD instance.

    kubectl create -f manifests/argocd.yaml

    Wait for the Argo CD instance to be available.

    kubectl -n argocd-podinfo wait \
    --for=jsonpath='{.status.phase}'=Available \
    argocds.argoproj.io \
    argocd-podinfo \
    --timeout=300s

    Sample output:

    argocd.argoproj.io/argocd-podinfo condition met

    Log in to the Argo CD web console

    Observe the argocd-podinfo-server service is available.

    kubectl -n argocd-podinfo get svc argocd-podinfo-server

    Sample output:

    NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
    argocd-podinfo-server ClusterIP 10.96.76.127 <none> 80/TCP,443/TCP 5m10s

    Port forward the service at port 8443:443 in a new terminal tab or window and keep the command running.

    kubectl -n argocd-podinfo port-forward svc/argocd-podinfo-server 8443:443

    Open your browser at https://localhost:8443/. You will receive a certificate warning since the Argo CD instance uses an untrusted certificate signed by the internal CA managed by Argo CD Operator by default. Feel free to bypass this warning to proceed to the login screen. Replacing the untrusted certificate with a custom trusted certificate is outside the scope of this lab.

    Ignore certificate warning

    The default username is admin and the default password is stored in the key admin.password in the secret argocd-podinfo-cluster. Extract the admin password from the provided secret.

    kubectl -n argocd-podinfo get secret \
    argocd-podinfo-cluster \
    -o json | \
    jq '.data."admin.password"' --raw-output | \
    base64 -d -; echo
    Argo CD login screen

    On successful login, you should see the “Applications” view of Argo CD.

    Argo CD Applications view

    Create and label the podinfo namespace

    Reference: Deploy Resources to Different Namespaces – Argo CD Operator

    Create the podinfo namespace for our application.

    kubectl create ns podinfo

    Apply the label argocd.argoproj.io/managed-by=argocd-podinfo to the podinfo namespace. This instructs the Argo CD instance in the argocd-podinfo namespace to manage the podinfo namespace and all resources within it.

    kubectl label ns podinfo argocd.argoproj.io/managed-by=argocd-podinfo

    Deploy the podinfo application with Kustomize

    In the Argo CD web console, click on “New App” to the top left of the page.

    Argo CD - New App

    Fill in the details.

    1. Under the “General” section, enter podinfo for the name and select the default project. Leave the sync policy at “Manual” and leave the remaining options at their defaults
    2. Under the “Source” section, enter the following details:
      1. Repository URL: https://github.com/DonaldKellett/argocd-gitops-demo.git
      2. Revision: HEAD
      3. Path: podinfo/overlays/dev
    3. Under the “Destination” section, fill in https://kubernetes.default.svc for the cluster URL and podinfo for the namespace
    4. Under the “Kustomize” section, enter podinfo for the namespace and leave the remaining options at their defaults
    Argo CD - New App - General
    Argo CD - New App - Source
    Argo CD - New App - Destination
    Argo CD - New App - Kustomize

    Click “Create” when ready.

    The podinfo app is initially out of sync. Click “Sync”, then accept the default and click “Synchronize”.

    Argo CD - Sync application
    Argo CD - Confirm sync application

    The application shows as “Progressing” and enters a “Healthy” state within a few minutes.

    Argo CD - Application is progressing
    Argo CD - Application is healthy
    Argo CD - Application map

    Test the podinfo application

    List the services in the podinfo namespace.

    kubectl -n podinfo get svc

    Sample output:

    NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
    podinfo ClusterIP 10.96.1.206 <none> 9898/TCP,9999/TCP 18m

    In yet another new terminal tab or window, port forward the service at port 9898/tcp and keep it running.

    kubectl -n podinfo port-forward svc/podinfo 9898:9898

    Point your browser to http://localhost:9898/ and observe that the podinfo application is ready and serving requests.

    Podinfo application running

    Congratulations, you have successfully deployed an app with GitOps using Argo CD!

    Demo

    A command-line walkthrough of this demo is available on Asciinema.

    Concluding remarks and going further

    We saw in this lab how to deploy, manage and operate Argo CD instances and applications with the Argo CD Operator using mostly default settings. However, the default configuration options are not production ready and further configuration is required to secure and scale Argo CD reliably and effectively in the enterprise.

    Dive deeper into Argo CD and related projects with the following courses:

    1. LFS256: DevOps and Workflow Management with Argo covers Argo CD as well as the other projects under the Argo umbrella
    2. Red Hat OpenShift Administration III: Scaling Deployments in the Enterprise (DO380) – Ver. 4.14 has a chapter on OpenShift GitOps, an enterprise distribution of Argo CD available in OpenShift environments

    I hope you enjoyed this article and stay tuned for updates 😉

    + ,
  • Ceph is a distributed, scalable, fault-tolerant solution for provisioning block, file and object storage to applications.

    1. In Provisioning Ceph storage with cephadm, we provisioned a minimal Ceph cluster with cephadm and configured an RBD pool to provision block storage for applications
    2. In Running stateful workloads on Kubernetes with Rook Ceph, we deployed Ceph to Kubernetes with Rook using mostly default settings and provisioned block storage for MinIO with minimal effort

    While deploying Ceph to Kubernetes with Rook is simple and is sufficient for most Kubernetes use cases, certain scenarios may benefit from configuring Rook with an external Ceph cluster instead or as an additional option:

    1. Enables storage (Ceph) to scale independently of compute (Kubernetes)
    2. For large-scale deployments with high storage performance requirements best served by a dedicated Ceph cluster
    3. Sharing a Ceph cluster between Kubernetes clusters for multi-tenancy and maximizing storage utilization
    4. Deploying and managing Ceph separately from Kubernetes to separate administration duties among teams
    5. Providing additional storage capacity to Kubernetes workloads by deploying Ceph both internally and externally

    In the lab instructions to follow, we’ll configure a Ceph cluster with cephadm and configure Rook to connect to the external Ceph cluster.

    Lab: Configuring Rook with an external Ceph cluster for Kubernetes workloads

    Prerequisites

    Proficiency in Linux administration is assumed. Familiarity with Kubernetes is assumed.

    Setup

    The following lab instructions were tested on 4 VMs with Ubuntu 24.04 LTS (Noble) server installed. Default options were used for the OS installation except the following day-2 customizations:

    1. Vim as the default editor
    2. Password-less sudo configured
    3. Remote login restricted to SSH public key authentication
    4. Swap disabled on each host
    5. The SSH public key of ceph0 (Ceph bootstrap node) copied to the authorized_keys file on all hosts for both non-root and root users enabling seamless SSH access
    6. The following environment variables configured on all hosts:
      1. CEPH0_IP: IP address of the Ceph bootstrap node
      2. CEPH1_IP: IP address of the 2nd Ceph node
      3. CEPH2_IP: IP address of the 3rd Ceph node
      4. KIND_IP: IP address of the Docker host running the kind Kubernetes cluster
      5. RGW_IP: IP address of the ingress service for Ceph RADOS Gateway (RGW). RGW exposes an S3-compatible object storage service for Ceph.
    HostCPUMemoryOS diskData diskDescription
    ceph023Gi16Gi32GiCeph bootstrap node
    ceph123Gi16Gi32Gi2nd Ceph node
    ceph223Gi16Gi32Gi3rd Ceph node
    kind45Gi32GiDocker host for running Kubernetes with kind

    Note that all data disks on Ceph nodes are unused, unformatted raw devices. This is required for Ceph to identify them as available and consume them to form the required storage pools.

    In the lab instructions to follow, all actions should be performed on the Ceph bootstrap node ceph0 unless otherwise specified.

    Install required packages and tools

    Install the following on each Ceph node:

    1. Podman
    2. cephadm 19.2.3 (Squid)
    sudo apt update && sudo apt install -y podman
    CEPH_RELEASE=19.2.3
    curl -k --silent --remote-name --location https://download.ceph.com/rpm-${CEPH_RELEASE}/el9/noarch/cephadm
    sudo install -T cephadm /usr/local/bin/cephadm

    Install the following on kind:

    1. Docker
    2. jq
    3. kind
    4. kubectl
    5. yq
    6. Helm
    sudo apt update && sudo apt install -y docker.io jq
    sudo usermod -aG docker "${USER}"
    # For AMD64 / x86_64
    [ $(uname -m) = x86_64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.30.0/kind-linux-amd64
    # For ARM64
    [ $(uname -m) = aarch64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.30.0/kind-linux-arm64
    chmod +x ./kind
    sudo mv ./kind /usr/local/bin/kind
    sudo wget -qO /usr/local/bin/kubectl https://dl.k8s.io/release/v1.34.1/bin/linux/amd64/kubectl
    sudo chmod +x /usr/local/bin/kubectl
    sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.47.2/yq_linux_amd64
    sudo chmod +x /usr/local/bin/yq
    wget https://get.helm.sh/helm-v3.19.0-linux-amd64.tar.gz
    tar xvf helm-v3.19.0-linux-amd64.tar.gz
    chmod +x linux-amd64/helm
    sudo mv linux-amd64/helm /usr/local/bin/helm

    Bootstrap the cluster

    Bootstrap the cluster with cephadm:

    sudo cephadm bootstrap --mon-ip "${CEPH0_IP}"

    Distribute the cluster’s SSH public key to the remaining Ceph nodes:

    ssh-copy-id -f -i /etc/ceph/ceph.pub root@$CEPH1_IP
    ssh-copy-id -f -i /etc/ceph/ceph.pub root@$CEPH2_IP

    Join the remaining Ceph nodes to the cluster:

    sudo cephadm shell -- \
    ceph orch host add \
    ceph1 \
    "${CEPH1_IP}" 2> /dev/null
    sudo cephadm shell -- \
    ceph orch host add \
    ceph2 \
    "${CEPH2_IP}" 2> /dev/null

    Consume all available disks for OSDs:

    sudo cephadm shell -- \
    ceph orch apply osd \
    --all-available-devices 2> /dev/null

    Wait a few minutes – our cluster should be ready:

    sudo cephadm shell -- \
    ceph status 2> /dev/null

    Sample output:

      cluster:
        id:     7fee1a8c-9853-11f0-9854-525400befb56
        health: HEALTH_OK
    
      services:
        mon: 3 daemons, quorum ceph0,ceph1,ceph2 (age 1.51568s)
        mgr: ceph0.jndcfg(active, since 6m), standbys: ceph1.vjepiq
        osd: 3 osds: 3 up (since 1.43009s), 3 in (since 22s)
    
      data:
        pools:   0 pools, 0 pgs
        objects: 0 objects, 0 B
        usage:   479 MiB used, 96 GiB / 96 GiB avail
        pgs:
    

    Create an RBD pool

    Create an RBD pool – let’s call it kind-rbd:

    sudo cephadm shell -- ceph osd pool create kind-rbd 2> /dev/null
    sudo cephadm shell -- rbd pool init kind-rbd 2> /dev/null

    Confirm the pool is listed:

    sudo cephadm shell -- \
    ceph osd pool ls 2> /dev/null

    Sample output:

    .mgr
    kind-rbd

    Create a Ceph file system

    Ceph File System (CephFS) provides distributed, reliable, scalable, POSIX-compliant file storage for applications with RADOS as the backing store. This is achieved through its revolutionary architecture:

    1. Filesystem metadata and data are stored in separate OSD pools
    2. Metadata servers (MDS) handle POSIX filesystem calls and record filesystem activity to a journal on RADOS which is periodically flushed to the metadata pool
    3. Clients perform I/O operations by writing data blocks directly to the data pool without a gateway or broker

    Refer to the official documentation for more details.

    CephFS architecture

    Source: Ceph File System – Ceph Documentation

    Create a CephFS volume kind-cephfs. With modern versions of Ceph, it’s as simple as ceph fs volume create:

    sudo cephadm shell -- \
    ceph fs volume create kind-cephfs 2> /dev/null

    Confirm the volume is created with ceph fs volume ls:

    sudo cephadm shell -- ceph fs volume ls 2> /dev/null

    Sample output:

    [
    {
    "name": "kind-cephfs"
    }
    ]

    Configure Ceph RADOS Gateway (RGW)

    Ceph RADOS Gateway (RGW) provides an S3-compatible REST API for accessing Ceph storage as buckets and objects. It also provides a Swift-compatible interface for accessing Ceph storage as OpenStack Swift object storage which is not covered in this lab.

    Refer to the official documentation for details.

    Ceph RGW overview

    Source: Ceph Object Gateway – Ceph Documentation

    Let deploy an RGW with the default of 2 daemons. Call our RGW kind-rgw and ensure it listens at port 8080/tcp.

    Use the ceph orch apply rgw command:

    sudo cephadm shell -- \
    ceph orch apply rgw kind-rgw --port=8080 2> /dev/null

    Sample output:

    Scheduled rgw.kind-rgw update...

    Confirm the RGW daemons are running:

    sudo cephadm shell -- ceph status 2> /dev/null

    Sample output:

      cluster:
        id:     7fee1a8c-9853-11f0-9854-525400befb56
        health: HEALTH_OK
    
      services:
        mon: 3 daemons, quorum ceph0,ceph1,ceph2 (age 36m)
        mgr: ceph0.jndcfg(active, since 42m), standbys: ceph1.vjepiq
        mds: 1/1 daemons up, 1 standby
        osd: 3 osds: 3 up (since 36m), 3 in (since 36m)
        rgw: 2 daemons active (2 hosts, 1 zones)
    
      data:
        volumes: 1/1 healthy
        pools:   8 pools, 243 pgs
        objects: 220 objects, 457 KiB
        usage:   150 MiB used, 96 GiB / 96 GiB avail
        pgs:     243 active+clean
    

    Configure ingress for our RGW

    The ingress service provides a stable, highly available endpoint for RGW in the form of a floating virtual IP. It uses HAProxy and keepalived for the backing implementation.

    Refer to the official documentation for details.

    Ceph ingress RGW architecture

    Source: RGW Service – Ceph Documentation

    Create a dedicated directory ceph-toolbox/ for the Ceph toolbox container and make it your working directory:

    mkdir -p $HOME/ceph-toolbox/
    pushd $HOME/ceph-toolbox/

    Create an ingress service specification file kind-rgw-ingress.yaml for our kind-rgw RGW. Replace the ${xxxxx_IP} placeholders with your corresponding IP addresses.

    service_type: ingress
    service_id: kind-rgw
    spec:
    backend_service: rgw.kind-rgw
    virtual_ip: ${RGW_IP}/24
    frontend_port: 80
    monitor_port: 1967

    Apply the file with ceph orch apply. Use the --mount option of cephadm shell to mount the Ceph toolbox directory into the container under /mnt:

    sudo cephadm shell --mount "$HOME/ceph-toolbox/" -- \
    ceph orch apply -i /mnt/kind-rgw-ingress.yaml 2> /dev/null

    Sample output:

    Scheduled ingress.kind-rgw update...

    Check the ingress is up and ready:

    sudo cephadm shell -- ceph status 2> /dev/null

    Sample output:

      cluster:
        id:     a84ddfe3-9860-11f0-a350-525400befb56
        health: HEALTH_OK
    
      services:
        mon: 3 daemons, quorum ceph0,ceph2,ceph1 (age 4m)
        mgr: ceph0.wwavuz(active, since 5m), standbys: ceph2.vhpuyd
        mds: 1/1 daemons up, 1 standby
        osd: 3 osds: 3 up (since 4m), 3 in (since 4m)
        rgw: 2 daemons active (2 hosts, 1 zones)
    
      data:
        volumes: 1/1 healthy
        pools:   8 pools, 243 pgs
        objects: 220 objects, 457 KiB
        usage:   150 MiB used, 96 GiB / 96 GiB avail
        pgs:     243 active+clean
    

    Confirm our ingress service for RGW is reachable:

    nc -vz "${RGW_IP}" 80

    Sample output:

    Connection to 192.168.124.37 80 port [tcp/http] succeeded!

    Return to the home directory:

    popd

    Create our Kubernetes cluster

    Now onto the interesting part – let’s create a Kubernetes cluster with kind, deploy the Rook orchestrator and configure it to connect to our Ceph cluster!

    Log in to kind and run:

    kind create cluster

    Wait for the node to become ready:

    kubectl wait \
    --for=condition=Ready \
    nodes \
    --all \
    --timeout=300s

    Sample output:

    node/kind-control-plane condition met

    Let’s delete the standard default StorageClass and the corresponding namespace local-path-storage since we won’t be needing them.

    kubectl delete sc --all
    kubectl delete ns local-path-storage

    Deploy the Rook operator

    Reference: External Storage Cluster – Rook Ceph Documentation

    Install Rook 1.18.2 from the official Helm chart repository. On kind, run:

    helm repo add rook https://charts.rook.io/release
    helm repo update
    helm -n rook-ceph install \
    rook-ceph \
    rook/rook-ceph \
    --version 1.18.2 \
    --create-namespace \
    --wait

    Sample output:

    NAME: rook-ceph
    LAST DEPLOYED: Tue Sep 23 11:18:20 2025
    NAMESPACE: rook-ceph
    STATUS: deployed
    REVISION: 1
    TEST SUITE: None
    NOTES:
    The Rook Operator has been installed.
    Visit https://rook.io/docs/rook/latest for instructions on how to create and configure Rook clusters
    Important Notes:
    - You must customize the 'CephCluster' resource in the sample manifests for your cluster.
    - Each CephCluster must be deployed to its own namespace, the samples use `rook-ceph` for the namespace.
    - The sample manifests assume you also installed the rook-ceph operator in the `rook-ceph` namespace.
    - The helm chart includes all the RBAC required to create a CephCluster CRD in the same namespace.
    - Any disk devices you add to the cluster in the 'CephCluster' must be empty (no filesystem and no partitions).
    - The CSI operator will manage the CSI driver lifecycle for RBD, CephFS, and NFS drivers.

    Confirm the Rook operator and associated deployments are ready:

    kubectl -n rook-ceph wait \
    --for=condition=Available \
    deploy \
    --all \
    --timeout=300s

    Sample output:

    deployment.apps/ceph-csi-controller-manager condition met
    deployment.apps/rook-ceph-operator condition met

    Export the configuration from our Ceph cluster

    Reference: Export config from the Ceph provider cluster – Rook Ceph Documentation

    Log in to ceph0 and enter the $HOME/ceph-toolbox/ directory we created in an earlier step:

    pushd $HOME/ceph-toolbox/

    Clone the release-1.18 branch of rook/rook from GitHub:

    git clone -b release-1.18 https://github.com/rook/rook.git

    Export the config to ceph-export.sh with the provided script rook/deploy/examples/external/create-external-cluster-resources.py and the following options:

    1. --rbd-data-pool-name kind-rbd: use the kind-rbd RBD pool
    2. --cephfs-filesystem-name kind-cephfs: use the kind-cephfs CephFS volume
    3. --rgw-endpoint ${RGW_IP}:80: use the ingress service endpoint we created for the kind-rgw RGW service
    4. --namespace rook-ceph: our CephCluster and Rook components will run in the rook-ceph namespace
    5. --k8s-cluster-name kind: the Kubernetes cluster we created has the name kind by default
    6. --restricted-auth-permission true: enable multi-tenancy so multiple Kubernetes clusters can connect to our Ceph cluster
    7. --format bash: export the configuration as a Bash script
    sudo cephadm shell --mount "$HOME/ceph-toolbox/" -- \
    python3 \
    /mnt/rook/deploy/examples/external/create-external-cluster-resources.py \
    --rbd-data-pool-name kind-rbd \
    --cephfs-filesystem-name kind-cephfs \
    --rgw-endpoint ${RGW_IP}:80 \
    --namespace rook-ceph \
    --k8s-cluster-name kind \
    --restricted-auth-permission true \
    --format bash > ceph-export.sh 2> /dev/null

    Inspect the generated ceph-export.sh script.

    cat ceph-export.sh

    Sample output:

    export ARGS="[Configurations]
    k8s-cluster-name = kind
    namespace = rook-ceph
    rgw-pool-prefix = default
    restricted-auth-permission = true
    format = bash
    cephfs-filesystem-name = kind-cephfs
    cephfs-metadata-pool-name = cephfs.kind-cephfs.meta
    cephfs-data-pool-name = cephfs.kind-cephfs.data
    rbd-data-pool-name = kind-rbd
    rgw-endpoint = 192.168.124.37:80
    "
    export NAMESPACE=rook-ceph
    export ROOK_EXTERNAL_FSID=c014ad7b-9877-11f0-9bc6-525400befb56
    export ROOK_EXTERNAL_USERNAME=client.healthchecker
    export ROOK_EXTERNAL_CEPH_MON_DATA=ceph0=192.168.124.42:6789
    export ROOK_EXTERNAL_USER_SECRET=AQDgmNJoPTNWMRAA2cNm+rbe5jZ3TFzxafkC1w==
    export ROOK_EXTERNAL_DASHBOARD_LINK=https://192.168.124.42:8443/
    export CSI_RBD_NODE_SECRET=AQDgmNJowvv2MhAAiQnooIlpaS4qZUnmRU4npg==
    export CSI_RBD_NODE_SECRET_NAME=csi-rbd-node-kind-kind-rbd
    export CSI_RBD_PROVISIONER_SECRET=AQDgmNJo8eaLNBAA7wy+W71EWh0HL9Un942p5Q==
    export CSI_RBD_PROVISIONER_SECRET_NAME=csi-rbd-provisioner-kind-kind-rbd
    export CEPHFS_POOL_NAME=cephfs.kind-cephfs.data
    export CEPHFS_METADATA_POOL_NAME=cephfs.kind-cephfs.meta
    export CEPHFS_FS_NAME=kind-cephfs
    export RESTRICTED_AUTH_PERMISSION=true
    export CSI_CEPHFS_NODE_SECRET=AQDgmNJoHK8SNhAAie5DGIp2NaH4J4plwBC9fA==
    export CSI_CEPHFS_PROVISIONER_SECRET=AQDgmNJowLwXOBAAU/uO8lngQwCrpB2+45Cp5g==
    export CSI_CEPHFS_NODE_SECRET_NAME=csi-cephfs-node-kind-kind-cephfs
    export CSI_CEPHFS_PROVISIONER_SECRET_NAME=csi-cephfs-provisioner-kind-kind-cephfs
    export MONITORING_ENDPOINT=192.168.124.42
    export MONITORING_ENDPOINT_PORT=9283
    export RBD_POOL_NAME=kind-rbd
    export RGW_POOL_PREFIX=default
    export RGW_ENDPOINT=192.168.124.37:80
    export RGW_ADMIN_OPS_USER_ACCESS_KEY=60JFMQV3220XB72TTUV8
    export RGW_ADMIN_OPS_USER_SECRET_KEY=TDFTbjpIxoosAMpHXPdAImvnPdk12XmOwSdt3dsI

    WARNING: the generated script contains access credentials and other sensitive information. Do not share it with others!

    For the purposes of this lab, the Ceph cluster will be destroyed afterwards so it is fine to display the credentials here.

    Copy the ceph-export.sh script to our Kubernetes host kind:

    scp ceph-export.sh "${KIND_IP}:~"

    Return to your home directory:

    popd

    Import the configuration to Rook

    Reference: Import Ceph configuration to the Rook consumer cluster – Rook Ceph Documentation

    Log in to kind and clone the release-1.18 branch of rook/rook from GitHub:

    git clone -b release-1.18 https://github.com/rook/rook.git

    Enter the rook/ project directory:

    pushd rook/

    Deploy version 1.18.2 of the rook-ceph-cluster chart with the values specified in deploy/charts/rook-ceph-cluster/values-external.yaml:

    helm -n rook-ceph install \
    -f deploy/charts/rook-ceph-cluster/values-external.yaml \
    rook-ceph-cluster \
    rook/rook-ceph-cluster \
    --version 1.18.2 \
    --wait

    Sample output:

    NAME: rook-ceph-cluster
    LAST DEPLOYED: Tue Sep 23 13:13:58 2025
    NAMESPACE: rook-ceph
    STATUS: deployed
    REVISION: 1
    TEST SUITE: None
    NOTES:
    The Ceph Cluster has been installed. Check its status by running:
    kubectl --namespace rook-ceph get cephcluster
    Visit https://rook.io/docs/rook/latest/CRDs/Cluster/ceph-cluster-crd/ for more information about the Ceph CRD.
    Important Notes:
    - You can only deploy a single cluster per namespace
    - If you wish to delete this cluster and start fresh, you will also have to wipe the OSD disks using `sfdisk`

    Confirm that the rook-ceph CephCluster resource is in the Connecting phase. This indicates that it is waiting to connect to an external Ceph cluster.

    kubectl -n rook-ceph get cephcluster rook-ceph -o json | \
    jq '.status.phase' --raw-output

    Sample output:

    Connecting

    Leave the rook/ project directory and return to your home directory:

    popd

    Source the ceph-export.sh script we generated earlier to populate our shell with the environment variables required by the import script at a later step.

    source ./ceph-export.sh

    Now mark the deploy/examples/external/import-external-cluster.sh script under the Rook repository as executable and run it.

    chmod +x ./rook/deploy/examples/external/import-external-cluster.sh
    ./rook/deploy/examples/external/import-external-cluster.sh

    Sample output:

    cluster namespace rook-ceph already exists
    secret/rook-ceph-mon created
    configmap/rook-ceph-mon-endpoints created
    configmap/external-cluster-user-command created
    secret/rook-csi-rbd-node-kind-kind-rbd created
    secret/rook-csi-rbd-provisioner-kind-kind-rbd created
    secret/rgw-admin-ops-user created
    secret/rook-csi-cephfs-node-kind-kind-cephfs created
    secret/rook-csi-cephfs-provisioner-kind-kind-cephfs created
    storageclass.storage.k8s.io/ceph-rbd created
    storageclass.storage.k8s.io/cephfs created

    Now wait for the rook-ceph CephCluster resource to become Connected – this may take a while:

    kubectl -n rook-ceph wait \
    --for=condition=Connected \
    cephcluster \
    --all \
    --timeout=300s

    Sample output:

    cephcluster.ceph.rook.io/rook-ceph condition met

    Inspect and use the available storage classes

    Log in to kind and list the storage classes created by Rook:

    kubectl get sc

    Sample output:

    NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
    ceph-rbd rook-ceph.rbd.csi.ceph.com Delete Immediate true 12m
    cephfs rook-ceph.cephfs.csi.ceph.com Delete Immediate true 12m

    Two storage classes were created:

    • ceph-rbd: provisions block storage with Ceph RBD
    • cephfs: provisions file storage with CephFS volumes

    Notice a storage class for Ceph RGW is missing. Configuring a Ceph RGW storage class with Rook and consuming object storage from Kubernetes is left as an exercise to the reader.

    Let’s create two PersistentVolumeClaims – one for block storage and one for file storage.

    Create a file ceph-block-file-pvc.yaml with the following content:

    ---
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
    name: ceph-block-pvc
    spec:
    accessModes:
    - ReadWriteOnce
    resources:
    requests:
    storage: 1Gi
    storageClassName: ceph-rbd
    volumeMode: Filesystem
    ---
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
    name: ceph-file-pvc
    spec:
    accessModes:
    - ReadWriteMany
    resources:
    requests:
    storage: 1Gi
    storageClassName: cephfs
    volumeMode: Filesystem

    Create a dummy application namespace and create the PVC resources:

    kubectl create ns busybox
    kubectl -n busybox create -f ceph-block-file-pvc.yaml

    Sample output:

    namespace/busybox created
    persistentvolumeclaim/ceph-block-pvc created
    persistentvolumeclaim/ceph-file-pvc created

    Confirm both PVCs are bound:

    kubectl -n busybox get pvc \
    -o custom-columns='NAME:metadata.name,PHASE:status.phase'

    Sample output:

    NAME PHASE
    ceph-block-pvc Bound
    ceph-file-pvc Bound

    Optional: create a dummy pod with the docker.io/library/busybox:latest image and mount both PVCs to the pod. Confirm that the pod can read from and write to both volumes. This is left as an exercise to the reader.

    Congratulations, you’ve successfully configured Rook to provision block and file storage for Kubernetes applications from an external Ceph cluster!

    Demo

    An asciicast recording of this hands-on lab is available on Asciinema.

    Concluding remarks and going further

    I hope you enjoyed this lab as much as I did preparing it 🙂

    Dive deeper into Ceph storage and Kubernetes with the following resources:

    1. CL260: Red Hat Ceph Storage for OpenStack dives into the details of Ceph administration and how to integrate Ceph with OpenStack for a hyperconverged platform
    2. DO370: Enterprise Kubernetes Storage with Red Hat OpenShift Data Foundation dives into provisioning Ceph storage on Kubernetes with the Rook orchestrator and using Ceph to provision block, file and object storage for Kubernetes applications, among other tasks

    Stay tuned for updates! 😉

    + ,
  • Ceph is an open source, distributed storage system for providing block, file and object storage to applications. Under the hood, Ceph manages all storage as objects within RADOS (Reliable Autonomic Distributed Object Store) and uses the CRUSH (Controlled Replication Under Scalable Hashing) algorithm to determine object placement.

    Common use cases of Ceph include:

    Ceph provides many options for deployment such as with AnsiblePuppet or Rook. In this lab, we’ll deploy a Ceph cluster with cephadm consisting of 3 nodes and 3 disks which we’ll use to provision a block device mounted to an external host. Note that this minimal setup is for demonstration purposes only and is not suitable for production.

    Ceph architecture overview

    Ceph architecture

    Source: Network Configuration Referece – Ceph Documentation

    The following core components of Ceph are relevant in this lab for provisioning and managing RADOS block device (RBD) volumes:

    1. Monitors (MON): the brains of the cluster. Uses the Paxos algorithm for cluster quorum. An odd number of MONs is recommended for high availability, usually 3 or 5
    2. Managers (MGR): exposes a REST API for programmatic access and management operations. 2 is recommended for high availability
    3. Object Storage Daemons (OSD): manages the data on local storage. Exactly 1 OSD is created per raw storage device consumed by Ceph

    Lab: Provisioning a minimal Ceph cluster with cephadm

    Prerequisites

    Proficiency in Linux administration is assumed. If in doubt, consult the resources below on an as-needed basis:

    Setup

    The following lab instructions were tested on 4 VMs with Ubuntu 24.04 LTS (Noble) server installed. Default options were used for the OS installation except the following day-2 customizations:

    1. Vim as the default editor
    2. Password-less sudo configured
    3. Remote login restricted to SSH public key authentication
    4. Swap disabled on each Ceph node
    5. The SSH public key of the non-root user on the bastion host copied to the authorized_keys file on all Ceph nodes for both non-root and root users for seamless access via SSH
    6. The following environment variables configured on all hosts:
      1. BASTION_IP: IP address of the bastion host
      2. CEPH0_IP: IP address of the 1st Ceph node
      3. CEPH1_IP: IP address of the 2nd Ceph node
      4. CEPH2_IP: IP address of the 3rd Ceph node
    HostCPUMemoryOS diskData diskDescription
    bastion12Gi16GiBastion host used to connect to Ceph nodes. Also used as a client for mounting the block device provisioned by Ceph
    ceph024Gi16Gi32GiThe 1st Ceph node. Also the bootstrap node used to bootstrap the Ceph cluster
    ceph124Gi16Gi32GiThe 2nd Ceph node
    ceph224Gi16Gi32GiThe 3rd Ceph node

    Note that all data disks on Ceph nodes are unused, unformatted raw devices. This is required for Ceph to identify them as available and consume them to form the required storage pools.

    In the lab instructions to follow, all actions should be performed on the bastion host unless otherwise specified.

    Install required packages and tools

    The following dependencies are required by cephadm as per the official documentation:

    • Python 3
    • Systemd
    • Podman or Docker for running containers
    • Time synchronization (such as Chrony or the legacy ntpd)
    • LVM2 for provisioning storage devices

    All of them are pre-installed in Ubuntu 24.04 except Podman / Docker.

    Install Podman on each Ceph node. Run the following commands on each Ceph node:

    1. ceph0
    2. ceph1
    3. ceph2
    sudo apt update && sudo apt install -y podman

    Now install cephadm for Ceph 19.2.3 (Squid). Run the following commands on each Ceph node:

    CEPH_RELEASE=19.2.3
    curl -k --silent --remote-name --location https://download.ceph.com/rpm-${CEPH_RELEASE}/el9/noarch/cephadm
    sudo install -T cephadm /usr/local/bin/cephadm

    Install the Ceph client binaries on the bastion host. Run the following commands:

    sudo apt update && sudo apt install -y ceph-common

    Bootstrap the Ceph cluster on ceph0

    Bootstrap the Ceph cluster on ceph0 with cephadm bootstrap. Run the following commands on ceph0:

    sudo cephadm bootstrap --mon-ip $CEPH0_IP

    Wait until the command reports that the bootstrap is successful. The process should take under 5 minutes.

    Bootstrap complete.

    Verify the cluster status with the Ceph CLI. Run the following command on ceph0 to enter an interactive shell:

    sudo cephadm shell

    Observe the shell prompt change to root@ceph0:/#. You are dropped into a container with the Ceph CLI pre-installed and the relevant Ceph configuration files from the host bind-mounted into the container.

    Verify the cluster status from within the cephadm shell:

    ceph status

    Sample output:

      cluster:
        id:     e24d2e8b-96dc-11f0-8b73-525400137312
        health: HEALTH_WARN
                OSD count 0 < osd_pool_default_size 3
    
      services:
        mon: 1 daemons, quorum ceph0 (age 2m)
        mgr: ceph0.pflfbk(active, since 62s)
        osd: 0 osds: 0 up, 0 in
    
      data:
        pools:   0 pools, 0 pgs
        objects: 0 objects, 0 B
        usage:   0 B used, 0 B / 0 B avail
        pgs:
    

    Notice that the cluster health is in a HEALTH_WARN state since we haven’t added any OSDs yet. This is expected and we will add the OSDs at a later step.

    It is normal to observe components such as Prometheus or Grafana progressing if running the ceph status command right after the bootstrap procedure. The Ceph components should stabilize within a few minutes.

    Check the raw used and available capacity within the Ceph cluster.

    Run the following command in the cephadm shell:

    ceph df

    Sample output:

    --- RAW STORAGE ---
    CLASS SIZE AVAIL USED RAW USED %RAW USED
    TOTAL 0 B 0 B 0 B 0 B 0
    --- POOLS ---
    POOL ID PGS STORED OBJECTS USED %USED MAX AVAIL

    We get 0 bytes as expected since no disks are available yet.

    List the cluster nodes. In the cephadm shell, run:

    ceph orch host ls

    Sample output:

    HOST ADDR LABELS STATUS
    ceph0 192.168.124.33 _admin
    1 hosts in cluster

    We only have 1 node ceph0 – for now.

    Exit the cephadm shell:

    exit

    Note that we can run Ceph commands with cephadm shell in line, like so. For example, the command below prints the CRUSH map in human-readable format to standard output (STDOUT).

    sudo cephadm shell -- \
    /bin/sh -c "ceph osd getcrushmap | crushtool -d -"

    Sample output:

    Inferring fsid e24d2e8b-96dc-11f0-8b73-525400137312
    Inferring config /var/lib/ceph/e24d2e8b-96dc-11f0-8b73-525400137312/mon.ceph0/config
    Using ceph image with id 'aade1b12b8e6' and tag 'v19' created on 2025-07-17 19:53:27 +0000 UTC
    quay.io/ceph/ceph@sha256:af0c5903e901e329adabe219dfc8d0c3efc1f05102a753902f33ee16c26b6cee
    1
    # begin crush map
    tunable choose_local_tries 0
    tunable choose_local_fallback_tries 0
    tunable choose_total_tries 50
    tunable chooseleaf_descend_once 1
    tunable chooseleaf_vary_r 1
    tunable chooseleaf_stable 1
    tunable straw_calc_version 1
    tunable allowed_bucket_algs 54
    # devices
    # types
    type 0 osd
    type 1 host
    type 2 chassis
    type 3 rack
    type 4 row
    type 5 pdu
    type 6 pod
    type 7 room
    type 8 datacenter
    type 9 zone
    type 10 region
    type 11 root
    # buckets
    root default {
    id -1 # do not change unnecessarily
    # weight 0.00000
    alg straw2
    hash 0 # rjenkins1
    }
    # rules
    rule replicated_rule {
    id 0
    type replicated
    step take default
    step chooseleaf firstn 0 type host
    step emit
    }
    # end crush map

    The first few lines from standard error (STDERR) display the configuration mounted to the container and the OCI image used. To omit those lines, simply redirect STDERR to /dev/null, like so:

    sudo cephadm shell -- \
    /bin/sh -c "ceph osd getcrushmap | crushtool -d -" \
    2> /dev/null

    Add the remaining hosts to our Ceph cluster

    During the bootstrap process, an SSH key pair was created for our Ceph cluster. The SSH public key is located at /etc/ceph/ceph.pub.

    From the bastion host, copy the cluster SSH public key to the root user’s authorized_keys in ceph1 and ceph2:

    scp $CEPH0_IP:/etc/ceph/ceph.pub .
    ssh-copy-id -f -i ceph.pub root@$CEPH1_IP
    ssh-copy-id -f -i ceph.pub root@$CEPH2_IP

    Now log in to ceph0 and run the ceph orch host add command to add ceph1 to our cluster:

    sudo cephadm shell -- ceph orch host add \
    ceph1 \
    $CEPH1_IP \
    --labels _admin 2> /dev/null

    Sample output:

    Added host 'ceph1' with addr '192.168.124.205'

    Repeat for ceph2:

    sudo cephadm shell -- ceph orch host add \
    ceph2 \
    $CEPH2_IP \
    --labels _admin 2> /dev/null

    Sample output:

    Added host 'ceph2' with addr '192.168.124.58'

    Now list the hosts in our cluster again from ceph0:

    sudo cephadm shell -- ceph orch host ls 2> /dev/null

    Sample output:

    HOST ADDR LABELS STATUS
    ceph0 192.168.124.33 _admin
    ceph1 192.168.124.205 _admin
    ceph2 192.168.124.58 _admin
    3 hosts in cluster

    Add available storage devices to the cluster

    List the available storage devices on each Ceph node. On ceph0, run:

    sudo cephadm shell -- ceph orch device ls 2> /dev/null

    Sample output:

    HOST PATH TYPE DEVICE ID SIZE AVAILABLE REFRESHED REJECT REASONS
    ceph0 /dev/vdb hdd 32.0G Yes 29m ago
    ceph1 /dev/vdb hdd 32.0G Yes 29m ago
    ceph2 /dev/vdb hdd 32.0G Yes 29m ago

    The output above indicates that the device /dev/vdb is available on each Ceph node. Your output may be different, remember to adapt the commands accordingly.

    Let’s add 1 OSD for /dev/vdb on ceph0 with ceph orch daemon add osd. On ceph0, run:

    sudo cephadm shell -- \
    ceph orch daemon add osd \
    ceph0:/dev/vdb 2> /dev/null

    Sample output:

    Created osd(s) 0 on host 'ceph0'

    Observe that the OSD is created with ceph osd ls. On ceph0, run:

    sudo cephadm shell -- ceph osd ls 2> /dev/null

    Sample output:

    0

    Let’s check the used and available raw capacity on each OSD with ceph osd df.

    On ceph0, run:

    sudo cephadm shell -- ceph osd df 2> /dev/null

    Sample output:

    ID CLASS WEIGHT REWEIGHT SIZE RAW USE DATA OMAP META AVAIL %USE VAR PGS STATUS
    0 hdd 0.03119 1.00000 32 GiB 26 MiB 88 KiB 1 KiB 26 MiB 32 GiB 0.08 1.00 0 up
    TOTAL 32 GiB 26 MiB 88 KiB 1.6 KiB 26 MiB 32 GiB 0.08
    MIN/MAX VAR: 1.00/1.00 STDDEV: 0

    Instead of manually identifying the available devices and adding the OSDs one by one, we can instruct Ceph to consume all available devices and create OSDs for them automatically.

    On ceph0, run:

    sudo cephadm shell -- \
    ceph orch apply osd \
    --all-available-devices 2> /dev/null

    Sample output:

    Scheduled osd.all-available-devices update...

    Confirm that OSDs are created for the remaining devices. Use the ceph osd tree command on ceph0:

    sudo cephadm shell -- ceph osd tree 2> /dev/null

    Sample output:

    ID CLASS WEIGHT TYPE NAME STATUS REWEIGHT PRI-AFF
    -1 0.09357 root default
    -3 0.03119 host ceph0
    0 hdd 0.03119 osd.0 up 1.00000 1.00000
    -7 0.03119 host ceph1
    1 hdd 0.03119 osd.1 up 1.00000 1.00000
    -5 0.03119 host ceph2
    2 hdd 0.03119 osd.2 up 1.00000 1.00000

    Create an RBD pool

    Let’s create an RBD pool using the available storage to provision block devices for our applications. This is a 2-step process:

    1. Create an OSD pool with the name rbd
    2. Initialize the RBD pool with the default name rbd

    List the existing OSD pools. For a newly configured Ceph cluster, there should just be 1 pool .mgr used by the MGRs internally.

    Run the ceph osd pool ls command on ceph0:

    sudo cephadm shell -- ceph osd pool ls 2> /dev/null

    Sample output:

    .mgr

    Create the OSD pool rbd from ceph0 with ceph osd pool create:

    sudo cephadm shell -- ceph osd pool create rbd 2> /dev/null

    To use the OSD pool rbd for RBD block volumes, we need to initialize it with rbd pool init ${POOL_NAME}. If POOL_NAME is not specified then it is assumed to be the default rbd.

    On ceph0, run:

    sudo cephadm shell -- rbd pool init 2> /dev/null

    List the available pools again from ceph0:

    sudo cephadm shell -- ceph osd pool ls 2> /dev/null

    Sample output:

    .mgr
    rbd

    This time we see the rbd pool is listed.

    Create a block device user for least-privilege client access

    When running Ceph commands on the bootstrap node ceph0, the default authentication credentials used are as follows:

    • Name: client.admin
    • ID: admin
    • Keyring: /etc/ceph/ceph.client.admin.keyring

    Here, “client” is a convention indicating the principal is a user as opposed to a service account. The ID “admin” is the username and the filename of the keyring is by convention ceph.${PRINCIPAL_TYPE}.${PRINCIPAL_NAME}.keyring.

    This bootstrap user has unrestricted privileges on the Ceph cluster and is not suitable for sharing to external hosts, especially if all the external host requires is to create, manage and consume RBD volumes from a specific pool.

    Let’s create a new user client.rbd with the following privileges:

    1. View cluster configuration and metadata but not modify the cluster configuration: mon 'profile rbd'
    2. Read-write permissions on data and metadata objects within the rbd pool: osd 'profile rbd pool=rbd'
    3. Perform RBD management API calls scoped to the rbd pool: mgr 'profile rbd pool=rbd'

    On ceph0, run:

    sudo cephadm shell -- ceph auth get-or-create \
    client.rbd \
    mon 'profile rbd' \
    osd 'profile rbd pool=rbd' \
    mgr 'profile rbd pool=rbd' \
    > ceph.client.rbd.keyring \
    2> /dev/null

    This creates the client.rbd user with the specified privileges and writes the keyring to ceph.client.rbd.keyring under the current directory. The keyring is used to authenticate to the Ceph cluster as the client.rbd user which we’ll copy to the bastion host in a moment.

    Refer to Basic Block Device Commands – Ceph Documentation for more details.

    Now on the bastion host, create the /etc/ceph configuration directory and copy the following files from ceph0 to under /etc/ceph on our bastion host:

    1. The ceph.conf Ceph cluster configuration file
    2. The ceph.client.rbd.keyring keyring for the client.rbd user we just created on ceph0
    sudo mkdir -p /etc/ceph
    scp $CEPH0_IP:/etc/ceph/ceph.conf .
    scp $CEPH0_IP:~/ceph.client.rbd.keyring .
    sudo mv ceph.conf ceph.client.rbd.keyring /etc/ceph/.

    Create and use a block device provisioned from Ceph

    Now to the interesting part – let’s create an RBD image from our pool, make it available on our bastion host as a block device and use it as if it were a local disk device!

    Create an RBD image hello-rbd with size 1024Mi = 1Gi. Note that we specify the option -n client.rbd to authenticate as the client.rbd user:

    rbd -n client.rbd create \
    hello-rbd \
    --size 1024

    List the available RBD images in our pool:

    rbd -n client.rbd ls

    Sample output:

    hello-rbd

    View detailed info on our hello-rbd image:

    rbd -n client.rbd info hello-rbd

    Sample output:

    rbd image 'hello-rbd':
    size 1 GiB in 256 objects
    order 22 (4 MiB objects)
    snapshot_count: 0
    id: acfb908ead87
    block_name_prefix: rbd_data.acfb908ead87
    format: 2
    features: layering, exclusive-lock, object-map, fast-diff, deep-flatten
    op_features:
    flags:
    create_timestamp: Sun Sep 21 13:55:15 2025
    access_timestamp: Sun Sep 21 13:55:15 2025
    modify_timestamp: Sun Sep 21 13:55:15 2025

    The created image is 1Gi in size as expected.

    Now map our RBD image to a block device:

    sudo rbd -n client.rbd map hello-rbd

    Sample output:

    /dev/rbd0

    Our hello-rbd image is mapped to the block device /dev/rbd0. Your output may differ; remember to adapt the commands accordingly.

    Format the device with XFS and mount it under /mnt/hello-rbd:

    sudo mkfs.xfs /dev/rbd0
    sudo mkdir -p /mnt/hello-rbd
    sudo mount /dev/rbd0 /mnt/hello-rbd

    Let’s check if our block device actually works. Create a file 512Mi in size with dd:

    sudo dd if=/dev/urandom of=/mnt/hello-rbd/testfile bs=1M count=512

    Now verify the usage of our block device:

    df -Th /dev/rbd0

    Sample output:

    Filesystem Type Size Used Avail Use% Mounted on
    /dev/rbd0 xfs 960M 564M 397M 59% /mnt/hello-rbd

    About 50% of the 1Gi capacity of our block device is used as expected.

    Verify the raw space used by our 512Mi test file on the Ceph cluster

    Let’s check how much raw space our 512Mi test file has consumed on the cluster. Note the client.rbd user is permitted to query general information about the cluster but not modify the cluster configuration.

    ceph -n client.rbd df

    Sample output:

    --- RAW STORAGE ---
    CLASS SIZE AVAIL USED RAW USED %RAW USED
    hdd 96 GiB 94 GiB 1.6 GiB 1.6 GiB 1.71
    TOTAL 96 GiB 94 GiB 1.6 GiB 1.6 GiB 1.71
    --- POOLS ---
    POOL ID PGS STORED OBJECTS USED %USED MAX AVAIL
    .mgr 1 1 449 KiB 2 1.3 MiB 0 30 GiB
    rbd 2 32 514 MiB 141 1.5 GiB 1.66 30 GiB

    Turns out our 512Mi file has occupied 1.5Gi raw space on the cluster! Let’s see why.

    Query the size and min_size settings of our rbd pool with ceph osd pool get:

    ceph -n client.rbd osd pool get rbd size
    ceph -n client.rbd osd pool get rbd min_size

    Sample output:

    size: 3
    min_size: 2

    The size is 3 and min_size is 2size determines the number of data replicas to maintain and min_size determines the minimum number of data replicas to tolerate before Ceph halts all I/O activity for the pool during a partial cluster failure or outage. By default, we have 3-way replication and Ceph halts all I/O when it detects data blocks in the pool with fewer than 2 replicas, e.g. due to a failed OSD. So the usable capacity of the RBD pool is by default just 33% the raw capacity.

    We can view all the tunable parameters of each OSD pool with ceph osd pool ls detail:

    ceph -n client.rbd osd pool ls detail

    Sample output:

    pool 1 '.mgr' replicated size 3 min_size 2 crush_rule 0 object_hash rjenkins pg_num 1 pgp_num 1 autoscale_mode on last_change 20 flags hashpspool stripe_width 0 pg_num_max 32 pg_num_min 1 application mgr read_balance_score 3.00
    pool 2 'rbd' replicated size 3 min_size 2 crush_rule 0 object_hash rjenkins pg_num 32 pgp_num 32 autoscale_mode on last_change 28 lfor 0/0/26 flags hashpspool,selfmanaged_snaps stripe_width 0 application rbd read_balance_score 1.31

    The meaning of each configurable option is outside the scope of this lab exercise.

    Mount the RBD image at system boot

    It’s great we can make the RBD image available as a block device and mount it locally, but it’s not much use if we must manually map the RBD image and mount the device every time the system reboots. Let’s make our configuration persistent so our bastion host maps the RBD image as a block device and automatically mounts it under /mnt/hello-rbd at system boot.

    Append the following line to /etc/ceph/rbdmap. This instructs the rbdmap.service systemd unit to automatically map the hello-rbd image as a block device on system boot:

    hello-rbd id=rbd,keyring=/etc/ceph/ceph.client.rbd.keyring

    Now append the following file system entry under /etc/fstab:

    /dev/rbd/rbd/hello-rbd /mnt/hello-rbd xfs defaults,nofail 0 0

    /dev/rbd0 is not a stable identifier and may change across reboots. Instead, we use the stable identifier of the form /dev/rbd/${POOL_NAME}/${IMAGE_NAME}. Also note the nofail option – Ubuntu tries to mount the mapped device before rbdmap runs so let’s allow it to fail – rest assured the mount will eventually succeed after rbdmap runs and the device is successfully mounted once the system is fully booted.

    Reboot the system now:

    sudo systemctl reboot

    Wait up to 1 minute for the system to reboot. Now log in to the bastion host again and confirm the device is available and mounted:

    df -Th /dev/rbd/rbd/hello-rbd

    Sample output:

    Filesystem Type Size Used Avail Use% Mounted on
    /dev/rbd0 xfs 960M 563M 398M 59% /mnt/hello-rbd

    Congratulations, you provisioned a Ceph cluster, created an RBD image and mapped it to an external host as a block volume for application access!

    Demo

    An asciicast recording of this hands-on lab is available on Asciinema.

    Concluding remarks and going further

    This lab only scratches the surface of what’s possible with Ceph. Check out the following resources to learn more about Ceph:

    1. CL260: Red Hat Ceph Storage for OpenStack: this course dives into the details of Ceph administration and how to integrate Ceph with OpenStack for a hyperconverged platform
    2. DO370: Enterprise Kubernetes Storage with Red Hat OpenShift Data Foundation: this course dives into provisioning Ceph storage on Kubernetes with the Rook orchestrator and using Ceph to provision block, file and object storage for Kubernetes applications, among other tasks

    I hope you enjoyed the lab and stay tuned for updates! 😉

    +
  • DISCLAIMER: The contents in this article must NOT be considered as official CKS training material by any means and is provided solely for reference. CKS candidates are strongly encouraged to consult official CNCF training content such as LFS260: Kubernetes Security Essentials and study independently for the exam.

    Encrypting data at rest and in transit should be part of any robust IT security strategy. Furthermore, a zero-trust approach to workload security mandates encryption in transit not only for north-south traffic but also east-west traffic. In Kubernetes, the latter corresponds to Pod-to-Pod communication.

    This article and accompanying hands-on lab demonstrates Pod-to-Pod encryption with Istio Ambient Mesh, also known as ambient mode.

    Lab: Securing workload communication with mTLS using Istio Ambient Mesh

    This lab has been tested with Kubernetes v1.32 (Penelope) and Cilium 1.17.3. It aligns with the CKS domain “Minimize Microservice Vulnerabilities” in the updated curriculum effective April 2025.

    Minimize Microservice Vulnerabilities (20%)

    • Use appropriate pod security standards
    • Manage Kubernetes secrets
    • Understand and implement isolation techniques (multi-tenancy, sandboxed containers, etc.)
    • Implement Pod-to-Pod encryption (Cilium, Istio)

    Prerequisites

    Familiarity with Kubernetes is assumed. See LFS158x: Introduction to Kubernetes for a gentle introduction to Kubernetes.

    Setting up your environment

    A Linux environment with at least 4 vCPUs, 8GiB memory and sufficient available disk space capable of running Docker. This can be your own desktop/laptop if you’re a Linux user (like I am ;-), or a spare board (e.g. Raspberry Pi), physical server, virtual machine or cloud instance.

    The reference environment is Ubuntu 24.04 LTS (Noble Numbat) so if you’re on a different Linux distribution, adapt apt-related commands with dnf / pacman / something else accordingly when installing system packages. Otherwise, the remaining instructions should be broadly applicable to most Linux distributions.

    We’ll install the following tools:

    Install Docker

    sudo apt update && sudo apt install -y docker.io
    sudo usermod -aG docker $USER

    Log out and in again for group membership to take effect.

    Check the Docker version:

    docker --version

    Sample output:

    Docker version 26.1.3, build 26.1.3-0ubuntu1~24.04.1

    Install kind

    sudo wget -qO /usr/local/bin/kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64
    sudo chmod +x /usr/local/bin/kind

    Check the kind version:

    kind version

    Sample output:

    kind v0.27.0 go1.23.6 linux/amd64

    Install kubectl

    sudo wget -qO /usr/local/bin/kubectl https://dl.k8s.io/release/v1.32.3/bin/linux/amd64/kubectl
    sudo chmod +x /usr/local/bin/kubectl

    Check the kubectl version:

    kubectl version --client

    Sample output:

    Client Version: v1.32.3
    Kustomize Version: v5.5.0

    Enable kubectl shell completion:

    echo "source <(kubectl completion bash)" >> $HOME/.bashrc
    source $HOME/.bashrc

    Install Cilium CLI

    We’ll use Cilium CLI to install Cilium as our CNI. Cilium provides advanced networking performance, security and observability features through the revolutionary eBPF kernel technology.

    wget https://github.com/cilium/cilium-cli/releases/download/v0.18.3/cilium-linux-amd64.tar.gz
    tar xvf cilium-linux-amd64.tar.gz
    chmod +x ./cilium
    sudo mv ./cilium /usr/local/bin/.

    Check the Cilium CLI version:

    cilium version --client

    Sample output:

    cilium-cli: v0.18.3 compiled with go1.24.2 on linux/amd64
    cilium image (default): v1.17.2
    cilium image (stable): v1.17.3

    Enable shell completion for Cilium CLI:

    echo "source <(cilium completion bash)" >> $HOME/.bashrc
    source $HOME/.bashrc

    Install Helm

    wget https://get.helm.sh/helm-v3.17.3-linux-amd64.tar.gz
    tar xvf helm-v3.17.3-linux-amd64.tar.gz
    chmod +x linux-amd64/helm
    sudo mv linux-amd64/helm /usr/local/bin/.

    Check the Helm version:

    helm version

    Sample output:

    version.BuildInfo{Version:"v3.17.3", GitCommit:"e4da49785aa6e6ee2b86efd5dd9e43400318262b", GitTreeState:"clean", GoVersion:"go1.23.7"}

    Enable shell completion for Helm:

    echo "source <(helm completion bash)" >> $HOME/.bashrc
    source $HOME/.bashrc

    Spin up a cluster with kind

    We’ll use the following kind configuration file to disable kube-proxy and use Cilium as our CNI with the kube-proxy replacement enabled in a later step.

    ---
    kind: Cluster
    apiVersion: kind.x-k8s.io/v1alpha4
    name: istio-ambient-mtls
    networking:
    disableDefaultCNI: true
    kubeProxyMode: "none"

    Save the file above as kind-istio-ambient-mtls.yaml and create our cluster with the following command:

    kind create cluster --config kind-istio-ambient-mtls.yaml

    Install Cilium with cni.exclusive=false

    We’ll install Cilium 1.17.3 with cni.exclusive set to false. This ensures that Cilium does not take complete ownership of the CNI-related directories and is a requirement for running Istio in ambient mode on top of Cilium.

    cilium install \
    --version 1.17.3 \
    --set cni.exclusive=false

    Wait for Cilium to become ready:

    cilium status --wait

    Install Istio in ambient mode with Helm

    Istio Ambient Mesh consists of the following Helm charts:

    1. istio/base: shared Istio resources including CRDs
    2. istio/istiod: the Istio control plane (Pilot)
    3. istio/cni: the Istio CNI plugin. Required for ambient mode
    4. istio/ztunnel: Istio’s zero-trust tunnel (ztunnel) data plane for ambient mode

    All Istio Helm charts support specifying the installation profile via the profile Helm option. For example, pass --set profile=ambient to each helm install command to install Istio with the ambient profile enabled.

    Add the official Istio Helm repository and update repository metadata.

    helm repo add istio https://istio-release.storage.googleapis.com/charts
    helm repo update

    Create the istio-system namespace if not exists.

    kubectl create ns istio-system

    Now install each chart in order, specifying the --set profile=ambient option to install each Istio component with the ambient profile enabled.

    helm -n istio-system install \
    --set profile=ambient \
    base \
    istio/base \
    --version 1.25.2 \
    --wait
    helm -n istio-system install \
    --set profile=ambient \
    istiod \
    istio/istiod \
    --version 1.25.2 \
    --wait
    helm -n istio-system install \
    --set profile=ambient \
    cni \
    istio/cni \
    --version 1.25.2 \
    --wait
    helm -n istio-system install \
    --set profile=ambient \
    ztunnel \
    istio/ztunnel \
    --version 1.25.2 \
    --wait

    Create a meshed namespace and enforce mTLS

    Let’s create a namespace istio-ambient-mtls for our meshed application.

    kubectl create ns istio-ambient-mtls

    Label our namespace with istio.io/dataplane-mode=ambient to mesh all workloads in the namespace.

    kubectl label ns istio-ambient-mtls \
    istio.io/dataplane-mode=ambient

    To enforce Pod-to-Pod encryption with mTLS for our meshed workloads in strict mode, we need to define a PeerAuthentication custom resource.

    ---
    apiVersion: security.istio.io/v1
    kind: PeerAuthentication
    metadata:
    name: peer-auth-strict-mtls
    namespace: istio-ambient-mtls
    spec:
    mtls:
    mode: STRICT

    Save the file above as peer-auth-strict-mtls.yaml and apply the manifest.

    kubectl apply -f peer-auth-strict-mtls.yaml

    Verify our mTLS configuration is working

    Create a simple NGINX deployment and expose it as a ClusterIP Service:

    kubectl -n istio-ambient-mtls create deploy nginx \
    --image=nginx \
    --replicas=2 \
    --port=80
    kubectl -n istio-ambient-mtls expose deploy nginx

    Now create a simple curlpod which cURLs our NGINX service every second:

    kubectl -n istio-ambient-mtls run curlpod \
    --image=curlimages/curl \
    --command=true \
    -- \
    /bin/sh -c "while true; do curl -is --fail --max-time 1 nginx; sleep 1; done"

    Confirm that our curlpod can reach our NGINX service:

    kubectl -n istio-ambient-mtls logs pod/curlpod | \
    grep "200 OK" | \
    tail -1

    Sample output:

    HTTP/1.1 200 OK

    Now validate mTLS is working in the ztunnel logs:

    kubectl -n istio-system logs ds/ztunnel | \
    grep "connection complete" | \
    grep "istio-ambient-mtls" | \
    tail -1

    Sample output:

    2025-04-20T03:08:37.820987Z info access connection complete src.addr=10.244.0.237:39816 src.workload="curlpod" src.namespace="istio-ambient-mtls" src.identity="spiffe://cluster.local/ns/istio-ambient-mtls/sa/default" dst.addr=10.244.0.230:15008 dst.hbone_addr=10.244.0.230:80 dst.workload="nginx-86c57bc6b8-npqfq" dst.namespace="istio-ambient-mtls" dst.identity="spiffe://cluster.local/ns/istio-ambient-mtls/sa/default" direction="outbound" bytes_sent=69 bytes_recv=853 duration="3ms"

    Congratulations, you successfully enabled Pod-to-Pod encryption with Istio Ambient Mesh!

    Demo

    An asciicast recording for this hands-on lab is available on Asciinema.

    Concluding remarks and going further

    We saw how Istio Ambient Mesh provides in-transit encryption of east-west traffic via mTLS which is an integral part of any robust IT security strategy adopting zero-trust principles for their workloads. If you would like to dive deeper into this topic or Kubernetes security in general, consider the following open-ended exercises:

    1. Which Istio components (if any) can be run with the restricted PSS profile enabled? If so, which Helm value(s) should be customized? By installing only those Istio components requiring elevated host access in the kube-system namespace, can you deploy the remaining components in the istio-system namespace and enforce the restricted PSS profile namespace-wide?
    2. Try setting the Cilium policy enforcement mode to always. What breaks (Note: some components in kube-system may break as well!)? Apply suitable Cilium network policies allowing just the minimum amount of network access to get our example working again and verify that our curlpod can still reach the NGINX service with mTLS enabled
    3. Do we really need a service mesh just for transparent Pod-to-Pod encryption? Learn about the various ways to enable Pod-to-Pod encryption with Cilium in the Isovalent lab Cilium Transparent Encryption with IPSec and WireGuard

    I hope you enjoyed this article and stay tuned for updates 😉

    + , ,
  • I purchased the LFS260: Kubernetes Security Essentials course about a month ago and was working through the course material recently in preparation for the Certified Kubernetes Security Specialist (CKS) exam. The course included plenty of hands-on lab exercises covering each topic in detail which I followed through by creating a 2-node kubeadm cluster on AWS with Amazon EC2.

    The environment was configured with what I thought were reasonably secure defaults for my own purposes of experimenting with and learning about Kubernetes security, but since it was just for personal use, I made some trade-offs between security and convenience:

    1. Both nodes were placed in a public subnet with unrestricted outbound Internet connectivity
    2. Both nodes allowed unrestricted inbound SSH access
    3. Both nodes had SSH public key authentication configured with no login password set
    4. Both nodes were initially deployed 2 weeks ago from the latest official Ubuntu 24.04 LTS AMI
    5. No security hardening or package updates were performed on either node at the OS level
    6. Limited security hardening was performed on the cluster level based on the CIS Kubernetes Benchmarks while following through the course exercises

    One of the course exercises involved installing an eBPF-based security tool by Aqua Security known as Tracee and observing the JSON events emitted which uncover the runtime activity of applications running on both nodes in the cluster. Initially, the JSON events appeared normal and the activity matched the expected output based on the actions performed in the exercise. At the end of the exercise, I kept Tracee installed and running on my cluster to compare its output to those generated by Falco in the next exercise.

    While comparing the output of both runtime security tools, an interesting event emitted by Tracee caught my eye. Soon did I realize that the worker node in my cluster was compromised!

    A reverse shell attack!

    Here’s the JSON event of the reverse shell attack in all its glory.

    Tracee JSON event of the reverse shell attack

    A few important pieces of information apparent from the JSON event:

    1. An outbound connection was made to the public IP address 92.118.39.76 from/to a random high-numbered port
    2. Based on the event description, the process had its I/O redirected to a socket indicative of a reverse shell attack
    3. The timestamp of the event was only about 15 minutes before I noticed the event (not shown in the image)
    4. The affected host was my worker node which I only logged in to 2 weeks ago during the initial cluster setup (not shown in the image)

    The last observation in particular caught my attention so I decided to check the IP address against an IP reputation checker. After some initial research, I settled with the Symantec online IP reputation checker by Broadcom. I entered the IP address and clicked “submit”, hoping it would inform me that it was just a normal IP address with no bad reputation associated with it. But voila – it responded unambiguously with the message:

    The IP Address 92.118.39.76 was found to have a negative reputation. Reasons for this assessment include:

    • The host has been observed sending spam in a format that is similar to snow shoe spamming techniques.
    • The host is unauthorized to send email directly to email servers.
    Symantec bad IP reputation for address 92.118.39.76

    By now, it was apparent that my worker node was pwned!

    Lessons learned

    While I knew that allowing unrestricted SSH access from the Internet was far from a secure configuration and would never do that in production, I had assumed that using a recent official base AMI with the latest patches and enabling SSH public key authentication instead of password authentication would suffice to keep most attackers at bay. Reality proved that my mindset could not be further from the truth – the attack succeeded within 1 hour of starting my cluster nodes! Fortunately, I observed the attack happening in real time with a runtime security and observability tool such as Tracee, as otherwise I wouldn’t even be aware that such an attack had already taken place and succeeded.

    This incident demonstrates the importance of adopting a security-first mindset and implementing proper security measures at every level, from your infrastructure all the way up to your applications – “defense in depth” as some would put it.

    + , ,
  • Inspired by How eBPF will solve Service Mesh – Goodbye Sidecars | Isovalent Blog

    Service meshes are an infrastructure layer designed for the microservices era and typically provide the following features on top of microservices-oriented platforms such as Kubernetes:

    • Resilient connectivity
    • L7 traffic management
    • Identity-based security
    • Observability and tracing
    • Transparency

    (source: “How eBPF will solve Service Mesh – Goodbye Sidecars | Isovalent Blog”)

    N.B. by microservices-oriented platforms such as Kubernetes, we really mean it’s the only such type of platform that is still relevant in 2025 and beyond. See LFS158x: Introduction to Kubernetes for a quick primer on Kubernetes and LFS144x: Introduction to Istio for a quick primer on the Istio service mesh.

    There are currently 2 main categories of service meshes with widespread adoption across the cloud-native industry:

    1. Traditional (user-space) service meshes such as Istio and Linkerd. They are most commonly known for their sidecar-oriented architecture, though we’ll see that other forms of traditional service mesh exist, e.g. Istio ambient mesh
    2. eBPF-based (kernel-space) service meshes such as Cilium. They run through eBPF programs which dynamically extend the kernel at runtime with cloud-native features such as L7-aware processing and workload identity. More importantly, eBPF-based service meshes are fully integrated into the Kubernetes networking fabric (CNI), providing advanced networking, security and observability features in a fully transparent, performant and elegant manner
    Evolution of the service mesh

    (source: “How eBPF will solve Service Mesh – Goodbye Sidecars | Isovalent Blog”)

    The article “How eBPF will solve Service Mesh – Goodbye Sidecars” by Isovalent does an excellent job explaining the why and how eBPF-based service meshes outperform traditional ones by orders of magnitude, especially when deploying and operating workloads at scale – talk 10,000 microservices and above. Instead, we’ll focus on the security aspects of traditional and eBPF-based service meshes in this article – how the former compromises on cluster, workload and host-level security by design while the latter naturally solves all of these problems.

    Istio and Linkerd – your classic sidecar-oriented service mesh

    Istio architecture

    (source: Istio / Architecture)

    Istio and Linkerd both employ the sidecar-oriented architecture as depicted above with the default installation. With the sidecar-oriented architecture, communications between a pair of microservices A and B are routed through sidecar proxies sitting in front of each respective microservice. The routing is performed at the infrastructure layer; in other words, it is transparent to the application containers, requiring no changes to the application code nor the application (container) image. The per-microservice sidecar proxies (data plane) combined with the control plane provide advanced L7 observability, security and traffic management features such as HTTP metrics, mTLS mutual authentication and in-transit encryption as well as traffic splitting, load-balancing and circuit breaking etc.

    In their default configurations, both Istio and Linkerd achieve this infrastructure-level routing by injecting an init container with the NET_ADMIN and NET_RAW Linux kernel capabilities to each meshed Pod. The init container configures the iptables / nftables rules directly on the Node on which the Pod is scheduled on before the application container(s) and sidecar proxy are started.

    This operation naturally requires elevated privileges on the host and is therefore insecure. Using Istio or Linkerd with this default mode requires each meshed namespace enforce the privileged Pod Security Standard (PSS) or disable Pod Security Admission (PSA) altogether. This means that once an attacker gains privileges to deploy workloads to any meshed namespace, they can trivially carry out host-level attacks by deploying privileged Pods using the host network and host PID namespace etc., deploying crypto-miners or retaining persistent access to the compromised cluster for carrying out subsequent malicious activities further up the attack chain.

    These fundamental security design flaws were quickly uncovered in the early days of sidebar-oriented service mesh and CNI mode was soon introduced to address these issues.

    Istio and Linkerd CNI mode – not as insecure but still vulnerable

    Istio and Linkerd CNI mode retains the sidecar architecture but replaces the privileged init container with a DaemonSet which integrates with the underlying CNI. The DaemonSet runs a copy of the equivalent of the former privileged init container on each Node. It is responsible for configuring the iptables / nftables rules on the Node for each meshed Pod to route traffic through the sidecar proxy, before the sidecar proxy or application container starts. Since meshed Pods do not require the privileged init container anymore, meshed namespaces can lock down host and workload-level security by enforcing the restricted PSS which disallows any Linux capability except NET_BIND_SERVICE and forbids running containers as root among other measures.

    A limitation to this approach is that init containers defined by the application owner lose network connectivity since routes are added to the sidecar proxy which starts only after all init containers have executed to completion. A common workaround for this limitation is to run init containers as the UID of the service mesh reserved user – 1337 for Istio and 2102 for Linkerd. This special UID is able to bypass all routing rules imposed by the service mesh by design.

    There’s just one problem – any container in any Pod can freely declare their own process UID, regardless of whether or not the container is an init container. This means that Pods and containers assuming the service mesh reserved UID can bypass all traffic restrictions imposed by the service mesh and potentially intercept sensitive network traffic within the cluster. In fact, SAPwned is a real-world example of how white-hat hackers at Wiz Research managed to intercept sensitive traffic by assuming the Istio-reserved 1337 UID and eventually exfiltrate privileged credentials of SAP AI customers, in which the SAP AI platform was powered by Istio and allowed its users to submit arbitrary Argo Workflows manifests.

    To address the growing security concerns around sidecar-based service mesh architectures, the Istio project announced Istio Ambient Mesh in 2022.

    Istio Ambient Mesh – sidecar-free, but still with a greater attack surface than eBPF-based service mesh

    Istio Ambient Mesh popularized the concept of sidecar-free service mesh since its debut in 2022. Instead of a per-Pod sidecar proxy, Envoy is deployed as a DaemonSet which runs a copy of the Envoy proxy on each node. The per-node Envoy proxy is then responsible for routing requests from and to each meshed Pod, forwarding the request to the Envoy copy running on the destination node when the source and destination Pods reside on different nodes. With this innovative architecture, the Istio 1337 UID is no longer reserved so workloads assuming this UID can no longer trivially escape the mesh among many other improvements.

    Nevertheless, the Istio components themselves require elevated host-level privileges and typically run in a dedicated namespace other than kube-system; by default istio-system. Furthermore, Istio must be deployed on top of an existing CNI such as FlannelCalico or Cilium. The additional privileged Istio namespace and components on top of the existing CNI increases the attack surface of the Kubernetes cluster running Istio.

    In contrast, eBPF-based service meshes embedded within the CNI such as Cilium require no additional components or privileged namespaces other than kube-system thus minimizing the attack surface of the Kubernetes cluster.

    Cilium CNI – unified Kubernetes networking fabric and eBPF service mesh

    Cilium - Unified CNI and eBPF-based service mesh

    (source: “How eBPF will solve Service Mesh – Goodbye Sidecars | Isovalent Blog”)

    Cilium CNI includes advanced L7 networking, security and observability features out of the box comparable to that offered by a traditional service mesh. The Cilium datapath is implemented as eBPF programs which run in the kernel for performance at scale. Furthermore, to ensure the security of eBPF programs and that they do not crash or hang the kernel, all eBPF programs must pass the verifier before compiling into bytecode dynamically loaded into the kernel at runtime. All of this means that you get the features expected with a service mesh for free, embedded right within the CNI itself.

    Conclusion

    Traditional service meshes running in user-space are not only inefficient but also pose some serious security risks. We saw that:

    1. With the classic sidecar architecture, Pods meshed by both Istio and Linkerd require the cluster administrator to forego workload and host-level security entirely for all meshed namespaces, allowing attackers to escalate their privileges trivially once they get hold of privileges to deploy workloads in any meshed namespace
    2. With CNI mode enabled, meshed namespaces can lock down host-level security with the restricted PSS but attackers can still leverage the service mesh reserved UID to intercept sensitive traffic and perform data exfiltration
    3. With Istio ambient mesh, the service mesh reserved UID is no longer a concern but nevertheless additional components imply a greater attack surface compared to a fully embedded service mesh
    4. With Cilium CNI, the service mesh is embedded in the CNI implementation itself which boasts incredible performance and security guarantees

    I hope you enjoyed this article and stay tuned for updates 😉

    + , , ,