Go言語でSNMPエージェントを実装する3つの方法【2026年版】
SNMPエージェントは、監視サーバー(SNMPマネージャー)から届くGETやGETNEXTを受け取り、自分が保持するMIBの値を返すサーバー側のプロセスです。Goで書いた自作アプリをそのまま監視対象に組み込みたいとき、多くの解説はgosnmpを勧めます。しかしgosnmpはREADMEが「SNMP client library」と明記するとおりマネージャー側のライブラリで、多くの解説が使うSnmpEncodePacketではエージェントの応答を組み立てられません。この記事では、公開APIのMarshalMsgを使えばどこまで自作できて、どこからが自前になるのかをv1.44.0のソースと実機で確認したうえで、Goでエージェントを成立させる3つの方式を実装コードつきで比較します。検証環境はGo 1.26.5とgosnmp v1.44.0で、動作確認にはnet-snmpのsnmpgetとsnmpwalkを使っています。
まとめ
結論から言うと、gosnmpでエージェントを自作すること自体は可能です。ただし多くの解説が使うSnmpEncodePacketでは応答を組み立てられず、公開APIのMarshalMsgを使う必要があります。そのうえでGETNEXTの辞書順やSNMPv3の認証・暗号をすべて自前で書くことになるため、実運用では次の3方式のいずれかを推奨します。
- GoSNMPServer:Goだけで完結する常駐エージェント。snmpdが不要で、SNMPv2c/v3とGET・GETNEXT・GETBULK・WALK・BULKWALK・SETに対応し、IPv4とIPv6の両方で動きます。
- net-snmpのpass_persist:稼働中のsnmpdに標準入出力で相乗りする最小構成。Goの標準ライブラリだけで実装でき、追加依存はゼロです。
- AgentX(RFC 2741):snmpdへTCPまたはUnixソケットで接続する常駐サブエージェント。複数プロセスでMIBツリーを分担する構成に向きます。
どれを選ぶかは「監視対象サーバーですでにsnmpdが動いているか」でほぼ決まります。以下ではまずSNMPエージェントの役割を整理し、gosnmpで自作した場合にどこまで自前になるかをソースと実機で確認したうえで、各方式の実装に進みます。
SNMPエージェントの役割とマネージャーとの通信の流れ
SNMPは、監視する側(マネージャー)と監視される側(エージェント)が役割を分けるプロトコルです。エージェントはルーターやサーバーに常駐し、自分の状態をMIBという決められた形式で公開します。ネットワーク機器の場合はファームウェアにエージェントが内蔵されており、管理者は設定で有効化するだけです。自作アプリを監視対象にしたい場合だけ、このエージェントを自分で用意する必要が生じます。
GET・GETNEXT・SETへの応答とTRAPの送信
エージェントが処理する要求は3種類です。GETは指定されたOIDの値をそのまま返し、GETNEXTは指定OIDの次に来るOIDとその値を返し、SETは書き込み可能なOIDの値を更新します。応答はいずれもGetResponse(PDU種別0xa2)というひとつの型で返します。
これとは別に、エージェントから能動的に送るのがTRAPです。TRAPは応答ではなく通知なので、要求を待たずにマネージャーへ送りつけます。確認応答が欲しい場合はSNMPv2c以降のINFORMを使います。エージェントを自作するときに実装量が増えるのはGETNEXTで、後述するとおりOIDの並び順を自前で管理する必要があるためです。
UDP 161番と162番の役割分担
SNMPはUDPを使い、ポートを用途で分けています。エージェントが待ち受けるのが161番、マネージャーがTRAPやINFORMを受け取るのが162番です。つまりエージェントを自作するということは、161番でUDPを待ち受けるプロセスを立てることを意味します。
Linuxでは1024番未満が特権ポートなので、161番のバインドにはroot権限か相応のケーパビリティが要ります。開発中は1161番など非特権ポートで動かし、本番でのみ161番へ切り替える運用が扱いやすく、後述のsetcapで権限問題を回避できます。
MIBツリーとOIDの辞書順
MIBは管理対象の情報を木構造で定義したもので、その節点を数値の並びで指したものがOIDです。たとえば1.3.6.1.2.1.1.1.0はsysDescr(機器の説明文)を指します。ベンダー独自の値を公開する場合は、IANAから割り当てられた企業番号の下(1.3.6.1.4.1.企業番号)に自分で枝を切ります。
ここで見落とされがちなのが、GETNEXTが返す「次」は木構造の辞書順であって、文字列としての大小ではないという点です。1.3.6.1.4.1.9.1と1.3.6.1.4.1.10.1を文字列比較すると10が9より前に来てしまい、snmpwalkが途中で打ち切られます。OIDを比較するときは必ずドットで分割し、数値の配列として先頭から比べてください。自社で管理する機器のOIDや資産情報を台帳側で整理する場合は、IPAMとDCIMを一元管理するNetBoxのようなツールと突き合わせておくと、公開するMIBの設計が固まりやすくなります。
gosnmpで自作した場合に自前になる範囲
「gosnmpにエージェント機能を追加する」という方針の解説は少なくありません。実際にv1.44.0(2026年7月16日リリース)で試すと、多くの解説が使う関数では応答を組み立てられない一方、別の公開APIを使えば成立します。どこまでがライブラリの守備範囲で、どこからが自前になるのかをソースで確認します。
SnmpEncodePacketが応答生成に使えない理由
パケット生成を外部へ公開している関数として目に付くのがSnmpEncodePacketですが、実際のシグネチャはSnmpPacketを受け取りません。
// gosnmp v1.44.0 gosnmp.go:514
func (x *GoSNMP) SnmpEncodePacket(pdutype PDUType, pdus []SnmpPDU, nonRepeaters uint8, maxRepetitions uint32) ([]byte, error)
// gosnmp.go:523-525(関数内部の抜粋)
reqID := (atomic.AddUint32(&(x.requestID), 1) & 0x7FFFFFFF)
pkt.RequestID = reqID
問題は最後のpkt.RequestID = reqIDです。SNMPの応答は、マネージャーが送ってきたRequestIDをそのまま返すことで要求と対応づけられます。ところがSnmpEncodePacketは内部のカウンタで採番し直すため、こちらが指定した値は必ず捨てられます。加えて、パケットを組み立てるmkSnmpPacket(gosnmp.go:415)がError: 0, ErrorIndex: 0を固定で書き込むため、noSuchNameのようなエラー応答も返せません。この関数は名前に反して、エージェント用途では使えません。
MarshalMsgによる自作と、そこから増える実装範囲
ただし手がないわけではありません。SnmpPacketを自分で組み立てて直接マーシャルするMarshalMsg(marshal.go:507)が公開されており、こちらはRequestIDもErrorも指定した値のまま通ります。gosnmpをforkする必要はありません。
const appVersionOID = "1.3.6.1.4.1.99999.1.1.0"
x := &gosnmp.GoSNMP{Version: gosnmp.Version2c, Community: "public"}
req, err := x.SnmpDecodePacket(buf[:n])
if err != nil {
continue
}
resp := &gosnmp.SnmpPacket{
Version: req.Version,
Community: req.Community,
PDUType: gosnmp.GetResponse,
RequestID: req.RequestID, // 要求のIDをそのまま返す
}
if len(req.Variables) > 0 && req.Variables[0].Name == "."+appVersionOID {
resp.Variables = []gosnmp.SnmpPDU{{
Name: "." + appVersionOID, Type: gosnmp.OctetString, Value: "issoh-app 1.0.0",
}}
} else {
resp.Error = gosnmp.NoSuchName // エラー応答も指定できる
resp.ErrorIndex = 1
resp.Variables = req.Variables
}
out, err := resp.MarshalMsg()
if err != nil {
continue
}
conn.WriteToUDP(out, peer)
これをUDPの待ち受けループに入れると、net-snmpのクライアントから実際に値が引けます。存在しないOIDに対しても正しくエラーが返ります。
$ snmpget -v2c -c public 127.0.0.1:1161 1.3.6.1.4.1.99999.1.1.0
SNMPv2-SMI::enterprises.99999.1.1.0 = STRING: "issoh-app 1.0.0"
$ snmpget -v2c -c public 127.0.0.1:1161 1.3.6.1.4.1.99999.9.9.9
Error in packet
Reason: (noSuchName) There is no such variable name in this MIB.
ここまでは40行ほどで書けます。問題はこの先です。GETNEXTに答えるにはOIDを辞書順に並べて「次」を返す仕組みが要り、GETBULKではNonRepeatersとMaxRepetitionsの解釈を実装することになります。SNMPv3に対応するならUSMの認証と暗号に加えて、EngineIDとEngineBoots・EngineTimeの管理まで自分の責任範囲です。gosnmpが肩代わりしてくれるのはパケットのエンコードとデコードだけで、エージェントとしての振る舞いはすべて自作になります。
TrapListenerとSendTrapの守備範囲
gosnmpにはサーバー的な機能としてTrapListener(trap.go:118)があり、これをエージェント機能と取り違えた解説が見られます。しかしTrapListenerはエージェントから送られてくるTRAPを受信する側、つまり監視サーバーの部品です。GETに応答する用途には使えません。
一方、エージェントからTRAPを送る用途ではSendTrap(trap.go:33)がそのまま使えます。つまりgosnmpは、要求への応答には向かないものの、通知の送信にはエージェント側からでも問題なく使えるライブラリです。後述のGoSNMPServerと組み合わせる場合も、TRAPの送信はgosnmpのSendTrapが担当します。
Goでエージェントを実装する3方式の比較と選定基準
現実的な選択肢は次の3つです。差が出るのは常駐の形態と、MIBツリーをどの粒度で分担するかという2点です。
| 方式 | 追加依存 | snmpd | 対応バージョン | 本記事の実装例 | 向く場面 |
|---|---|---|---|---|---|
| GoSNMPServer | slayercat/GoSNMPServer |
不要 | v2c / v3 | 28行 | コンテナ単体で監視対象化 |
| pass_persist | なし(標準ライブラリ) | 必要 | snmpdの設定に従う | 52行 | 既存サーバーへ数値を数個追加 |
| AgentX | posteo/go-agentx |
必要 | snmpdの設定に従う | 非掲載 | 複数プロセスでMIBを分担 |
ここで立場を明確にしておきます。gosnmpだけで自作する方針は、どの場面でも推奨しません。前章のとおり応答を返すところまでは40行ほどで到達できますが、そこから先のGETNEXT・GETBULK・SNMPv3をすべて自作する規模を考えれば、既存実装を使わない理由はほぼありません。特にfork前提の解説を見かけたら、MarshalMsgが公開されている以上forkは不要なので、その時点で情報が古いと判断できます。
3方式のうち最も導入が軽いのはpass_persistです。追加ライブラリが不要で、公開したい値が数個であれば数十行で済みます。逆に、コンテナ1つで完結させたい、あるいはsnmpdを入れられない環境ではGoSNMPServerが唯一の選択肢になります。
GoSNMPServerによる単体エージェントの実装
GoSNMPServerは、SNMPサーバー側をGoだけで実装したライブラリです。READMEのとおりGet・GetNext・GetBulk・Walk・BulkWalk・SetとSNMPv2c/v3に対応し、IPv4とIPv6のどちらでも待ち受けられます。OIDごとにOnGetコールバックを登録する設計なので、値の生成をコールバックに閉じ込めれば、アプリの内部状態をそのままMIBとして公開できます。なおREADMEにあるTrapはトラップの受信を指します。エージェントからTRAPを送る場合は前章のgosnmpのSendTrapを併用してください。
package main
import (
"github.com/gosnmp/gosnmp"
"github.com/slayercat/GoSNMPServer"
"github.com/slayercat/GoSNMPServer/mibImps"
)
func main() {
items := append(mibImps.All(), &GoSNMPServer.PDUValueControlItem{
OID: "1.3.6.1.4.1.99999.1.1.0",
Type: gosnmp.OctetString,
OnGet: func() (interface{}, error) {
return GoSNMPServer.Asn1OctetStringWrap("issoh-app 1.0.0"), nil
},
Document: "アプリのバージョン文字列",
})
master := GoSNMPServer.MasterAgent{
Logger: GoSNMPServer.NewDefaultLogger(),
SecurityConfig: GoSNMPServer.SecurityConfig{NoSecurity: true},
SubAgents: []*GoSNMPServer.SubAgent{
{CommunityIDs: []string{"public"}, OIDs: items},
},
}
server := GoSNMPServer.NewSNMPServer(master)
if err := server.ListenUDP("udp", "0.0.0.0:1161"); err != nil {
panic(err)
}
server.ServeForever()
}
mibImps.All()はdismanEventMib・ifMib・ucdMibの一般的なOIDをまとめて返すヘルパーで、これに独自OIDを追加する形になります。Asn1OctetStringWrapのような型ラッパーは、Goの値をSNMPの型へ対応づけるために用意されており、整数ならAsn1IntegerWrap、カウンタならAsn1Counter64Wrapを使います。SETに対応させたい項目にはOnSetを、読み取り専用にしたい項目にはOnSetを省略します。
常駐させる前にNewDefaultLogger()は差し替えてください。実装はログレベルをTraceに設定して標準出力へ書くため、リクエストごとにパケットの中身がそのままダンプされます。本番ではNewDiscardLogger()にするか、WrapLogrus()でレベルを絞ります。
もうひとつ、リリースタグはv0.5.2(2024年3月27日)で止まっている一方、masterへのコミットは2026年5月7日まで続いています。go.modがgosnmp v1.36.2相当をpinしているため、同じプロジェクトでgosnmpの最新版をマネージャー用途にも使う場合はバージョンの整合を確認してください。
特権ポート161をrootなしで開く方法
上の例が1161番で待ち受けているのは、161番が特権ポートだからです。本番で161番を使う場合、rootで動かす代わりに次のどちらかで対処します。
# 実行ファイルに低位ポートのバインド権限だけを付与する
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/goagent
# あるいは非特権で使える下限を下げる(ホスト全体に影響する)
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=161
影響範囲が実行ファイル1つに閉じるsetcapを優先してください。sysctlの方はホスト上の全プロセスに対して161番以上を非特権で開放するため、他のサービスが意図せず低位ポートを掴めるようになります。なおコンテナで動かす場合は、Dockerなら--cap-add=NET_BIND_SERVICEを付ける方法もあります。
net-snmpのpass_persistによる最小構成の実装
監視対象のサーバーですでにsnmpdが動いているなら、pass_persistが最短です。指定したOID配下への問い合わせが来たとき、snmpdが外部プログラムを常駐起動して標準入出力でやり取りします。プロトコルはmanページに定義されており、起動時にPINGが来たらPONGを返し、以降は2行(コマンドとOID)を受けて3行(OID・型・値)を返すだけです。対応しないOIDにはNONEを返します。
package main
import (
"bufio"
"fmt"
"os"
"runtime"
"strings"
)
const targetOID = ".1.3.6.1.4.1.99999.1.2.0"
func respond(w *bufio.Writer) {
// OID・型・値の3行を返す。型は integer, gauge, counter, timeticks, ipaddress, objectid, string から選ぶ
fmt.Fprintf(w, "%s\ngauge\n%d\n", targetOID, runtime.NumGoroutine())
}
func main() {
in := bufio.NewScanner(os.Stdin)
out := bufio.NewWriter(os.Stdout)
for in.Scan() {
switch strings.TrimSpace(in.Text()) {
case "PING":
fmt.Fprintln(out, "PONG")
case "get":
if !in.Scan() {
return
}
if strings.TrimSpace(in.Text()) == targetOID {
respond(out)
} else {
fmt.Fprintln(out, "NONE")
}
case "getnext":
if !in.Scan() {
return
}
// 公開するOIDが1つなので単純比較で足りる。
// 複数OIDを扱う場合は文字列比較ではなくドット区切りの数値配列で比較する
if strings.TrimSpace(in.Text()) < targetOID {
respond(out)
} else {
fmt.Fprintln(out, "NONE")
}
case "set":
// setはOIDと「型 値」の2行が続く。読み捨てないと以降の応答が1行ずつずれる
if !in.Scan() || !in.Scan() {
return
}
fmt.Fprintln(out, "not-writable")
default:
fmt.Fprintln(out, "NONE")
}
out.Flush()
}
}
ビルドした実行ファイルをsnmpd.confに登録します。第1引数が担当するOIDの根、第2引数が実行ファイルの絶対パスです。
# /etc/snmp/snmpd.conf
pass_persist .1.3.6.1.4.1.99999 /usr/local/bin/goagent
コード中のコメントで触れたとおり、公開するOIDが増えたら文字列比較は使えません。前章で述べた辞書順の問題がそのまま出るため、OIDを数値配列に分解して比較する関数へ差し替えてください。実装が軽い代わりに、GETNEXTの正しさを自分で担保する必要があるのがこの方式の弱点です。
setの分岐を省略しないことも重要です。SETではOIDと「型 値」の2行が続くため、読み捨てずに1行だけ返すと、以降すべての応答が2行ずれたまま復帰しません。書き込みを許可しないならnot-writableを1行返すのが正しい応答です。
AgentXサブエージェントの採用判断
AgentXはRFC 2741で定義された、snmpd(マスターエージェント)とサブエージェントを接続するプロトコルです。Goではposteo/go-agentxが使え、サブエージェントはTCPまたはUnixソケットでsnmpdへ接続し、自分が担当するOID範囲を登録します。
pass_persistとの違いは常駐の仕方です。pass_persistではsnmpdが子プロセスを起動して標準入出力で会話するのに対し、AgentXでは自分のプロセスとして常駐したままsnmpdへ接続します。アプリ本体が持っている状態(接続数やキュー長など)をそのまま公開したい場合、プロセスを分けずに済むAgentXが自然です。
採用するなら保守状況の確認が先です。go-agentxはアーカイブされていないものの、最終コミットは2025年8月22日で、本記事の執筆時点から約1年更新がありません。しかもその最終コミットがAPIの再設計とグローバルロガーの削除という破壊的変更のため、最新タグのv0.3.0とmasterでインターフェースが異なります。本記事で実装例を載せないのはこの理由で、どちらを使うかによってコードが変わるため、採用時は必ず対象リビジョンのGoDocを確認してください。
逆に、公開したい値がコマンド一発で取れる程度なら、AgentXは過剰です。snmpdへの接続維持と再接続の処理が必要になるぶん、pass_persistより確実に複雑になります。メトリクス監視の設計と同様に、公開する項目の数と更新頻度から方式を決めてください。
動作確認でつまずきやすい失敗
エージェントが立ち上がったら、net-snmpのコマンドで実際に問い合わせて確認します。GETとWALKの両方を試すのが重要で、GETだけ通ってWALKが空になるのはGETNEXTの実装ミスの典型的な症状です。つまずきどころは大きく3つあります。
# 単一OIDの取得
snmpget -v2c -c public 127.0.0.1:1161 1.3.6.1.4.1.99999.1.1.0
# 配下を順にたどる(GETNEXTの実装確認)
snmpwalk -v2c -c public 127.0.0.1:1161 1.3.6.1.4.1.99999
ここで頻出する失敗が、動作確認用のテストコードをgosnmpのマネージャー機能で書くときに起きます。gosnmp.Defaultをそのまま使い、Connect()を呼ばずにGet()を呼ぶコードは、marshal.go:445の判定に引っかかり「&GoSNMP.Conn is missing. Provide a connection or use Connect()」というエラーで必ず失敗します。gosnmp.DefaultはPort: 161やCommunity: "public"の既定値だけを持つひな型で、接続先のTargetが空のままなので、Targetを設定したうえでConnect()を呼んでから使ってください。
残る2つは環境側の問題です。ひとつは前述の特権ポートで、161番でバインドできずに起動直後へ落ちる場合はsetcapの付与漏れを疑います。もうひとつはファイアウォールで、SNMPはUDPなので、TCPだけ開けて通らないという取り違えが起こります。クラウド上でネットワーク側の通信を確認する手段も併せて押さえておくと切り分けが早くなります。
よくある質問
SNMPエージェントとは何ですか?
監視される側の機器やサーバーに常駐し、マネージャーからのGET・GETNEXT・SETに応答して自分の状態を返すプロセスです。ネットワーク機器では標準搭載されているため設定で有効化するだけですが、自作アプリを監視対象にする場合は本記事の方式で自分で用意します。
gosnmpだけでSNMPエージェントは作れますか?
作れますが、実装範囲は広くなります。多くの解説が使うSnmpEncodePacketは内部でRequestIDを採番し直すため応答には使えず、公開APIのMarshalMsgでSnmpPacketを自分で組み立てる必要があります。GETに答えるだけなら40行ほどで動きますが、GETNEXTの辞書順・GETBULK・SNMPv3のUSMはすべて自作です。
SNMPエージェントはどのポートを使いますか?
待ち受けはUDPの161番です。エージェントからマネージャーへ送るTRAPとINFORMは162番宛てに送信します。161番はLinuxの特権ポートなので、rootで動かさない場合はsetcap 'cap_net_bind_service=+ep'を実行ファイルに付与します。
GoSNMPServerはSNMPv3に対応していますか?
対応しています。READMEのとおりSNMPv2cとSNMPv3をサポートし、SecurityConfigのUsersフィールドにgosnmp.UsmSecurityParametersを並べて、ユーザー名と認証・暗号プロトコルを設定します。v3ではAuthoritativeEngineBootsの設定も必要です。本記事の例は動作確認を優先してNoSecurity: trueにしているので、本番では必ずv3の認証を設定してください。
すでにnet-snmpのsnmpdが動いているサーバーでもGoでエージェントを追加できますか?
できます。むしろその場合はpass_persistかAgentXが適しています。snmpdが担当するOID配下への問い合わせだけを自作プログラムへ委譲する形になるため、既存の監視設定を壊さずに独自の値を追加できます。