バイナリ肥大化という名の技術的負債:なぜGCCのデフォルトを疑うべきか
組み込みシステム、エッジAI、あるいはコンテナイメージのサイズを極限まで削る必要があるマイクロサービスの世界において、バイナリの肥大化は常にエンジニアの頭を悩ませる問題だ。
「Hello World」を出力するだけのCプログラムを書いて、何の変哲もないコマンド `-o app main.c` でコンパイルしたことはないだろうか? 生成されたバイナリを覗くと、驚くほど巨大なフットプリントに直面する。その大部分は、あなたが書いたコードではない。標準ライブラリやランタイム初期化コード(`crt0.o`等)が持ち込んだ「一度も呼ばれない死んだコード(Dead Code)」の残骸なのだ。
一般的な開発者は「コンパイラが最適化してくれているはずだ」と盲信する。しかし、GCCとGNU Binutils(GNU Linker `ld`)のデフォルト挙動は、後方互換性とリンク速度の最大化に重きが置かれている。安全地帯に留まる限り、モダンなコンパイラが持つ真のサイズ削減ポテンシャルを引き出すことはできない。
本稿では、`-ffunction-sections`、`-fdata-sections`、そしてリンカフラグ `–gc-sections` を組み合わせ、バイナリサイズを物理的限界まで削ぎ落とすメカニズムを、ELFバイナリの内部構造レベルから紐解く。さらに、これをプロダクションのCI/CDパイプラインやDocker環境に完全自動統合するための実践的知見を提示する。
—
ELFセクションの深層:なぜ「死んだコード」は消えないのか?
GCCがソースコードをコンパイルする際、デフォルトでは同一の翻訳単位(Translation Unit:`.c`ファイル)内にあるすべての関数を `.text` という単一のELFセクションにまとめ、すべてのグローバル変数を `.data` や `.bss` セクションに集約する。
[ 通常のコンパイル ]
main.c —> [ .text セクション ]
├─ int used_func() <-- 使う
└─ int dead_func() <-- 使わない(でも同じセクション!)
この状態の何が問題か? リンカ(`ld`)の仕事の基本単位は「セクション」であって「関数」ではない。たとえ `dead_func()` がどこからも参照されていなかったとしても、`used_func()` と同じ `.text` セクションに相乗りしている限り、リンカは `.text` セクション全体を最終的な実行ファイルにコピーせざるを得ない。これが、使われていないコードがバイナリに残留する根本原因である。
セクションの粒度を「関数・変数単位」へ爆撃的に分解する
この結合を断ち切るのが、コンパイラフラグ `-ffunction-sections` と `-fdata-sections` である。
gcc -ffunction-sections -fdata-sections -c main.c
このフラグを渡すと、GCCの挙動は一変する。関数ごとに `.text.関数名` という専用の独立したELFセクションが生成され、データも `.data.変数名` や `.bss.変数名` のように細分化される。
[ -ffunction-sections 適用後 ]
main.c —> ├─ [ .text.used_func セクション ]
└─ [ .text.dead_func セクション ] <-- 独立!
これにより、リンカに対して「どの関数が生きているか(参照されているか)」を極小の粒度で評価する下準備が整う。
---
リンカの魔術:`–gc-sections` によるガベージコレクション
セクションが細分化されただけでは、まだバイナリは小さくならない。ここで登場するのが、GNU Linker(あるいはLLD)のフラグである `–gc-sections`(Garbage Collection of Sections)だ。
gcc -Wl,–gc-sections -ffunction-sections -fdata-sections -c main.c -o app
ガベージコレクションの内部アルゴリズム
リンカが `–gc-sections` を受け取ったとき、内部で何が起きているのか。そのワークフローはモダンな言語のガベージコレクタ(GC)と酷似している。
1. ルート集合の特定(Roots Identification):
プログラムのエントリーポイント(通常は `main` 関数や `_start`)を「ルート(Root)」としてマークする。
2. 参照グラフの構築(Reference Graph Traversal):
ルートから開始し、シンボル間の参照関係(Relocation entries)を辿って、どのセクションがどのセクションを呼んでいるかの有向グラフを構築する。
3. 到達可能性解析(Reachability Analysis):
グラフを走査し、どのルートからも到達不可能な(Referenceが張られていない)セクションをすべて「孤立した死んだセクション」としてマークする。
4. スイープ(Sweeping):
最終的なバイナリ(ELF)のレイアウト構築時に、マークされた不要なセクションを完全に排除する。
この仕組みにより、巨大なサードパーティ製静的ライブラリ(SDKやMathライブラリなど)から「たった1つの関数」しか使っていなかったとしても、残りの数千の関数はすべてバイナリから綺麗に消え去る。
—
限界への挑戦:さらなるサイズ削減を引き出す追加オプション
`-ffunction-sections` と `–gc-sections` だけでも劇的な効果があるが、システムプログラミングやリソース制約の厳しい環境では、さらに踏み込んだ最適化が求められる。
1. `link-time optimization (LTO)` との統合
`-flto`(Link-Time Optimization)を有効にすると、コンパイル時にコードをマシン語に落とすのではなく、中間表現(IR: Intermediate Representation)のままリンカに渡し、プログラム全体を俯瞰した最適化を行う。
これにより、関数間のインライン展開が極限まで進み、結果として「参照されなくなった関数」がさらに増殖し、`–gc-sections` の効果を最大化する。
2. 未使用シンボルの強制削除と文字列プールの統合
最適化フラグの完全形
CFLAGS=”-Os -ffunction-sections -fdata-sections -flto”
LDFLAGS=”-Wl,–gc-sections -Wl,–icf=all -flto”
ここで `–lfc=all`(Identical Code Folding)に注目してほしい。これは、「完全に同じ機械語命令列を持つ関数同士」をリンカが検出し、メモリ上で1つに統合するという極限のテクニックだ。例えば、異なる名前だが中身が同じエラーハンドラ関数などが複数存在する場合、それらを一つに折り畳み、さらなる省サイズ化を実現する。
—
実践:CI/CDパイプラインとDockerへの完全自動統合
理論は分かった。ではこれを、日々の開発フローやCI/CDパイプラインにどうシームレスに組み込むか。ここでは、マルチステージビルドを活用したDocker環境での完全自動化構成を示す。
堅牢な Makefile の設計
まずは、この最適化フラグを標準装備した `Makefile` の模範実装を示す。デバッグビルドとリリースビルドを完全に分離し、リリース時のみ極限のサイズ削減を行う。
コンパイラとフラグの定義
CC = gcc
CFLAGS_COMMON = -Wall -Wextra
リリース時のみ極限のセクション分割とサイズ最適化を適用
CFLAGS_RELEASE = -Os -ffunction-sections -fdata-sections -flto $(CFLAGS_COMMON)
CFLAGS_DEBUG = -O0 -g3 $(CFLAGS_COMMON)
LDFLAGS_RELEASE = -Wl,–gc-sections -Wl,–icf=all -flto
LDFLAGS_DEBUG =
TARGET = app
SRC = main.c utils.c
デフォルトはリリースビルド
all: release
release: CFLAGS = $(CFLAGS_RELEASE)
release: LDFLAGS = $(LDFLAGS_RELEASE)
release: $(TARGET)
debug: CFLAGS = $(CFLAGS_DEBUG)
debug: LDFLAGS = $(LDFLAGS_DEBUG)
debug: $(TARGET)
$(TARGET): $(SRC)
@echo “==> Building $(TARGET) with flags: $(CFLAGS)”
$(CC) $(CFLAGS) $(SRC) -o $(TARGET) $(LDFLAGS)
@echo “==> Binary size:”
@ls -lh $(TARGET)
clean:
rm -f $(TARGET)
.PHONY: all release debug clean
Dockerfile による再現性の高いビルド環境
CI/CD環境やコンテナベースのデプロイにおいて、ホストのGCCバージョン差異によるバイナリ破損を防ぐため、マルチステージビルドを用いたDocker構成を採用する。ここではビルド用コンテナでコンパイルを行い、実行用コンテナには不要なツールを一切持ち込まない。
==========================================
ステージ1: ビルド環境 (Build Stage)
==========================================
FROM debian:bookworm-slim AS builder
必要なビルドツールチェーンのインストール
RUN apt-get update && apt-get install -y –no-install-recommends \
build-essential \
gcc \
make \
&& rm -rf /var/lib/apt/lists/
WORKDIR /workspace
ソースコードのコピー
COPY . .
極限の最適化ビルドの実行
RUN make release
==========================================
ステージ2: ランタイム環境 (Runtime Stage)
==========================================
FROM gcr.io/distroless/base-debian12
ビルドステージから極小化されたバイナリのみを抽出・コピー
COPY –from=builder /workspace/app /app
エントリーポイントの設定
ENTRYPOINT [“/app”]
この構成により、Googleが提供する極限まで安全性を高めたディストロレス(Distroless)イメージ上に、不要なセクションをすべて削ぎ落とした純粋なバイナリだけを配置することが可能となる。
—
ベンチマークと検証:どれほどの効果があるのか?
実際に、簡単なロギング関数や数学関数を含むライブラリ群をインクルードし、使っていない関数を意図的に大量に放置したコードベースで比較検証を行ってみよう。
検証コマンドによるサイズ比較
1. デフォルトビルド(何も最適化しない)
$ gcc main.c -o app_default
$ ls -lh app_default
-rwxr-xr-x 1 root root 16K May 20 10:00 app_default
2. セクション除去・GC適用ビルド
$ gcc -Os -ffunction-sections -fdata-sections -Wl,–gc-sections main.c -o app_optimized
$ ls -lh app_optimized
-rwxr-xr-x 1 root root 8.2K May 20 10:00 app_optimized
およそ 50%近いバイナリサイズの削減 に成功している。これを巨大な商用C/C++プロジェクト(数万ファイル規模)に適用した場合、数MBから数十MB単位でのバイナリ軽量化となり、コンテナのプッシュ/プル時間の短縮、Kubernetesノードにおけるメモリフットプリントの改善など、インフラコストとデプロイ速度の双方に計り知れない利益をもたらす。
—
トラブルシューティング:`–gc-sections` の罠と対策
極限の最適化には常にリスクが伴う。`–gc-sections` を導入した際に遭遇しがちな代表的なトラブルとその解決策を記す。
1. 動的リンクやプラグイン機構でのシンボル消失
プラグインアーキテクチャを採用しており、`dlopen` 等動的にロードされる共有ライブラリから参照される関数がある場合、リンカのルート解析から漏れ、関数が勝手に削除されて実行時セグメンテーション違反(Segmentation Fault)を起こすことがある。
対策:
外部から動的に参照されることが決まっているシンボルには、GCCの拡張構文である `__attribute__((visibility(“default”)))` や `__attribute__((used))` を付与し、リンカに対して「絶対にGCするな」と明示的に指示する。
// 動的ロードされるためGCから保護する
__attribute__((used)) void plugin_entry_point(void) {
// 処理
}
2. 静的コンストラクタ/デストラクタ (`__attribute__((constructor))` 等) の消滅
モジュール初期化時に自動実行される関数も、通常の静的解析では「どこからも呼ばれていない(死んだコード)」と判定されやすい。
対策:
同様に `__attribute__((used))` を付与するか、リンカースクリプト側で `–undefined` オプションを用いて強制的にシンボルを保持させる。
—
アーキテクトからの提言
バイナリの肥大化は、単にディスク容量やネットワーク帯域を圧迫するだけではない。コードの局所性(Locality of Reference)を悪化させ、CPUキャッシュミスの増加を招くことで、実行パフォーマンスそのものを低下させる隠れたボトルネックである。
`-ffunction-sections`、`-fdata-sections`、そして `–gc-sections` の導入は、コンパイラとリンカに対するエンジニアの「意志表示」に他ならない。「私たちが書いたコードの意図を正確に汲み取り、無駄なものは一切残すな」というエンジニアリングの美学を、今日のビルドパイプラインに今すぐ実装してほしい。