SNMPエージェントは、監視サーバー(SNMPマネージャー)から届くGETやGETNEXTを受け取り、自分が持つMIBの値を返すサーバー側のプロセスです。Goで書いた自作アプリを監視対象に組み込もうとすると、まずgosnmpが候補に挙がりますが、READMEが「SNMP client library」と書くとおりマネージャー側のライブラリで、エージェントとしての振る舞いはほとんど自前です。この記事では、エージェントが守るべきGETNEXT・GETBULKの決まりを先に押さえ、そのうえでGoでエージェントを成立させる3つの方式をコードつきで比べます。コードはすべてGo 1.27.2、gosnmp v1.47.0、net-snmp 5.6.2.1のsnmpget・snmpdで動かして確認しています。
まとめ
Goでエージェントを作るなら、gosnmpだけで組むのはやめて次の3方式から選んでください。どれにするかは「監視対象のサーバーでsnmpdがすでに動いているか」でほぼ決まります。
- GoSNMPServer:snmpdなしでGoだけで完結する常駐エージェント。GET・GETNEXT・GETBULK・SETの処理はライブラリが受け持ちます。ただしSNMPv3の要求のHMACを検証しないことを実測で確かめたため、閉じたネットワークでのv2cか検証用に限って使います。
- net-snmpのpass_persist:稼働中のsnmpdに標準入出力で相乗りする最小構成。標準ライブラリだけで書けますが、GETNEXTの辞書順は自分で正しく実装する必要があります。
- AgentX(RFC 2741):snmpdへソケットで接続する常駐サブエージェント。Goのgo-agentxは2025年8月から更新が止まっているため、採用前に版を固定して検証します。
gosnmpは2026年10月23日のv1.47.0でSNMPv3の認証回避を含む重大な脆弱性を直しています。gosnmp v1.46.0以下でSNMPv3のクライアントや通知受信を実装している場合は、先に更新してください。SNMPv1/v2cだけの利用は、この勧告の影響対象外です。SNMPv3を本番で使うなら、認証と暗号をnet-snmpのsnmpdに任せられるpass_persistかAgentXを選んでください。
SNMPエージェントの役割とマネージャーとの通信の流れ
SNMPは、監視する側(マネージャー)と監視される側(エージェント)が役割を分けるプロトコルです。ルーターやスイッチではファームウェアにエージェントが入っており、設定で有効にするだけで使えます。マネージャー側の代表例はZabbixのような監視ツールで、ここから一定間隔でエージェントに値を問い合わせます。エージェントを自分で用意する必要が出るのは、自作アプリの内部状態を既存の監視基盤に載せたい場合です。監視項目そのものの考え方は死活監視と性能監視の違いを整理した記事にまとめています。
RFC 3411でのエージェントの定義
SNMPv3のアーキテクチャを定めたRFC 3411は、「エージェント」を単一のプログラムとしては定義していません。管理データへのアクセスを提供するコマンドレスポンダと、通知を送り出すノーティフィケーションオリジネーターのどちらか(または両方)を、SNMPエンジンと組み合わせたものを従来エージェントと呼んできた、と説明しています。要求に応答する部分とTRAPを送る部分は別の機能で、片方だけ実装しても構いません。
UDP 161番と162番、TRAPとINFORMの違い
ポートはRFC 3417の3.2節が、コマンドレスポンダはUDP 161番、通知を受け取る側はUDP 162番で待ち受けるよう提案しています。エージェントを作るとは、161番でUDPを待つプロセスを立てることです。エージェントから送る通知には2種類あり、RFC 3416の4.2.6節はSNMPv2-Trapに確認応答が無いことを、4.2.7節はInformRequestが受信側のResponseで確認を取る方式であることを定めています。障害通知を取りこぼしたくないならINFORMを選びますが、それでも配送そのものは保証されないため、マネージャー側の定期ポーリングと併用するのが前提です。
GETNEXTとGETBULKが返す「次」の決まり方
エージェントの実装で一番手間がかかるのは、GETではなくGETNEXTです。snmpwalkも監視ツールのテーブル取得も、内部ではGETNEXTかGETBULKを繰り返しているだけなので、ここが崩れると「GETは通るのにWALKが空」という症状になります。
GetNextRequestの辞書順と次のOIDの選択規則
RFC 3416の4.2.2節によると、GetNextRequestを受けたエージェントは、要求されたOIDに辞書順で最初に続く、アクセス可能な変数のOIDと値を返します。続く変数が無ければ、値をendOfMibViewとし、名前には要求されたOIDをそのまま入れて返します。要求したOIDそのものが存在している必要はありません。次の出力は、OIDを2つだけ持つエージェント(後述のGoSNMPServer版)に問い合わせた結果です。
$ snmpgetnext -v2c -c public -On 127.0.0.1:1161 1.3.6.1.4.1.99999.1
.1.3.6.1.4.1.99999.1.1.0 = STRING: "macbookpro.lan"
$ snmpgetnext -v2c -c public -On 127.0.0.1:1161 1.3.6.1.4.1.99999.1.2.0
.1.3.6.1.4.1.99999.1.2.0 = No more variables left in this MIB View (It is past the end of the MIB tree)
1つ目は存在しない枝1.3.6.1.4.1.99999.1を渡していますが、その直後にある.1.1.0が返っています。2つ目は最後のOIDを渡したので、endOfMibView(net-snmpの表示では「No more variables left in this MIB View」)が返り、snmpwalkはこれを見て終了します。
OIDの数値配列による比較と文字列比較の誤り
MIBは管理情報の木構造で、その節点を数値の並びで指したものがOIDです。ベンダー独自の値は、IANAから割り当てられた企業番号の下(1.3.6.1.4.1.企業番号)に枝を切って公開します。ここで「辞書順」を文字列の大小で実装すると壊れます。1.3.6.1.4.1.99999.1.10.0と1.3.6.1.4.1.99999.1.2.0を文字列で比べると、1が2より小さいため1.10が先に来てしまい、walkで1.2.0が飛ばされます。OIDはドットで分割し、各要素を数値として先頭から比べてください。公開するOIDの割り当てを機器台帳とそろえておきたい場合は、NetBoxのような管理ツールと突き合わせるとMIBの設計がぶれません。
GetBulkRequestのnon-repeatersとmax-repetitions
GETBULKは、GETNEXTを1往復で何回分もまとめて返させる要求で、SNMPv2c以降で使えます。RFC 3416の4.2.3節では、要求のvarbind数をV、non-repeatersをN(V以下に切り詰める)、max-repetitionsをMとして、先頭N個は「次」を1つずつ、残りR=V−N個は「次」を最大M個ずつ返すと定めています。応答のvarbindは最大でN+M×R個です。
$ snmpbulkget -v2c -c public -On -Cn0 -Cr5 127.0.0.1:1161 1.3.6.1.4.1.99999
.1.3.6.1.4.1.99999.1.1.0 = STRING: "macbookpro.lan"
.1.3.6.1.4.1.99999.1.2.0 = Timeticks: (113) 0:00:01.13
.1.3.6.1.4.1.99999.1.2.0 = No more variables left in this MIB View (It is past the end of the MIB tree)
N=0、M=5で1つのOIDを渡したので最大5個まで返せますが、配下に2つしか無いため、3個目でendOfMibViewになって打ち切られています。この例のように反復対象が1つなら、endOfMibViewの後を省略できます。複数の反復対象がある場合は、それぞれを処理し、全対象がendOfMibViewになった反復の後で打ち切れます。
gosnmpで自作した場合に自前になる範囲
「gosnmpにエージェント機能を追加する」という解説は少なくありません。v1.47.0のソースで確かめると、よく使われている関数では応答を組み立てられず、別の公開APIなら成り立ちます。どこまでがライブラリの守備範囲かを確認します。
SnmpEncodePacketが応答生成に使えない理由
パケットを作る関数として目に付くSnmpEncodePacketは、SnmpPacketを受け取らず、内部でRequestIDを振り直します。
// gosnmp v1.47.0 gosnmp.go:552
func (x *GoSNMP) SnmpEncodePacket(pdutype PDUType, pdus []SnmpPDU, nonRepeaters uint8, maxRepetitions uint32) ([]byte, error)
// gosnmp.go:561-563(関数内部の抜粋)
reqID := (atomic.AddUint32(&x.requestID, 1) & 0x7FFFFFFF)
pkt.RequestID = reqID
SNMPの応答は、マネージャーが送ってきたRequestIDをそのまま返すことで要求と対応づけられます。SnmpEncodePacketは内部カウンタで採番し直すので、こちらが指定した値は捨てられます。さらに、パケットを組み立てるmkSnmpPacket(gosnmp.go:453)がError: 0, ErrorIndex: 0を固定で書くため、エラー応答も返せません。名前に反して、この関数はエージェントには使えません。
MarshalMsgで応答を組み立てる最小コード
代わりに使えるのが、SnmpPacketを直接マーシャルする公開メソッドMarshalMsg(marshal.go:516)です。RequestIDもErrorも指定した値のまま通るので、gosnmpをforkする必要はありません。UDPの待ち受けループの中身は次のとおりです。
x := &gosnmp.GoSNMP{Version: gosnmp.Version2c, Community: "public"}
for {
n, peer, err := conn.ReadFromUDP(buf)
if err != nil {
continue
}
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)
}
$ 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.
Failed object: SNMPv2-SMI::enterprises.99999.9.9.9
$ snmpwalk -v2c -c public 127.0.0.1:1161 1.3.6.1.4.1.99999
End of MIB
この例は単一OIDの応答生成を示す学習用コードです。未定義OIDに返すnoSuchNameはSNMPv2cのGET処理として規格どおりではなく、複数varbindの処理、要求PDUの判別、コミュニティの照合も省いています。snmpwalkが「End of MIB」で終わるのはGETNEXT専用の処理が無いためで、既知のOIDを渡すと次ではなく同じOIDを返してしまいます。実用にするには、前章の辞書順の検索、GETBULKのN+M×Rの組み立て、SNMPv3なら要求の認証とEngineID・EngineBoots・EngineTimeの管理までを自分で書くことになります。この規模を考えると、学習目的以外で一から書く理由はありません。
TrapListenerとSendTrapの守備範囲
gosnmpのTrapListener(trap.go:119)はサーバーのように見えますが、エージェントが送ったTRAPを受け取るマネージャー側の部品で、GETには応答しません。逆にエージェントからTRAPを送る用途ではSendTrap(trap.go:34)がそのまま使えます。まとめると、gosnmpはMarshalMsgによる応答パケットの生成とSendTrapによる通知の送信には使えますが、要求の処理やMIBの管理まで備えたエージェントではありません。
Goでエージェントを実装する3方式の比較と選定基準
差が出るのは、snmpdを前提にするかどうかと、MIBの木を誰が受け持つかの2点です。
| 方式 | 追加依存 | snmpd | GETNEXTの実装 | 最新版 | 向く場面 |
|---|---|---|---|---|---|
| GoSNMPServer | slayercat/GoSNMPServer |
不要 | ライブラリ任せ | v0.5.2(master 2026-05-07) | 閉じたネットワークのv2c単体運用 |
| pass_persist | なし | 必要 | 自前 | net-snmp 5.9.5.2 | 既存サーバーへ値を数個追加 |
| AgentX | posteo/go-agentx |
必要 | ライブラリ任せ | v0.3.0(2025-08-22) | アプリ本体の状態を常駐で公開 |
最も導入が軽いのはpass_persistで、公開したい値が数個なら100行に収まります。snmpdを入れられない環境や、コンテナ1つで完結させたい場合はGoSNMPServerを選びますが、後述のとおりSNMPv3の認証は当てにできないため、v2cを閉じたネットワークで使う前提です。公開する値が少なく、別プロセスから取得できるならpass_persistを優先し、アプリ本体の状態を直接公開したい場合にAgentX方式を検討します。ただしGoのAgentX実装(posteo/go-agentx)は更新が止まっている点を織り込んでください。監視の仕組みを自社で持ち続ける体制まで含めて検討するなら、保守運用・内製化支援のように運用設計から相談できる窓口を使う方法もあります。
GoSNMPServerによる単体エージェントの実装と制約
GoSNMPServerは、SNMPのサーバー側をGoだけで実装したライブラリです。OIDごとにOnGetコールバックを登録する設計で、辞書順の検索やGETBULKの組み立てはライブラリが行います。リリースタグはv0.5.2(2024年3月27日)で止まっていますが、masterには2026年5月7日にGETBULKの「次」の扱いを直すコミットが入っているので、そのコミットに対応する疑似バージョンを指定して取り込みます。
依存するgosnmpのv1.47.0への更新と適用範囲
GoSNMPServerのgo.modは、gosnmpをv1.36.2-0.20231009064202-d306ed5aa998で固定しています。gosnmpは2026年10月23日のv1.47.0で、GHSA-w96x-5v55-24xx(深刻度critical、影響範囲1.46.0以下)を修正しました。認証フラグを落としたTRAPがHMAC検証を素通りする問題や、短い認証パラメータを持つ1パケットでプロセスがpanicする問題など6件で、対象はSNMPv3のトラップ受信とv3クライアントです。GoSNMPServerもv3の要求を同じUSM実装のSnmpDecodePacketで復号するので、gosnmpは上げておきます。ただし後述のとおり、更新だけではGoSNMPServerのSNMPv3認証は保証されません。
go get github.com/slayercat/[email protected]
go get github.com/gosnmp/[email protected]
go mod tidy
# go.mod に残る行(2026-11-02時点)
github.com/gosnmp/gosnmp v1.47.0
github.com/slayercat/GoSNMPServer v0.5.3-0.20260507060852-18e6381ccc44
新規ディレクトリでは先にgo mod init example.com/goagentを実行し、後述のコードをmain.goに保存してから上記コマンドを実行します。Goのモジュールは要求された中で最も高い版を選ぶため、自分のgo.modでv1.47.0を指定すればGoSNMPServerもその版でビルドされます。この組み合わせでgo vetとビルドが通り、v2cとv3のGET・GETNEXT・GETBULKが動くことを確認しました。
SNMPv3ユーザーを設定したエージェントのコード
ホスト名と起動からの経過時間を返す、OID 2つのローカル検証用エージェントです。AuthoritativeEngineBootsを1に固定しているため、本番ではEngineIDと起動回数を永続化し、SNMPエンジンの再起動時に起動回数を増やしてEngineTimeを初期化する実装が必要です。v2cのコミュニティpublicと、SHA認証・AES暗号のv3ユーザーmonitorの両方で受け付けます。
package main
import (
"log"
"os"
"time"
"github.com/gosnmp/gosnmp"
"github.com/slayercat/GoSNMPServer"
)
func main() {
start := time.Now()
master := GoSNMPServer.MasterAgent{
Logger: GoSNMPServer.NewDiscardLogger(),
SecurityConfig: GoSNMPServer.SecurityConfig{
AuthoritativeEngineBoots: 1,
Users: []gosnmp.UsmSecurityParameters{{
UserName: "monitor",
AuthenticationProtocol: gosnmp.SHA,
AuthenticationPassphrase: "authpass123",
PrivacyProtocol: gosnmp.AES,
PrivacyPassphrase: "privpass123",
}},
},
SubAgents: []*GoSNMPServer.SubAgent{{
CommunityIDs: []string{"public"},
OIDs: []*GoSNMPServer.PDUValueControlItem{
{
OID: "1.3.6.1.4.1.99999.1.1.0",
Type: gosnmp.OctetString,
OnGet: func() (interface{}, error) {
h, _ := os.Hostname()
return GoSNMPServer.Asn1OctetStringWrap(h), nil
},
},
{
OID: "1.3.6.1.4.1.99999.1.2.0",
Type: gosnmp.TimeTicks,
OnGet: func() (interface{}, error) {
return GoSNMPServer.Asn1TimeTicksWrap(uint32(time.Since(start) / (10 * time.Millisecond))), nil
},
},
},
}},
}
server := GoSNMPServer.NewSNMPServer(master)
if err := server.ListenUDP("udp", "127.0.0.1:1161"); err != nil {
log.Fatal(err)
}
server.ServeForever()
}
ロガーは最初からNewDiscardLogger()にしています。サンプルによく出てくるNewDefaultLogger()は、logrusのレベルをTraceにして標準出力へ書くため(logger.go:37)、要求のたびにパケットの中身がダンプされます。本番でログが必要ならWrapLogrus()でレベルを絞ったロガーを渡します。値の型はAsn1OctetStringWrap・Asn1TimeTicksWrapのほかAsn1IntegerWrap・Asn1Counter32Wrapなどのラッパーで合わせ、書き込みを許す項目にだけOnSetを付けます。OnSetが無い項目へのSETはreadOnlyで拒否されます。
GoSNMPServerのSNMPv3コンテキスト名とコミュニティIDの対応
v3で最初につまずくのがここです。GoSNMPServerは、v1/v2cではコミュニティ名、v3ではコンテキスト名を使って、どのSubAgentに振り分けるかを決めます(helpers.go:11〜17)。そのためv3の問い合わせでは、CommunityIDsに書いた値を-nでコンテキスト名として渡す必要があります。
# -n でコンテキスト名(= CommunityIDs の値)を渡す
$ snmpget -v3 -n public -l authPriv -u monitor -a SHA -A authpass123 -x AES -X privpass123 -On 127.0.0.1:1161 1.3.6.1.4.1.99999.1.1.0
.1.3.6.1.4.1.99999.1.1.0 = STRING: "macbookpro.lan"
# -n を省くと、認証情報が正しくても応答が返らない
$ snmpget -v3 -l authPriv -u monitor -a SHA -A authpass123 -x AES -X privpass123 -On 127.0.0.1:1161 1.3.6.1.4.1.99999.1.1.0
snmpget: Invalid message (e.g. msgFlags)
エラーメッセージがmsgFlagsを名指しするので認証設定を疑いがちですが、原因は振り分け先が見つからないことです。Zabbixなどの監視ツールからv3で取得する場合も、コンテキスト名の欄に同じ値を入れてください。なおSecurityConfigにNoSecurity: trueを指定するとコミュニティ名を確認しなくなり、SubAgentも1つしか持てません(agentcontrol.go:91)。試験用と割り切って使います。
GoSNMPServerのSNMPv3要求認証(HMAC)の欠落
本番でv3を使う前に知っておくべき制約があります。対象コミット(18e6381)のResponseForBufferは、要求のセキュリティレベル(msgFlags)がユーザーの設定と一致するかは確かめますが、メッセージのHMACは検証しません(agentcontrol.go:126〜186)。復号に使うSnmpDecodePacketも、gosnmpのクライアント受信処理(marshal.go:341)やトラップ受信(trap.go:588)と違って、HMACの検証を呼んでいません。gosnmpをv1.47.0へ上げても変わらない部分です。エージェントの前にUDPの中継を置き、応答パケットが返ったかどうかを数えた結果が次です(エンジン探索の往復は省略)。
# 正しい認証・暗号パスフレーズ
request 183 bytes -> response 197 bytes
# 認証パスフレーズだけを誤らせた要求
request 183 bytes -> response 197 bytes
# 暗号パスフレーズを誤らせた要求
request 183 bytes -> no response
認証パスフレーズを誤った要求にも、正しい要求と同じ197バイトの応答が返りました。net-snmpのsnmpgetは応答の署名が合わないので「Authentication failed for monitor」と表示して値を捨てますが、エージェントは値を送り出しています。暗号を誤った要求に応答が無いのは、復号に失敗するためです。つまりauthPrivのユーザーでは暗号パスフレーズだけが実質の鍵で、認証のみ(authNoPriv)のユーザーならコード上はユーザー名が合えば応答します。RFC 3414の再送対策に使うEngineBoots・EngineTimeも、前述のサンプルではEngineBootsを1に固定しています。EngineTimeは既定の関数がホストの稼働時間から作るため固定値ではありませんが、SNMPエンジンの再起動に連動した状態管理にはなっていません。GoSNMPServerのv3はローカル検証にとどめ、本番のSNMPv3処理はnet-snmpのsnmpdに任せてください。
LinuxのUDP 161番へのバインド権限
サンプルは非特権で試しやすい1161番を使います。Linuxの特権ポートの境界は既定で1024番ですが、ネットワーク名前空間ごとのip_unprivileged_port_start設定によって変わります。本番で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による独自OIDの追加
監視対象のサーバーですでにsnmpdが動いているなら、pass_persistが最短です。指定したOID配下への問い合わせが来ると、snmpdが外部プログラムを常駐起動して標準入出力でやり取りします。snmpd.confのマニュアルが定める手順は、起動時にPINGが来たらPONGを返し、以降はgetまたはgetnextとOIDの2行を受けて、OID・型・値の3行を返すというものです。返せないときはNONEを返します。次のコードは、OIDを2つ持ち、前章の数値比較でGETNEXTに答えます。
package main
import (
"bufio"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"time"
)
type item struct {
oid string
typ string // integer, gauge, counter, timeticks, ipaddress, objectid, string
value func() string
}
var start = time.Now()
// 辞書順に並べておく(getnextはこの順に「次」を探す)
var items = []item{
{".1.3.6.1.4.1.99999.1.2.0", "gauge", func() string { return strconv.Itoa(runtime.NumGoroutine()) }},
{".1.3.6.1.4.1.99999.1.10.0", "timeticks", func() string { return strconv.FormatUint(uint64(uint32(time.Since(start)/(10*time.Millisecond))), 10) }},
}
// compareOID はドット区切りの各要素を数値として先頭から比べる(文字列比較では 1.10 < 1.2 になる)
func compareOID(a, b string) int {
as := strings.Split(strings.Trim(a, "."), ".")
bs := strings.Split(strings.Trim(b, "."), ".")
for i := 0; i < len(as) && i < len(bs); i++ {
x, _ := strconv.Atoi(as[i])
y, _ := strconv.Atoi(bs[i])
if x != y {
if x < y {
return -1
}
return 1
}
}
return len(as) - len(bs)
}
func write(w *bufio.Writer, it item) {
fmt.Fprintf(w, "%s\n%s\n%s\n", it.oid, it.typ, it.value())
}
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", "getnext":
cmd := strings.TrimSpace(in.Text())
if !in.Scan() {
return
}
oid := strings.TrimSpace(in.Text())
found := false
for _, it := range items {
if (cmd == "get" && compareOID(it.oid, oid) == 0) ||
(cmd == "getnext" && compareOID(it.oid, oid) > 0) {
write(out, it)
found = true
break
}
}
if !found {
fmt.Fprintln(out, "NONE")
}
case "set":
// setはOIDと「型 値」の2行が続く。読み捨てないと以降の応答がずれる
if !in.Scan() || !in.Scan() {
return
}
fmt.Fprintln(out, "not-writable")
default:
fmt.Fprintln(out, "NONE")
}
out.Flush()
}
}
ビルドした実行ファイルをsnmpd.confに登録します。第1引数が担当するOIDの根、第2引数が実行ファイルの絶対パスです。下の試験では、これに加えてrocommunity public 127.0.0.1でループバックからの読み取りを許可し、snmpdをudp:127.0.0.1:11161で待ち受けさせています。
# /etc/snmp/snmpd.conf
pass_persist .1.3.6.1.4.1.99999 /usr/local/bin/goagent
snmpdを非特権ポート11161番で起動して問い合わせた結果です。walkは1.2.0、1.10.0の順に正しくたどり、未定義のOIDにはsnmpdがnoSuchInstanceを返しています。
$ snmpwalk -v2c -c public -On 127.0.0.1:11161 .1.3.6.1.4.1.99999
.1.3.6.1.4.1.99999.1.2.0 = Gauge32: 1
.1.3.6.1.4.1.99999.1.10.0 = Timeticks: (0) 0:00:00.00
$ snmpget -v2c -c public -On 127.0.0.1:11161 .1.3.6.1.4.1.99999.1.9.0
.1.3.6.1.4.1.99999.1.9.0 = No Such Instance currently exists at this OID
$ snmpset -v2c -c public -On 127.0.0.1:11161 .1.3.6.1.4.1.99999.1.2.0 i 5
Error in packet.
Reason: notWritable (That object does not support modification)
setの分岐は省略しないでください。マニュアルのとおりSETはset・OID・「型 値」の3行で届くので、残り2行を読み捨てずに1行だけ返すと、以降の応答がずれたまま戻りません。書き込みを許さないならnot-writableを返します。
AgentXサブエージェントを採用する条件
RFC 2741のAgentXでは、SNMPメッセージの送受信はマスターエージェント(snmpd)が担い、サブエージェントは自分が受け持つMIBの範囲を登録して、その範囲の操作だけを処理します。net-snmpではsnmpd.confにmaster agentxと書くと待ち受けが有効になり、既定の接続先はUnixドメインソケットの/var/agentx/masterです。pass_persistとの違いは常駐の仕方で、snmpdに子プロセスとして起こされるのではなく、自分のプロセスのままsnmpdへ接続します。接続数やキュー長のようにアプリ本体が持つ値をそのまま出したいなら、プロセスを分けずに済むAgentXが自然です。
Goで使えるposteo/go-agentxは、最終コミットが2025年8月22日で、2026年11月時点で1年以上更新がありません。その日にv0.2.2とv0.3.0が続けて打たれ、間にAPIを作り直すコミットとハンドラーにcontextを足すコミットが入っているため、v0.2.2以前を前提にした解説のコードはv0.3.0では動きません。本記事で実装例を載せないのはこのためです。GitHub Releasesの表示はv0.1.0(2020年11月)のままなので、版はタグで確認します。同じ日には、OIDを文字列順ではなく各要素の数値で比較するよう直すプルリクエスト(#11)も取り込まれており、前章の比較の問題はライブラリ側でも起きていました。採用するならv0.3.0に固定し、snmpdとの再接続を自分で試験してください。公開したい値が少なく更新頻度も低いなら、メトリクス監視の設計の観点で見てもpass_persistで十分です。
動作確認でつまずく失敗と症状
エージェントを立ち上げたら、GETとWALKの両方で確かめます。GETだけ通ってWALKが空なら、GETNEXTの実装を疑ってください。
# 単一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
- WALKが「End of MIB」で何も返らない:GETNEXTに未対応か、辞書順の比較が文字列になっている。
- v3だけ「Invalid message (e.g. msgFlags)」:GoSNMPServerでコンテキスト名(
-n)を渡していない。 - v2cなのに未定義OIDでエラー終了する:GoSNMPServerは未定義OIDのGETに、v2cでもerror-statusをnoSuchNameにして返す(agentcontrolSubAgent.go:234)。snmpdは
noSuchInstanceを入れた正常応答になる。監視ツールに登録するOIDは公開している範囲に限る。 - 「&GoSNMP.Conn is missing」:試験用クライアントをgosnmpで書くときに、
gosnmp.DefaultのTargetを設定せずConnect()も呼ばずにGet()した(marshal.go:454)。 - 161番で起動直後に落ちる:
setcapの付与漏れ。SNMPはUDPなので、ファイアウォールでTCPだけ開けていないかも確かめる。クラウド上の通信の切り分けはフロー監視の手段が使えます。
よくある質問
SNMPエージェントとは何ですか?
監視される側の機器やサーバーで動き、マネージャーからのGET・GETNEXT・SETに応答して自分の状態を返し、必要に応じてTRAPを送るプロセスです。RFC 3411では、応答するコマンドレスポンダと通知を送る機能の組み合わせとして説明されています。SNMP対応のネットワーク機器では組み込みのエージェントを使えます。サーバーでは既存エージェントを導入し、標準の監視項目で足りない場合に独自項目を追加します。
SNMPのGetNextRequestとは何ですか?
指定したOIDの、辞書順で次にある変数のOIDと値を返させる要求です。指定したOID自体は存在しなくても構いません。SNMPv2c/v3では、次が無ければendOfMibViewが返ります。SNMPv1ではnoSuchNameエラーになります。snmpwalkはGETNEXTを繰り返して配下の値を順に取得します。
gosnmpだけでSNMPエージェントは作れますか?
作れますが、実装範囲は広くなります。SnmpEncodePacketはRequestIDを振り直すため応答には使えず、公開メソッドのMarshalMsgでSnmpPacketを自分で組み立てます。GETに答えるだけなら数十行で動きますが、GETNEXTの探索やGETBULKの応答構成に加え、SNMPv3ではgosnmpのUSM実装を使いながら、要求認証・アクセス制御・エンジン状態管理をエージェントとして組み立てる必要があります。
SNMPエージェントはどのポートを使いますか?
待ち受けはUDPの161番です。エージェントからマネージャーへ送るTRAPとINFORMは162番宛てです。Linuxの既定設定では161番へのバインドにrootまたはCAP_NET_BIND_SERVICEが必要です。ただし、ネットワーク名前空間のip_unprivileged_port_start設定によっては非特権で利用できます。権限が必要な環境では、一例として実行ファイルにsetcap 'cap_net_bind_service=+ep'を付与します。
すでにnet-snmpのsnmpdが動いているサーバーでもGoでエージェントを追加できますか?
できます。その場合はpass_persistかAgentXが向いています。snmpdが担当するOID配下への問い合わせだけを自作プログラムに渡す形になるので、既存の監視設定を壊さずに独自の値を足せます。