大きなファイルのアップロードやバックアップで上り回線を使い切ると、ブラウザーや通話などの通信に影響することがあります。ここではLinuxのネットワーク名前空間とtcを使い、指定したコマンドの送信帯域を30 Mbit/sに制限します。
アプリケーション側に帯域制限の設定がなくても利用できる方法です。指定したコマンドとその子プロセスを、帯域制限のあるネットワーク環境で実行します。
仕組み
コマンドを専用のネットワーク名前空間bwlimitで実行します。ホストと名前空間を仮想インターフェースのペアで接続し、名前空間側の送信口bwnetにToken Bucket Filter(TBF)を設定します。
指定したコマンド
→ 名前空間 bwlimit
→ bwnet [tc: 30 Mbit/s]
→ ホスト側 bwhost
→ NAT・転送
→ 通常のネットワークインターフェース
→ インターネット
制限するのは、この名前空間から出ていくパケットです。通常の環境で動くアプリの送信帯域には、この制限を直接適用しません。ただし、インターネット回線は共用なので、設定値が回線の上り容量に近すぎると、ほかのアプリへの影響は残ります。
30 Mbit/sは、バイト換算で約3.75 MB/sです。TCP/IPなどのヘッダーも帯域に含まれるため、ファイルの実際の転送速度はこれより低くなります。TBFは短いバーストを許しつつ平均的な送信速度を制御します。詳しい設定はtc-tbfのマニュアルにあります。
準備
スクリプトを~/work/bw-limit/bw-limit.shに置き、実行権限を付けます。
chmod +x ~/work/bw-limit/bw-limit.sh
必要なコマンドはBash、sudo、ip、tc、iptables、sysctlです。Debian/Ubuntuでは、必要に応じて次のパッケージを導入します。
sudo apt update
sudo apt install iproute2 iptables
ネットワーク名前空間や転送ルールの設定には管理者権限が必要です。一方、runで起動するコマンドは、sudoを呼び出した一般ユーザーに戻して実行します。ホームディレクトリに保存された設定や認証情報は、その一般ユーザーのものを利用します。
このスクリプトはIPv4のNAT構成を使います。10.203.77.0/30がLAN、VPN、コンテナーなどの既存ネットワークと重ならないことを確認してください。スクリプトの確認は同じプレフィックスの経路がある場合に限られ、より広い経路との重複までは検出しません。
1. 30 Mbit/sの制限を用意する
一般ユーザーのターミナルから実行します。
sudo env UPLOAD_RATE=30mbit ~/work/bw-limit/bw-limit.sh setup
setupは名前空間、仮想インターフェース、DNS、IPv4転送、NATルール、TBFを設定します。既存のIPv4転送設定は、後片付けで復元できるように/run/bw-limitへ保存します。
DNSの初期値は1.1.1.1です。社内ネットワークなどで別のDNSが必要なら、到達可能なDNSサーバーのIPv4アドレスを指定します。
sudo env UPLOAD_RATE=30mbit UPLOAD_DNS=192.168.1.1 \
~/work/bw-limit/bw-limit.sh setup
ここでは名前空間に一つの帯域制限を設定するため、複数のコマンドを起動した場合は、すべての送信トラフィックが合計30 Mbit/sの枠を共有します。
2. 制限付きでコマンドを実行する
基本形は次のとおりです。
sudo ~/work/bw-limit/bw-limit.sh run -- \
/absolute/path/to/command arg1 arg2
仮想環境内のプログラムは絶対パスで指定します。sudoの設定によっては、起動元のPATHや環境変数が引き継がれないためです。引数で渡すパスに空白がある場合は引用符で囲んでください。
通常の環境ですでに動いているアップロードには、この制限は適用されません。元のアップロードを停止してから、このコマンドで再実行します。途中まで転送したデータを再利用できる範囲は、アップローダーの実装に依存します。
パイプやリダイレクトを含む処理は、シェルを明示して実行します。
sudo ~/work/bw-limit/bw-limit.sh run -- /bin/bash -c \
'/absolute/path/to/uploader input.dat > upload.log 2>&1'
名前空間内の127.0.0.1はホストのループバックとは別です。ホスト側のローカルプロキシに接続するコマンドなどは、接続先の調整が必要です。
3. 状態を確認する
アップロード中に、別のターミナルで実行します。
sudo ~/work/bw-limit/bw-limit.sh status
tc -s qdiscの出力で、設定されたrate 30Mbit、送信済みのバイト数、待機中のパケットなどを確認できます。これは累積の統計であり、そのまま瞬間的なMB/sを示すものではありません。overlimitsはレート制御のために送信を待たせた回数として増えることがあり、通信失敗を直接意味するものではありません。
名前空間内で動いているプロセスのPIDも表示されます。
4. 終了後に後片付けする
コマンドが終了してから実行します。
sudo ~/work/bw-limit/bw-limit.sh cleanup
この処理は、追加したiptablesルール、名前空間、仮想インターフェース、DNS設定、保存した状態を削除し、IPv4転送設定をsetup前の値へ戻します。名前空間内にプロセスが残っている場合は、後片付けを拒否します。先に実行中のコマンドを終了してください。
IPv4転送設定はホスト全体に関わります。この作業中に別の用途で転送設定を変更した場合は、保存した値へ戻してよいか確認してください。
帯域の変更とトラブル時の確認
動作中の上限を20 Mbit/sに変更するには、TBFの設定を更新します。
sudo ip netns exec bwlimit tc qdisc change dev bwnet root tbf \
rate 20mbit burst 256kb latency 100ms
次回のsetupにも同じ値を使う場合は、UPLOAD_RATE=20mbitを指定します。
通信できない場合は、まずstatusで名前空間と制限が存在するか確認し、DNSやホスト側の転送設定を確認します。UFW、firewalld、VPNなどが独自に転送を制御している環境では、iptablesに追加した許可ルールだけでは接続できないことがあります。このスクリプトはそれらの管理ツールの設定を自動変更しません。
setupが「状態が残っている」と表示した場合は、残っているコマンドを停止したうえでcleanupし、再度setupします。名前空間の作成とコマンド実行の詳細はip-netnsのマニュアルを参照してください。
#!/usr/bin/env bash
set -euo pipefail
NS=bwlimit
HOST_IF=bwhost
NS_IF=bwnet
SUBNET=10.203.77.0/30
HOST_IP=10.203.77.1
UPLOAD_IP=10.203.77.2
STATE=/run/bw-limit
RATE=${UPLOAD_RATE:-30mbit}
DNS=${UPLOAD_DNS:-1.1.1.1}
root() { if (( EUID != 0 )); then echo 'Use sudo for this stage.' >&2; exit 1; fi; }
load_state() {
[[ -f "$STATE/interface" ]] || { echo 'Run setup first.' >&2; exit 1; }
read -r UPLOAD_IF < "$STATE/interface"
}
remove_rules() {
iptables -t nat -C POSTROUTING -s "$UPLOAD_IP/32" -o "$UPLOAD_IF" -j MASQUERADE 2>/dev/null &&
iptables -t nat -D POSTROUTING -s "$UPLOAD_IP/32" -o "$UPLOAD_IF" -j MASQUERADE || true
iptables -C FORWARD -i "$HOST_IF" -o "$UPLOAD_IF" -s "$UPLOAD_IP/32" -j ACCEPT 2>/dev/null &&
iptables -D FORWARD -i "$HOST_IF" -o "$UPLOAD_IF" -s "$UPLOAD_IP/32" -j ACCEPT || true
iptables -C FORWARD -i "$UPLOAD_IF" -o "$HOST_IF" -d "$UPLOAD_IP/32" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null &&
iptables -D FORWARD -i "$UPLOAD_IF" -o "$HOST_IF" -d "$UPLOAD_IP/32" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT || true
}
cleanup() {
load_state
if [[ -n "$(ip netns pids "$NS" 2>/dev/null || true)" ]]; then
echo 'Namespace still has processes. Stop the running command before cleanup.' >&2
return 1
fi
remove_rules
ip netns del "$NS" 2>/dev/null || true
ip link del "$HOST_IF" 2>/dev/null || true
rm -f "/etc/netns/$NS/resolv.conf"
rmdir "/etc/netns/$NS" 2>/dev/null || true
if [[ -f "$STATE/forward" ]]; then
read -r old_forward < "$STATE/forward"
sysctl -w "net.ipv4.ip_forward=$old_forward"
fi
rm -f "$STATE/interface" "$STATE/forward"
rmdir "$STATE"
}
case "${1:-help}" in
setup)
root
for tool in ip tc iptables sysctl; do command -v "$tool" >/dev/null; done
[[ ! -e "$STATE" ]] || { echo 'State exists: run status or cleanup first.' >&2; exit 1; }
! ip link show "$HOST_IF" >/dev/null 2>&1 || { echo 'bwhost already exists.' >&2; exit 1; }
! ip netns list | awk '{print $1}' | grep -Fxq "$NS" || { echo 'bwlimit already exists.' >&2; exit 1; }
if ip -4 route show table all | awk '{print $1}' | grep -Fxq "$SUBNET"; then
echo "Subnet $SUBNET is already routed. Choose another subnet in the script." >&2; exit 1
fi
UPLOAD_IF=$(ip -4 route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')
[[ -n "$UPLOAD_IF" ]]
mkdir -m 700 "$STATE"
printf '%s\n' "$UPLOAD_IF" > "$STATE/interface"
sysctl -n net.ipv4.ip_forward > "$STATE/forward"
trap 'echo "Setup failed; rolling back." >&2; cleanup' ERR
ip netns add "$NS"
ip link add "$HOST_IF" type veth peer name "$NS_IF"
ip link set "$NS_IF" netns "$NS"
ip addr add "$HOST_IP/30" dev "$HOST_IF"
ip link set "$HOST_IF" up
ip netns exec "$NS" ip addr add "$UPLOAD_IP/30" dev "$NS_IF"
ip netns exec "$NS" ip link set "$NS_IF" up
ip netns exec "$NS" ip link set lo up
ip netns exec "$NS" ip route add default via "$HOST_IP"
mkdir -p "/etc/netns/$NS"
printf 'nameserver %s\n' "$DNS" > "/etc/netns/$NS/resolv.conf"
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s "$UPLOAD_IP/32" -o "$UPLOAD_IF" -j MASQUERADE
iptables -I FORWARD 1 -i "$HOST_IF" -o "$UPLOAD_IF" -s "$UPLOAD_IP/32" -j ACCEPT
iptables -I FORWARD 1 -i "$UPLOAD_IF" -o "$HOST_IF" -d "$UPLOAD_IP/32" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
ip netns exec "$NS" tc qdisc add dev "$NS_IF" root tbf rate "$RATE" burst 256kb latency 100ms
trap - ERR
echo "Ready: command namespace limited to $RATE via $UPLOAD_IF."
;;
run)
root
load_state
shift
if [[ "${1:-}" == -- ]]; then shift; fi
(( $# > 0 )) || { echo 'Usage: sudo bw-limit.sh run -- COMMAND [ARGUMENTS...]' >&2; exit 1; }
RUN_USER=${SUDO_USER:-}
[[ -n "$RUN_USER" && "$RUN_USER" != root ]] || { echo 'Run this stage with sudo from your normal user account.' >&2; exit 1; }
printf 'Running as %s with capped outgoing bandwidth: ' "$RUN_USER"
printf '%q ' "$@"
printf '\n'
# Use an absolute command path when invoking an executable in a virtual environment.
# The command runs as that user, preserving its working directory and saved credentials.
exec ip netns exec "$NS" sudo -H -u "$RUN_USER" env "PATH=$PATH" "$@"
;;
status)
root
load_state
ip netns exec "$NS" tc -s qdisc show dev "$NS_IF"
echo 'Processes in command namespace:'
ip netns pids "$NS"
;;
cleanup)
root
cleanup
echo 'Removed command namespace and rules; restored forwarding setting.'
;;
*)
echo "Usage: sudo $0 {setup|run -- COMMAND [ARGS...]|status|cleanup}"
echo 'Defaults: 30mbit; override setup with UPLOAD_RATE and UPLOAD_DNS.'
echo 'Example: sudo env UPLOAD_RATE=30mbit bash /tmp/bw-limit.sh setup'
echo 'Example: sudo /tmp/bw-limit.sh run -- /absolute/path/to/command arg1 arg2'
echo 'All concurrent commands in this namespace share the upload cap.'
;;
esac